• 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

92.34
/src/Uwave.js
1
import EventEmitter from 'node:events';
1✔
2
import { promisify } from 'node:util';
1✔
3
import mongoose from 'mongoose';
1✔
4
import Redis from 'ioredis';
1✔
5
import avvio from 'avvio';
1✔
6
import { pino } from 'pino';
1✔
7
import {
1✔
8
  CamelCasePlugin, Kysely, OperationNodeTransformer, PostgresDialect, SqliteDialect,
1✔
9
} from 'kysely';
1✔
10
import httpApi, { errorHandling } from './HttpApi.js';
1✔
11
import SocketServer from './SocketServer.js';
1✔
12
import { Source } from './Source.js';
1✔
13
import { i18n } from './locale.js';
1✔
14
import models from './models/index.js';
1✔
15
import configStore from './plugins/configStore.js';
1✔
16
import booth from './plugins/booth.js';
1✔
17
import chat from './plugins/chat.js';
1✔
18
import motd from './plugins/motd.js';
1✔
19
import playlists from './plugins/playlists.js';
1✔
20
import users from './plugins/users.js';
1✔
21
import bans from './plugins/bans.js';
1✔
22
import history from './plugins/history.js';
1✔
23
import acl from './plugins/acl.js';
1✔
24
import waitlist from './plugins/waitlist.js';
1✔
25
import passport from './plugins/passport.js';
1✔
26
import migrations from './plugins/migrations.js';
1✔
27
import lodash from 'lodash';
1✔
28

1✔
29
const DEFAULT_MONGO_URL = 'mongodb://localhost:27017/uwave';
1✔
30
const DEFAULT_REDIS_URL = 'redis://localhost:6379';
1✔
31

1✔
32
class UwCamelCasePlugin extends CamelCasePlugin {
1✔
33
  /**
1✔
34
   * @param {string} str
1✔
35
   * @override
1✔
36
   * @protected
1✔
37
   */
1✔
38
  camelCase(str) {
1✔
39
    return super.camelCase(str).replace(/Id$/, 'ID');
20,747✔
40
  }
20,747✔
41
}
1✔
42

1✔
43
class SqliteDateColumnsPlugin {
1✔
44
  /** @param {string[]} dateColumns */
1✔
45
  constructor(dateColumns) {
1✔
46
    this.dateColumns = new Set(dateColumns);
92✔
47
    this.transformer = new class extends OperationNodeTransformer {
92✔
48
      /** @param {import('kysely').ValueNode} node */
92✔
49
      transformValue(node) {
92✔
50
        if (node.value instanceof Date) {
13,696!
NEW
51
          return { ...node, value: node.value.toISOString() };
×
NEW
52
        }
×
53
        return node;
13,696✔
54
      }
13,696✔
55

92✔
56
      /** @param {import('kysely').PrimitiveValueListNode} node */
92✔
57
      transformPrimitiveValueList(node) {
92✔
58
        return {
1,953✔
59
          ...node,
1,953✔
60
          values: node.values.map((value) => {
1,953✔
61
            if (value instanceof Date) {
11,755✔
62
              return value.toISOString();
2✔
63
            }
2✔
64
            return value;
11,753✔
65
          }),
1,953✔
66
        };
1,953✔
67
      }
1,953✔
68

92✔
69
      /** @param {import('kysely').ColumnUpdateNode} node */
92✔
70
      transformColumnUpdate(node) {
92✔
71
        /**
678✔
72
         * @param {import('kysely').OperationNode} node
678✔
73
         * @returns {node is import('kysely').ValueNode}
678✔
74
         */
678✔
75
        function isValueNode(node) {
678✔
76
          return node.kind === 'ValueNode';
678✔
77
        }
678✔
78

678✔
79
        if (isValueNode(node.value) && node.value.value instanceof Date) {
678!
NEW
80
          return super.transformColumnUpdate({
×
NEW
81
            ...node,
×
NEW
82
            value: /** @type {import('kysely').ValueNode} */ ({
×
NEW
83
              ...node.value,
×
NEW
84
              value: node.value.value.toISOString(),
×
NEW
85
            }),
×
NEW
86
          });
×
NEW
87
        }
×
88
        return super.transformColumnUpdate(node);
678✔
89
      }
678✔
90
    }();
92✔
91
  }
92✔
92

1✔
93
  /** @param {import('kysely').PluginTransformQueryArgs} args */
1✔
94
  transformQuery(args) {
1✔
95
    return this.transformer.transformNode(args.node);
5,833✔
96
  }
5,833✔
97

1✔
98
  /** @param {string} col */
1✔
99
  #isDateColumn(col) {
1✔
100
    if (this.dateColumns.has(col)) {
20,747✔
101
      return true;
3,922✔
102
    }
3,922✔
103
    const i = col.lastIndexOf('.');
16,825✔
104
    return i !== -1 && this.dateColumns.has(col.slice(i));
20,747✔
105
  }
