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

u-wave / core / 11166076478

03 Oct 2024 04:42PM UTC coverage: 78.708% (-1.4%) from 80.131%
11166076478

Pull #637

github

goto-bus-stop
Remove mongo from tests
Pull Request #637: Switch to a relational database, closes #549

734 of 894 branches covered (82.1%)

Branch coverage included in aggregate %.

1874 of 2561 new or added lines in 51 files covered. (73.17%)

11 existing lines in 6 files now uncovered.

8622 of 10993 relevant lines covered (78.43%)

70.85 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

92✔
152
  /** @type {import('./plugins/acl.js').Acl} */
92✔
153
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
154
  acl;
92✔
155

92✔
156
  /** @type {import('./plugins/bans.js').Bans} */
92✔
157
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
158
  bans;
92✔
159

92✔
160
  /** @type {import('./plugins/booth.js').Booth} */
92✔
161
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
162
  booth;
92✔
163

92✔
164
  /** @type {import('./plugins/chat.js').Chat} */
92✔
165
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
166
  chat;
92✔
167

92✔
168
  /** @type {import('./plugins/configStore.js').ConfigStore} */
92✔
169
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
170
  config;
92✔
171

92✔
172
  /** @type {import('./plugins/emotes.js').Emotes|null} */
92✔
173
  emotes = null;
92✔
174

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

92✔
179
  /** @type {import('./plugins/migrations.js').Migrate} */
92✔
180
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
181
  migrate;
92✔
182

92✔
183
  /** @type {import('./plugins/motd.js').MOTD} */
92✔
184
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
185
  motd;
92✔
186

92✔
187
  /** @type {import('./plugins/passport.js').Passport} */
92✔
188
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
189
  passport;
92✔
190

92✔
191
  /** @type {import('./plugins/playlists.js').PlaylistsRepository} */
92✔
192
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
193
  playlists;
92✔
194

92✔
195
  /** @type {import('./plugins/users.js').UsersRepository} */
92✔
196
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
197
  users;
92✔
198

92✔
199
  /** @type {import('./plugins/waitlist.js').Waitlist} */
92✔
200
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
201
  waitlist;
92✔
202

92✔
203
  /** @type {import('./HttpApi.js').HttpApi} */
92✔
204
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
205
  httpApi;
92✔
206

92✔
207
  /** @type {import('./SocketServer.js').default} */
92✔
208
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
209
  socketServer;
92✔
210

92✔
211
  /**
92✔
212
   * @type {Map<string, Source>}
92✔
213
   */
92✔
214
  #sources = new Map();
92✔
215

92✔
216
  /**
92✔
217
   * @param {Options} options
92✔
218
   */
92✔
219
  constructor(options) {
92✔
220
    super();
92✔
221

92✔
222
    const boot = avvio(this);
92✔
223

92✔
224
    this.logger = pino({
92✔
225
      ...options.logger,
92✔
226
      redact: ['req.headers.cookie', 'res.headers["set-cookie"]'],
92✔
227
    });
92✔
228
    this.locale = i18n.cloneInstance();
92✔
229

92✔
230
    this.options = {
92✔
231
      mongo: DEFAULT_MONGO_URL,
92✔
232
      redis: DEFAULT_REDIS_URL,
92✔
233
      ...options,
92✔
234
    };
92✔
235

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

92✔
269
    if (typeof options.redis === 'string') {
92✔
270
      this.redis = new Redis(options.redis, { lazyConnect: true });
92✔
271
    } else {
92!
272
      this.redis = new Redis({ ...options.redis, lazyConnect: true });
×
273
    }
×
274

92✔
275
    this.configureRedis();
92✔
276

92✔
277
    boot.onClose(() => Promise.all([
92✔
278
      this.redis.quit(),
92✔
279
    ]));
92✔
280

92✔
281
    boot.use(migrations);
92✔
282
    boot.use(configStore);
92✔
283

92✔
284
    boot.use(passport, {
92✔
285
      secret: this.options.secret,
92✔
286
    });
92✔
287

92✔
288
    // Initial API setup
92✔
289
    boot.use(httpApi, {
92✔
290
      secret: this.options.secret,
92✔
291
      helmet: this.options.helmet,
92✔
292
      mailTransport: this.options.mailTransport,
92✔
293
      recaptcha: this.options.recaptcha,
92✔
294
      createPasswordResetEmail: this.options.createPasswordResetEmail,
92✔
295
      onError: this.options.onError,
92✔
296
    });
92✔
297
    boot.use(SocketServer.plugin);
92✔
298

92✔
299
    boot.use(acl);
92✔
300
    boot.use(chat);
92✔
301
    boot.use(motd);
92✔
302
    boot.use(playlists);
92✔
303
    boot.use(users);
92✔
304
    boot.use(bans);
92✔
305
    boot.use(history);
92✔
306
    boot.use(waitlist);
92✔
307
    boot.use(booth);
92✔
308

92✔
309
    boot.use(errorHandling);
92✔
310
  }
