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

u-wave / core / 11085094286

28 Sep 2024 03:39PM UTC coverage: 79.715% (-0.4%) from 80.131%
11085094286

Pull #637

github

web-flow
Merge 11ccf3b06 into 14c162f19
Pull Request #637: Switch to a relational database, closes #549

751 of 918 branches covered (81.81%)

Branch coverage included in aggregate %.

1891 of 2530 new or added lines in 50 files covered. (74.74%)

13 existing lines in 7 files now uncovered.

9191 of 11554 relevant lines covered (79.55%)

68.11 hits per line

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

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

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

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

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

92✔
29
  #logger;
92✔
30

92✔
31
  #subscriber;
92✔
32

92✔
33
  #ajv;
92✔
34

92✔
35
  #emitter = new EventEmitter();
92✔
36

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

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

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

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

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

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

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

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

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

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

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

1✔
126
      return row?.value != null ? JSON.parse(/** @type {string} */ (row.value)) : null;
1!
127
    });
1✔
128

1✔
129
    return previous;
1✔
130
  }
1✔
131

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

203✔
139
    const row = await db.selectFrom('configuration')
203✔
140
      .select(sql`json(value)`.as('value'))
203✔
141
      .where('name', '=', key)
203✔
142
      .executeTakeFirst();
203✔
143
    if (!row) {
203✔
144
      return null;
202✔
145
    }
202✔
146

1✔
147
    return JSON.parse(/** @type {string} */ (row.value));
1✔
148
  }
203✔
149

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

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

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

203✔
180
    return config;
203✔
181
  }
203✔
182

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

1✔
203
    const oldSettings = await this.#save(key, settings);
1✔
204
    const patch = jsonMergePatch.generate(oldSettings, settings) ?? Object.create(null);
1!
205

1✔
206
    this.#logger.trace({ key, patch }, 'fire config update');
1✔
207
    await this.#uw.publish(CONFIG_UPDATE_MESSAGE, {
1✔
208
      key,
1✔
209
      user: user ? user.id : null,
1!
210
      patch,
1✔
211
    });
1✔
212
  }
1✔
213

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

×
NEW
222
    const results = await db.selectFrom('configuration')
×
NEW
223
      .select(['name', sql`json(value)`.as('value')])
×
NEW
224
      .execute();
×
225

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

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

×
251
    return {
×
252
      type: 'object',
×
253
      properties,
×
254
      required,
×
255
    };
×
256
  }
×
257

92✔
258
  async destroy() {
92✔
259
    await this.#subscriber.quit();
92✔
260
  }
92✔
261
}
92✔
262

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

1✔
271
export default configStorePlugin;
1✔
272
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

© 2025 Coveralls, Inc