20,747✔
106

1✔
107
  /** @param {import('kysely').PluginTransformResultArgs} args */
1✔
108
  async transformResult(args) {
1✔
109
    for (const row of args.result.rows) {
5,833✔
110
      for (let col in row) {
2,369✔
111
        if (this.#isDateColumn(col)) {
20,747✔
112
          const value = row[col];
3,922✔
113
          if (typeof value === 'string') {
3,922✔
114
            row[col] = new Date(value);
3,919✔
115
          }
3,919✔
116
        }
3,922✔
117
      }
20,747✔
118
    }
2,369✔
119
    return args.result;
5,833✔
120
  }
5,833✔
121
}
1✔
122

1✔
123
/**
1✔
124
 * @typedef {import('./Source.js').SourcePlugin} SourcePlugin
1✔
125
 */
1✔
126

1✔
127
/**
1✔
128
 * @typedef {UwaveServer & avvio.Server<UwaveServer>} Boot
1✔
129
 * @typedef {Pick<
1✔
130
 *   import('ioredis').RedisOptions,
1✔
131
 *   'port' | 'host' | 'family' | 'path' | 'db' | 'password' | 'username' | 'tls'
1✔
132
 * >} RedisOptions
1✔
133
 * @typedef {{
1✔
134
 *   port?: number,
1✔
135
 *   sqlite?: string,
1✔
136
 *   mongo?: string,
1✔
137
 *   redis?: string | RedisOptions,
1✔
138
 *   logger?: import('pino').LoggerOptions,
1✔
139
 * } & import('./HttpApi.js').HttpApiOptions} Options
1✔
140
 */
1✔
141