92✔
311

92✔
312
  /**
92✔
313
   * An array of registered sources.
92✔
314
   *
92✔
315
   * @type {Source[]}
92✔
316
   */
92✔
317
  get sources() {
92✔
318
    return [...this.#sources.values()];
×
319
  }
×
320

92✔
321
  /**
92✔
322
   * Get or register a source plugin.
92✔
323
   * If the first parameter is a string, returns an existing source plugin.
92✔
324
   * Else, adds a source plugin and returns its wrapped source plugin.
92✔
325
   *
92✔
326
   * @typedef {((uw: UwaveServer, opts: object) => SourcePlugin)} SourcePluginFactory
92✔
327
   * @typedef {SourcePlugin | SourcePluginFactory} ToSourcePlugin
92✔
328
   * @param {string | Omit<ToSourcePlugin, 'default'> | { default: ToSourcePlugin }} sourcePlugin
92✔
329
   *     Source name or definition.
92✔
330
   *     When a string: Source type name.
92✔
331
   *     Used to signal where a given media item originated from.
92✔
332
   *     When a function or object: Source plugin or plugin factory.
92✔
333
   * @param {object} opts Options to pass to the source plugin. Only used if
92✔
334
   *     a source plugin factory was passed to `sourcePlugin`.
92✔
335
   */
92✔
336
  source(sourcePlugin, opts = {}) {
92✔
337
    if (typeof sourcePlugin === 'string') { // eslint-disable-line prefer-rest-params
121✔
338
      return this.#sources.get(sourcePlugin);
69✔
339
    }
69✔
340

52✔
341
    const sourceFactory = 'default' in sourcePlugin && sourcePlugin.default ? sourcePlugin.default : sourcePlugin;
121!
342
    if (typeof sourceFactory !== 'function' && typeof sourceFactory !== 'object') {
121!
343
      throw new TypeError(`Source plugin should be a function, got ${typeof sourceFactory}`);
×
344
    }
×
345

52✔
346
    const sourceDefinition = typeof sourceFactory === 'function'
52✔
347
      ? sourceFactory(this, opts)
121✔
348
      : sourceFactory;
121✔
349
    const sourceType = sourceDefinition.name;
121✔
350
    if (typeof sourceType !== 'string') {
121!
351
      throw new TypeError('Source plugin does not specify a name. It may be incompatible with this version of üWave.');
×
352
    }
×
353
    const newSource = new Source(this, sourceType, sourceDefinition);
52✔
354

52✔
355
    this.#sources.set(sourceType, newSource);
52✔
356

52✔
357
    return newSource;
52✔
358
  }
121✔
359

92✔
360
  /**
92✔
361
   * @private
92✔
362
   */
92✔
363
  configureRedis() {
92✔
364
    const log = this.logger.child({ ns: 'uwave:redis' });
92✔
365

92✔
366
    this.redis.on('error', (error) => {
92✔
367
      log.error(error);
×
368
      this.emit('redisError', error);
×
369
    });
92✔
370
    this.redis.on('reconnecting', () => {
92✔
371
      log.info('trying to reconnect...');
×
372
    });
92✔
373

92✔
374
    this.redis.on('end', () => {
92✔
375
      log.info('disconnected');
92✔
376
      this.emit('redisDisconnect');
92✔
377
    });
92✔
378

92✔
379
    this.redis.on('connect', () => {
92✔
380
      log.info('connected');
92✔
381
      this.emit('redisConnect');
92✔
382
    });
92✔
383
  }
92✔
384

92✔
385
  /**
92✔
386
   * Publish an event to the üWave channel.
92✔
387
   *
92✔
388
   * @template {keyof import('./redisMessages.js').ServerActionParameters} CommandName
92✔
389
   * @param {CommandName} command
92✔
390
   * @param {import('./redisMessages.js').ServerActionParameters[CommandName]} data
92✔
391
   */
92✔
392
  publish(command, data) {
92✔
393
    return this.redis.publish('uwave', JSON.stringify({ command, data }));
61✔
394
  }
61✔
395

92✔
396
  async listen() {
92✔
397
    /** @type {import('avvio').Avvio<this>} */
92✔
398
    // @ts-expect-error TS2322
92✔
399
    const boot = this; // eslint-disable-line @typescript-eslint/no-this-alias
92✔
400
    await boot.ready();
92✔
401

92✔
402
    /** @type {(this: import('net').Server, port?: number) => Promise<void>} */
92✔
403
    const listen = promisify(this.server.listen);
92✔
404
    await listen.call(this.server, this.options.port);
92✔
405
  }
92✔
406
}
92✔
407

1✔
408
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