• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

u-wave / core / 20825751473

08 Jan 2026 05:28PM UTC coverage: 86.058% (+0.006%) from 86.052%
20825751473

push

github

web-flow
Tighten up internal types in error handling (#738)

1009 of 1205 branches covered (83.73%)

Branch coverage included in aggregate %.

36 of 38 new or added lines in 5 files covered. (94.74%)

10565 of 12244 relevant lines covered (86.29%)

100.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.89
/src/plugins/configStore.js
1
import assert from 'node:assert';
1✔
2
import fs from 'node:fs';
1✔
3
import EventEmitter from 'node:events';
1✔
4
import Ajv from 'ajv/dist/2019.js';
1✔
5
import formats from 'ajv-formats';
1✔
6
import jsonMergePatch from 'json-merge-patch';
1✔
7
import sjson from 'secure-json-parse';
1✔
8
import ValidationError from '../errors/ValidationError.js';
1✔
9
import { sql } from 'kysely';
1✔
10
import { fromJson, json, jsonb } from '../utils/sqlite.js';
1✔
11

1✔
12
/**
1✔
13
 * @typedef {import('type-fest').JsonObject} JsonObject
1✔
14
 * @typedef {import('../schema.js').UserID} UserID
1✔
15
 * @typedef {import('../schema.js').User} User
1✔
16
 */
1✔
17

1✔
18
const CONFIG_UPDATE_MESSAGE = 'configStore:update';
1✔
19

1✔
20
/**
1✔
21
 * Extensible configuration store.
1✔
22
 *
1✔
23
 * The config store contains named groups of settings. Each setting group is
1✔
24
 * stored in its own MongoDB Document. Groups have associated JSON Schemas to
1✔
25
 * check that the configuration is correct.
1✔
26
 */
1✔
27
class ConfigStore {
1✔
28
  #uw;
161✔
29

161✔
30
  #logger;
161✔
31

161✔
32
  #subscriber;
161✔
33

161✔
34
  #ajv;
161✔
35

161✔
36
  #emitter = new EventEmitter();
161✔
37

161✔
38
  /** @type {Map<string, import('ajv').ValidateFunction<unknown>>} */
161✔
39
  #validators = new Map();
161✔
40

161✔
41
  /**
161✔
42
   * @param {import('../Uwave.js').Boot} uw
161✔
43
   */
161✔
44
  constructor(uw) {
161✔
45
    this.#uw = uw;
161✔
46
    this.#logger = uw.logger.child({ ns: 'uwave:config' });
161✔
47
    this.#subscriber = uw.redis.duplicate();
161✔
48
    this.#ajv = new Ajv({
161✔
49
      useDefaults: true,
161✔
50
      // Allow unknown keywords (`uw:xyz`)
161✔
51
      strict: false,
161✔
52
      strictTypes: true,
161✔
53
    });
161✔
54
    formats(this.#ajv);
161✔
55
    this.#ajv.addMetaSchema(JSON.parse(
161✔
56
      fs.readFileSync(new URL('../../node_modules/ajv/dist/refs/json-schema-draft-07.json', import.meta.url), 'utf8'),
161✔
57
    ));
161✔
58
    this.#ajv.addSchema(JSON.parse(
161✔
59
      fs.readFileSync(new URL('../schemas/definitions.json', import.meta.url), 'utf8'),
161✔
60
    ));
161✔
61

161✔
62
    this.#subscriber.on('message', (_channel, command) => {
161✔
63
      this.#onServerMessage(command);
248✔
64
    });
161✔
65

161✔
66
    uw.use(async () => this.#subscriber.subscribe('uwave'));
161✔
67
  }
161✔
68

161✔
69
  /**
161✔
70
   * @param {string} rawCommand
161✔
71
   */
161✔
72
  async #onServerMessage(rawCommand) {
161✔
73
    /**
248✔
74
     * @type {undefined|{
248✔
75
     *   command: string,
248✔
76
     *   data: import('../redisMessages.js').ServerActionParameters['configStore:update'],
248✔
77
     * }}
248✔
78
     */
