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

u-wave / core / 25866600106

14 May 2026 02:44PM UTC coverage: 68.408% (+0.04%) from 68.367%
25866600106

push

github

web-flow
Fill in default config values when key is missing from database (#776)

686 of 1171 branches covered (58.58%)

Branch coverage included in aggregate %.

5 of 6 new or added lines in 1 file covered. (83.33%)

2025 of 2792 relevant lines covered (72.53%)

174.5 hits per line

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

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

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

17
const CONFIG_UPDATE_MESSAGE = 'configStore:update';
14✔
18

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

29
  #logger;
30

31
  #ajv;
32

33
  #emitter = new EventEmitter();
180✔
34

35
  #unsubscribe;
36

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

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

60
    this.#unsubscribe = uw.events.on(CONFIG_UPDATE_MESSAGE, async (data) => {
180✔
61
      this.#logger.trace({ data }, 'handle config update');
14✔
62

63
      try {
14✔
64
        const updatedSettings = await this.get(data.key);
14✔
65
        this.#emitter.emit(data.key, updatedSettings, data.user, data.patch);
14✔
66
      } catch (error) {
67
        this.#logger.error({ err: error }, 'could not retrieve settings after update');
×
68
      }
69
    });
70
  }
71

72
  /**
73
   * @template {JsonObject} TSettings
74
   * @param {string} key
75
   * @param {(settings: TSettings, user: UserID|null, patch: Partial<TSettings>) => void} listener
76
   */
77
  subscribe(key, listener) {
78
    this.#emitter.on(key, listener);
541✔
79
    return () => this.#emitter.off(key, listener);
541✔
80
  }
81

82
  /**
83
   * @param {string} name
84
   * @param {JsonObject} value
85
   * @returns {Promise<JsonObject|null>} The old values.
86
   */
87
  async #save(name, value) {
88
    const { db } = this.#uw;
14✔
89

90
    const previous = await db.transaction().execute(async (tx) => {
14✔
91
      const row = await tx.selectFrom('configuration')
14✔
92
        .select((eb) => json(eb.ref('value')).as('value'))
14✔
93
        .where('name', '=', name)
94
        .executeTakeFirst();
95

96
      await tx.insertInto('configuration')
14✔
97
        .values({ name, value: jsonb(value) })
98
        .onConflict((oc) => oc.column('name').doUpdateSet({ value: jsonb(value) }))
14✔
99
        .execute();
100

101
      return row?.value != null ? fromJson(row.value) : null;
14✔
102
    });
103

104
    return previous;
14✔
105
  }
106

107
  /**
108
   * @param {string} key
109
   * @returns {Promise<JsonObject|null>}
110
   */
111
  async #load(key) {
112
    const { db } = this.#uw;
458✔
113

114
    const row = await db.selectFrom('configuration')
458✔
115
      .select((eb) => json(eb.ref('value')).as('value'))
458✔
116
      .where('name', '=', key)
117
      .executeTakeFirst();
118
    if (row == null || row.value == null) {
458✔
119
      return null;
429✔
120
    }
121

122
    return fromJson(row.value);
29✔
123
  }
124

125
  /**
126
   * Add a config group.
127
   *
128
   * @param {string} key - The name of the config group.
129
   * @param {import('ajv').SchemaObject} schema - The JSON schema that the settings must
130
   *     follow.
131
   * @public
132
   */
133
  register(key, schema) {
134
    const validate = this.#ajv.compile(schema);
720✔
135

136
    if (!validate({})) {
720!
NEW
137
      throw new Error(`configStore.register: configuration schema ${key} must support a default object`);
×
138
    }
139

140
    this.#validators.set(key, validate);
720✔
141
  }
142

143
  /**
144
   * Get the current settings for a config group.
145
   *
146
   * @param {string} key
147
   * @returns {Promise<undefined | JsonObject>}
148
   *     `undefined` if the config group named `key` does not
149
   *     exist. An object containing current settings otherwise.
150
   * @public
151
   */
152
  async get(key) {
153
    const validate = this.#validators.get(key);
460✔
154
    if (!validate) {
460✔
155
      return undefined;
2✔
156
    }
157

158
    const config = (await this.#load(key)) ?? {};
458✔
159
    // Allowed to fail--just fills in defaults
160
    validate(config);
460✔
161

162
    return config;
460✔
163
  }
164

165
  /**
166
   * Update settings for a config group. Optionally specify the user who is updating the settings.
167
   *
168
   * Rejects if the settings do not follow the schema for the config group.
169
   *
170
   * @param {string} key
171
   * @param {JsonObject} settings
172
   * @param {{ user?: User }} [options]
173
   * @public
174
   */
175
  async set(key, settings, options = {}) {
15✔
176
    const { user } = options;
15✔
177
    const validate = this.#validators.get(key);
15✔
178
    if (validate) {
15!
179
      if (!validate(settings)) {
15✔
180
        assert(validate.errors, 'Unexpected null');
1✔
181
        this.#logger.trace({ key, errors: validate.errors }, 'config validation error');
1✔
182
        throw new ValidationError(validate.errors, this.#ajv);
1✔
183
      }
184
    }
185

186
    const oldSettings = await this.#save(key, settings);
14✔
187
    const patch = jsonMergePatch.generate(oldSettings, settings) ?? Object.create(null);
14!
188

189
    this.#logger.trace({ key, patch }, 'fire config update');
15✔
190
    await this.#uw.publish(CONFIG_UPDATE_MESSAGE, {
15✔
191
      key,
192
      user: user ? user.id : null,
14✔
193
      patch,
194
    });
195
  }
196

197
  /**
198
   * Get *all* settings.
199
   *
200
   * @returns {Promise<{ [key: string]: JsonObject }>}
201
   */
202
  async getAllConfig() {
203
    const { db } = this.#uw;
3✔
204

205
    const results = await db.selectFrom('configuration')
3✔
206
      .select(['name', sql`json(value)`.as('value')])
207
      .execute();
208

209
    const configs = Object.create(null);
3✔
210
    for (const [key, validate] of this.#validators.entries()) {
3✔
211
      const row = results.find((m) => m.name === key);
12✔
212
      configs[key] = row ? JSON.parse(/** @type {string} */ (row.value)) : {};
12!
213
      // Let the validator fill in defaults
214
      validate(configs[key]);
12✔
215
    }
216
    return configs;
3✔
217
  }
218

219
  /**
220
   * @returns {import('ajv').SchemaObject}
221
   */
222
  getSchema() {
223
    const properties = Object.create(null);
13✔
224
    const required = [];
13✔
225
    for (const [key, validate] of this.#validators.entries()) {
13✔
226
      properties[key] = validate.schema;
52✔
227
      required.push(key);
52✔
228
    }
229

230
    return {
13✔
231
      type: 'object',
232
      properties,
233
      required,
234
    };
235
  }
236

237
  destroy() {
238
    this.#unsubscribe();
180✔
239
  }
240
}
241

242
/**
243
 * @param {import('../Uwave.js').Boot} uw
244
 */
245
async function configStorePlugin(uw) {
246
  uw.config = new ConfigStore(uw);
180✔
247
  uw.onClose(async () => uw.config.destroy());
180✔
248
}
249

250
export default configStorePlugin;
251
export { ConfigStore };
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