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

u-wave / core / 11980840475

22 Nov 2024 10:04PM UTC coverage: 78.492% (-1.7%) from 80.158%
11980840475

Pull #637

github

goto-bus-stop
ci: add node 22
Pull Request #637: Switch to a relational database

757 of 912 branches covered (83.0%)

Branch coverage included in aggregate %.

2001 of 2791 new or added lines in 52 files covered. (71.69%)

9 existing lines in 7 files now uncovered.

8666 of 11093 relevant lines covered (78.12%)

70.72 hits per line

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

83.88
/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);
67✔
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
    /**
67✔
73
     * @type {undefined|{
67✔
74
     *   command: string,
67✔
75
     *   data: import('../redisMessages.js').ServerActionParameters['configStore:update'],
67✔
76
     * }}
67✔
77
     */
67✔
78
    const json = sjson.safeParse(rawCommand);
67✔
79
    if (!json) {
67!
80
      return;
×
81
    }
×
82
    const { command, data } = json;
67✔
83
    if (command !== CONFIG_UPDATE_MESSAGE) {
67✔
84
      return;
66✔
85
    }
66✔
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) {
67!
93
      this.#logger.error({ err: error }, 'could not retrieve settings after update');
×
94
    }
×
95
  }
67✔
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;
210✔
138

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

1✔
147
    return JSON.parse(/** @type {string} */ (row.value));
1✔
148
  }
210✔
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));
368✔
160
  }
368✔
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>}
92✔
167
   *     `undefined` if the config group named `key` does not
92✔
168
   *     exist. An object containing current settings otherwise.
92✔
169
   * @public
92✔
170
   */
92✔
171
  async get(key) {
92✔
172
    const validate = this.#validators.get(key);
210✔
173
    if (!validate) {
210!
NEW
174
      return undefined;
×
NEW
175
    }
×
176

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

210✔
181
    return config;
210✔
182
  }
210✔
183

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

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

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

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

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

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

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

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

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

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

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