248✔
79
    const json = sjson.safeParse(rawCommand);
248✔
80
    if (!json) {
248!
81
      return;
×
82
    }
×
83
    const { command, data } = json;
248✔
84
    if (command !== CONFIG_UPDATE_MESSAGE) {
248✔
85
      return;
237✔
86
    }
237✔
87

11✔
88
    this.#logger.trace({ command, data }, 'handle config update');
11✔
89

11✔
90
    try {
11✔
91
      const updatedSettings = await this.get(data.key);
11✔
92
      this.#emitter.emit(data.key, updatedSettings, data.user, data.patch);
11✔
93
    } catch (error) {
248!
94
      this.#logger.error({ err: error }, 'could not retrieve settings after update');
×
95
    }
×
96
  }
248✔
97

161✔
98
  /**
161✔
99
   * @template {JsonObject} TSettings
161✔
100
   * @param {string} key
161✔
101
   * @param {(settings: TSettings, user: UserID|null, patch: Partial<TSettings>) => void} listener
161✔
102
   */
161✔
103
  subscribe(key, listener) {
161✔
104
    this.#emitter.on(key, listener);
484✔
105
    return () => this.#emitter.off(key, listener);
484✔
106
  }
484✔
107

161✔
108
  /**
161✔
109
   * @param {string} name
161✔
110
   * @param {JsonObject} value
161✔
111
   * @returns {Promise<JsonObject|null>} The old values.
161✔
112
   */
161✔
113
  async #save(name, value) {
161✔
114
    const { db } = this.#uw;
11✔
115

11✔
116
    const previous = await db.transaction().execute(async (tx) => {
11✔
117
      const row = await tx.selectFrom('configuration')
11✔
118
        .select((eb) => json(eb.ref('value')).as('value'))
11✔
119
        .where('name', '=', name)
11✔
120
        .executeTakeFirst();
11✔
121

11✔
122
      await tx.insertInto('configuration')
11✔
123
        .values({ name, value: jsonb(value) })
11✔
124
        .onConflict((oc) => oc.column('name').doUpdateSet({ value: jsonb(value) }))
11✔
125
        .execute();
11✔
126

11✔
127
      return row?.value != null ? fromJson(row.value) : null;
11✔
128
    });
11✔
129

11✔
130
    return previous;
11✔
131
  }
11✔
132

161✔
133
  /**
161✔
134
   * @param {string} key
161✔
135
   * @returns {Promise<JsonObject|null>}
161✔
136
   */
161✔
137
  async #load(key) {
161✔
138
    const { db } = this.#uw;
409✔
139

409✔
140
    const row = await db.selectFrom('configuration')
409✔
141
      .select((eb) => json(eb.ref('value')).as('value'))
409✔
142
      .where('name', '=', key)
409✔
143
      .executeTakeFirst();
409✔
144
    if (row == null || row.value == null) {
409✔
145
      return null;
384✔
146
    }
384✔
147

25✔
148
    return fromJson(row.value);
25✔
149
  }
409✔
150

161✔
151
  /**
161✔
152
   * Add a config group.
161✔
153
   *
161✔
154
   * @param {string} key - The name of the config group.
161✔
155
   * @param {import('ajv').SchemaObject} schema - The JSON schema that the settings must
161✔
156
   *     follow.
161✔
157
   * @public
161✔
158
   */
161✔
159
  register(key, schema) {
161✔
160
    this.#validators.set(key, this.#ajv.compile(schema));
644✔
161
  }
644✔
162

161✔
163
  /**
161✔
164
   * Get the current settings for a config group.
161✔
165
   *
161✔
166
   * @param {string} key
161✔
167
   * @returns {Promise<undefined | JsonObject>}
161✔
168
   *     `undefined` if the config group named `key` does not
161✔
169
   *     exist. An object containing current settings otherwise.
161✔
170
   * @public
161✔
171
   */