1✔
142
class UwaveServer extends EventEmitter {
92✔
143
  /** @type {import('ioredis').default} */
92✔
144
  redis;
92✔
145

92✔
146
  /** @type {import('http').Server} */
92✔
147
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
148
  server;
92✔
149

92✔
150
  /** @type {import('express').Application} */
92✔
151
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
152
  express;
92✔
153

92✔
154
  /** @type {import('./models/index.js').Models} */
92✔
155
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
156
  models;
92✔
157

92✔
158
  /** @type {import('./plugins/acl.js').Acl} */
92✔
159
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
160
  acl;
92✔
161

92✔
162
  /** @type {import('./plugins/bans.js').Bans} */
92✔
163
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
164
  bans;
92✔
165

92✔
166
  /** @type {import('./plugins/booth.js').Booth} */
92✔
167
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
168
  booth;
92✔
169

92✔
170
  /** @type {import('./plugins/chat.js').Chat} */
92✔
171
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
172
  chat;
92✔
173

92✔
174
  /** @type {import('./plugins/configStore.js').ConfigStore} */
92✔
175
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
176
  config;
92✔
177

92✔
178
  /** @type {import('./plugins/emotes.js').Emotes|null} */
92✔
179
  emotes = null;
92✔
180

92✔
181
  /** @type {import('./plugins/history.js').HistoryRepository} */
92✔
182
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
183
  history;
92✔
184

92✔
185
  /** @type {import('./plugins/migrations.js').Migrate} */
92✔
186
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
187
  migrate;
92✔
188

92✔
189
  /** @type {import('./plugins/motd.js').MOTD} */
92✔
190
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
191
  motd;
92✔
192

92✔
193
  /** @type {import('./plugins/passport.js').Passport} */
92✔
194
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
195
  passport;
92✔
196

92✔
197
  /** @type {import('./plugins/playlists.js').PlaylistsRepository} */
92✔
198
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
199
  playlists;
92✔
200

92✔
201
  /** @type {import('./plugins/users.js').UsersRepository} */
92✔
202
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
203
  users;
92✔
204

92✔
205
  /** @type {import('./plugins/waitlist.js').Waitlist} */
92✔
206
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
207
  waitlist;
92✔
208

92✔
209
  /** @type {import('./HttpApi.js').HttpApi} */
92✔
210
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
211
  httpApi;
92✔
212

92✔
213
  /** @type {import('./SocketServer.js').default} */
92✔
214
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
215
  socketServer;
92✔
216

92✔
217
  /**
92✔
218
   * @type {Map<string, Source>}
92✔
219
   */
92✔
220
  #sources = new Map();
92✔
221

92✔
222
  /** @type {import('pino').Logger} */
92✔
223
  #mongoLogger;
92✔
224

92✔
225
  /**
92✔
226
   * @param {Options} options
92✔
227
   */
92✔
228
  constructor(options) {
92✔
229
    super();
92✔
230

92✔
231
    const boot = avvio(this);
92✔
232

92✔
233
    this.logger = pino({
92✔
234
      ...options.logger,
92✔
235
      redact: ['req.headers.cookie', 'res.headers["set-cookie"]'],
92✔
236
    });
92✔
237
    this.locale = i18n.cloneInstance();
92✔
238

92✔
239
    this.options = {
92✔
240
      mongo: DEFAULT_MONGO_URL,
92✔
241
      redis: DEFAULT_REDIS_URL,
92✔
242
      ...options,
92✔
243
    };
92✔
244

92✔
245
    /** @type {Kysely<import('./schema.js').Database>} */
92✔
246
    this.db = new Kysely({
92✔
247
      dialect: new SqliteDialect({
92✔
248
        async database() {
92✔
249
          const { default: Database } = await import('better-sqlite3');
92✔
250
          const db = new Database(options.sqlite ?? 'uwave_local.sqlite');
92!
251
          db.pragma('journal_mode = WAL');
92✔
252
          db.pragma('foreign_keys = ON');
92✔
253
          db.function('json_array_shuffle', { directOnly: true }, (items) => {
92✔
254
            if (typeof items !== 'string') {
1!
NEW
255
              throw new TypeError('json_array_shuffle(): items must be JSON string');
×
NEW
256
            }
×
257
            items = JSON.parse(items);
1✔
258
            if (!Array.isArray(items)) {
1!
NEW
259
              throw new TypeError('json_array_shuffle(): items must be JSON array');
×
NEW
260
            }
×
261
            return JSON.stringify(lodash.shuffle(items));
1✔
262
          });
92✔
263
          return db;
92✔
264
        },
92✔
265
      }),
92✔
266
      // dialect: new PostgresDialect({
92✔
267
      //   pool: new pg.Pool({
92✔
268
      //     user: 'uwave',
92✔
269
      //     database: 'uwave_test',
92✔
270
      //   }),
92✔
271
      // }),
92✔
272
      plugins: [
92✔
273
        new UwCamelCasePlugin(),
92✔
274
        new SqliteDateColumnsPlugin(['createdAt', 'updatedAt', 'expiresAt', 'playedAt', 'lastSeenAt']),
92✔
275
      ],
92✔
276
    });
92✔
277
    this.mongo = mongoose.createConnection(this.options.mongo);
92✔
278
    // mongoose.set('debug', true);
92✔
279

92✔
280
    if (typeof options.redis === 'string') {
92✔
281
      this.redis = new Redis(options.redis, { lazyConnect: true });
92✔
282
    } else {
92!
283
      this.redis = new Redis({ ...options.redis, lazyConnect: true });
×
284
    }
×
285

92✔
286
    this.#mongoLogger = this.logger.child({ ns: 'uwave:mongo' });
92✔
287

92✔
288
    this.configureRedis();
92✔
289
    this.configureMongoose();
92✔
290

92✔
291
    boot.onClose(() => Promise.all([
92✔
292
      this.redis.quit(),
92✔
293
      this.mongo.close(),
92✔
294
    ]));
92✔
295

92✔
296
    // Wait for the connections to be set up.
92✔
297
    boot.use(async () => {
92✔
298
      this.#mongoLogger.debug('waiting for mongodb...');
92✔
299
      await this.mongo.asPromise();
92✔
300
    });
92✔
301

92✔
302
    boot.use(models);
92✔
303
    boot.use(migrations);
92✔
304
    boot.use(configStore);
92✔
305

92✔
306
    boot.use(passport, {
92✔
307
      secret: this.options.secret,
92✔
308
    });
92✔
309

92✔
310
    // Initial API setup
92✔
311
    boot.use(httpApi, {
92✔
312
      secret: this.options.secret,
92✔
313
      helmet: this.options.helmet,
92✔
314
      mailTransport: this.options.mailTransport,
92✔
315
      recaptcha: this.options.recaptcha,
92✔
316
      createPasswordResetEmail: this.options.createPasswordResetEmail,
92✔
317
      onError: this.options.onError,
92✔
318
    });
92✔
319
    boot.use(SocketServer.plugin);
92✔
320

92✔
321
    boot.use(acl);
92✔
322
    boot.use(chat);
92✔
323
    boot.use(motd);
92✔
324
    boot.use(playlists);
92✔
325
    boot.use(users);
92✔
326
    boot.use(bans);
92✔
327
    boot.use(history);
92✔
328
    boot.use(waitlist);
92✔
329
    boot.use(booth);
92✔
330

92✔
331
    boot.use(errorHandling);
92✔
332
  }
92✔
333

92✔
334
  /**
92✔
335
   * An array of registered sources.
92✔
336
   *
92✔
337
   * @type {Source[]}
92✔
338
   */
92✔
339
  get sources() {
92✔
340
    return [...this.#sources.values()];
×
341
  }
×
342

92✔
343
  /**
92✔
344
   * Get or register a source plugin.
92✔
345
   * If the first parameter is a string, returns an existing source plugin.
92✔
346
   * Else, adds a source plugin and returns its wrapped source plugin.
92✔
347
   *
92✔
348
   * @typedef {((uw: UwaveServer, opts: object) => SourcePlugin)} SourcePluginFactory
92✔
349
   * @typedef {SourcePlugin | SourcePluginFactory} ToSourcePlugin
92✔
350
   * @param {string | Omit<ToSourcePlugin, 'default'> | { default: ToSourcePlugin }} sourcePlugin
92✔
351
   *     Source name or definition.
92✔
352
   *     When a string: Source type name.
92✔
353
   *     Used to signal where a given media item originated from.
92✔
354
   *     When a function or object: Source plugin or plugin factory.
92✔
355
   * @param {object} opts Options to pass to the source plugin. Only used if
92✔
356
   *     a source plugin factory was passed to `sourcePlugin`.
92✔
357
   */
92✔
358
  source(sourcePlugin, opts = {}) {
92✔
359
    if (typeof sourcePlugin === 'string') { // eslint-disable-line prefer-rest-params
121✔
360
      return this.#sources.get(sourcePlugin);
69✔
361
    }
69✔
362

52✔
363
    const sourceFactory = 'default' in sourcePlugin && sourcePlugin.default ? sourcePlugin.default : sourcePlugin;
121!
364
    if (typeof sourceFactory !== 'function' && typeof sourceFactory !== 'object') {
121!
365
      throw new TypeError(`Source plugin should be a function, got ${typeof sourceFactory}`);
×
366
    }
×
367

52✔
368
    const sourceDefinition = typeof sourceFactory === 'function'
52✔
369
      ? sourceFactory(this, opts)
121✔
370
      : sourceFactory;
121✔
371
    const sourceType = sourceDefinition.name;
121✔
372
    if (typeof sourceType !== 'string') {
121!
373
      throw new TypeError('Source plugin does not specify a name. It may be incompatible with this version of üWave.');
×
374
    }
×
375
    const newSource = new Source(this, sourceType, sourceDefinition);
52✔
376

52✔
377
    this.#sources.set(sourceType, newSource);
52✔
378

52✔
379
    return newSource;
52✔
380
  }
121✔
381

92✔
382
  /**
92✔
383
   * @private
92✔
384
   */
92✔
385
  configureRedis() {
92✔
386
    const log = this.logger.child({ ns: 'uwave:redis' });
92✔
387

92✔
388
    this.redis.on('error', (error) => {
92✔
389
      log.error(error);
×
390
      this.emit('redisError', error);
×
391
    });
92✔
392
    this.redis.on('reconnecting', () => {
92✔
393
      log.info('trying to reconnect...');
×
394
    });
92✔
395

92✔
396
    this.redis.on('end', () => {
92✔
397
      log.info('disconnected');
92✔
398
      this.emit('redisDisconnect');
92✔
399
    });
92✔
400

92✔
401
    this.redis.on('connect', () => {
92✔
402
      log.info('connected');
92✔
403
      this.emit('redisConnect');
92✔
404
    });
92✔
405
  }
92✔
406

92✔
407
  /**
92✔
408
   * @private
92✔
409
   */
92✔
410
  configureMongoose() {
92✔
411
    this.mongo.on('error', (error) => {
92✔
412
      this.#mongoLogger.error(error);
×
413
      this.emit('mongoError', error);
×
414
    });
92✔
415

92✔
416
    this.mongo.on('reconnected', () => {
92✔
417
      this.#mongoLogger.info('reconnected');
×
418
      this.emit('mongoReconnect');
×
419
    });
92✔
420

92✔
421
    this.mongo.on('disconnected', () => {
92✔
422
      this.#mongoLogger.info('disconnected');
92✔
423
      this.emit('mongoDisconnect');
92✔
424
    });
92✔
425

92✔
426
    this.mongo.on('connected', () => {
92✔
427
      this.#mongoLogger.info('connected');
92✔
428
      this.emit('mongoConnect');
92✔
429
    });
92✔
430
  }
92✔
431

92✔
432
  /**
92✔
433
   * Publish an event to the üWave channel.
92✔
434
   *
92✔
435
   * @template {keyof import('./redisMessages.js').ServerActionParameters} CommandName
92✔
436
   * @param {CommandName} command
92✔
437
   * @param {import('./redisMessages.js').ServerActionParameters[CommandName]} data
92✔
438
   */
92✔
439
  publish(command, data) {
92✔
440
    return this.redis.publish('uwave', JSON.stringify({ command, data }));
61✔
441
  }
61✔
442

92✔
443
  async listen() {
92✔
444
    /** @type {import('avvio').Avvio<this>} */
92✔
445
    // @ts-expect-error TS2322
92✔
446
    const boot = this; // eslint-disable-line @typescript-eslint/no-this-alias
92✔
447
    await boot.ready();
92✔
448

92✔
449
    /** @type {(this: import('net').Server, port?: number) => Promise<void>} */
92✔
450
    const listen = promisify(this.server.listen);
92✔
451
    await listen.call(this.server, this.options.port);
92✔
452
  }
92✔
453
}
92✔
454

1✔
455
export default UwaveServer;
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