161✔
172
  async get(key) {
161✔
173
    const validate = this.#validators.get(key);
409✔
174
    if (!validate) {
409!
175
      return undefined;
×
176
    }
×
177

409✔
178
    const config = (await this.#load(key)) ?? {};
409✔
179
    // Allowed to fail--just fills in defaults
409✔
180
    validate(config);
409✔
181

409✔
182
    return config;
409✔
183
  }
409✔
184

161✔
185
  /**
161✔
186
   * Update settings for a config group. Optionally specify the user who is updating the settings.
161✔
187
   *
161✔
188
   * Rejects if the settings do not follow the schema for the config group.
161✔
189
   *
161✔
190
   * @param {string} key
161✔
191
   * @param {JsonObject} settings
161✔
192
   * @param {{ user?: User }} [options]
161✔
193
   * @public
161✔
194
   */
161✔
195
  async set(key, settings, options = {}) {
161✔
196
    const { user } = options;
11✔
197
    const validate = this.#validators.get(key);
11✔
198
    if (validate) {
11✔
199
      if (!validate(settings)) {
11!
NEW
200
        assert(validate.errors, 'Unexpected null');
×
201
        this.#logger.trace({ key, errors: validate.errors }, 'config validation error');
×
202
        throw new ValidationError(validate.errors, this.#ajv);
×
203
      }
×
204
    }
11✔
205

11✔
206
    const oldSettings = await this.#save(key, settings);
11✔
207
    const patch = jsonMergePatch.generate(oldSettings, settings) ?? Object.create(null);
11!
208

11✔
209
    this.#logger.trace({ key, patch }, 'fire config update');
11✔
210
    await this.#uw.publish(CONFIG_UPDATE_MESSAGE, {
11✔
211
      key,
11✔
212
      user: user ? user.id : null,
11✔
213
      patch,
11✔
214
    });
11✔
215
  }
11✔
216

161✔
217
  /**
161✔
218
   * Get *all* settings.
161✔
219
   *
161✔
220
   * @returns {Promise<{ [key: string]: JsonObject }>}
161✔
221
   */
161✔
222
  async getAllConfig() {
161✔
223
    const { db } = this.#uw;
×
224

×
225
    const results = await db.selectFrom('configuration')
×
226
      .select(['name', sql`json(value)`.as('value')])
×
227
      .execute();
×
228

×
229
    const configs = Object.create(null);
×
230
    for (const [key, validate] of this.#validators.entries()) {
×
231
      const row = results.find((m) => m.name === key);
×
232
      if (row) {
×
233
        const value = JSON.parse(/** @type {string} */ (row.value));
×
234
        validate(value);
×
235
        configs[key] = value;
×
236
      } else {
×
237
        configs[key] = {};
×
238
      }
×
239
    }
×
240
    return configs;
×
241
  }
×
242

161✔
243
  /**
161✔
244
   * @returns {import('ajv').SchemaObject}
161✔
245
   */
161✔
246
  getSchema() {
161✔
247
    const properties = Object.create(null);
×
248
    const required = [];
×
249
    for (const [key, validate] of this.#validators.entries()) {
×
250
      properties[key] = validate.schema;
×
251
      required.push(key);
×
252
    }
×
253

×
254
    return {
×
255
      type: 'object',
×
256
      properties,
×
257
      required,
×
258
    };
×
259
  }
×
260

161✔
261
  async destroy() {
161✔
262
    await this.#subscriber.quit();
161✔
263
  }
161✔
264
}
161✔
265

1✔
266
/**
1✔
267
 * @param {import('../Uwave.js').Boot} uw
1✔
268
 */
1✔
269
async function configStorePlugin(uw) {
161✔
270
  uw.config = new ConfigStore(uw);
161✔
271
  uw.onClose(() => uw.config.destroy());
161✔
272
}
161✔
273

1✔
274
export default configStorePlugin;
1✔
275
export { ConfigStore };
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc