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

u-wave / core / 11980927100

22 Nov 2024 10:11PM UTC coverage: 78.471% (-0.02%) from 78.492%
11980927100

Pull #657

github

goto-bus-stop
SQLite Key-value store to replace Redis
Pull Request #657: SQLite Key-value store to replace Redis

760 of 915 branches covered (83.06%)

Branch coverage included in aggregate %.

26 of 37 new or added lines in 2 files covered. (70.27%)

117 existing lines in 4 files now uncovered.

8691 of 11129 relevant lines covered (78.09%)

70.74 hits per line

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

92.41
/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 { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely';
1✔
7
import httpApi, { errorHandling } from './HttpApi.js';
1✔
8
import SocketServer from './SocketServer.js';
1✔
9
import { Source } from './Source.js';
1✔
10
import { i18n } from './locale.js';
1✔
11
import configStore from './plugins/configStore.js';
1✔
12
import booth from './plugins/booth.js';
1✔
13
import chat from './plugins/chat.js';
1✔
14
import motd from './plugins/motd.js';
1✔
15
import playlists from './plugins/playlists.js';
1✔
16
import users from './plugins/users.js';
1✔
17
import bans from './plugins/bans.js';
1✔
18
import history from './plugins/history.js';
1✔
19
import acl from './plugins/acl.js';
1✔
20
import waitlist from './plugins/waitlist.js';
1✔
21
import passport from './plugins/passport.js';
1✔
22
import migrations from './plugins/migrations.js';
1✔
23
import { SqliteDateColumnsPlugin, connect as connectSqlite, jsonb } from './utils/sqlite.js';
1✔
24

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

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

1✔
39
/**
1✔
40
 * @typedef {import('./Source.js').SourcePlugin} SourcePlugin
1✔
41
 */
1✔
42

1✔
43
/**
1✔
44
 * @typedef {UwaveServer & avvio.Server<UwaveServer>} Boot
1✔
45
 * @typedef {Pick<
1✔
46
 *   import('ioredis').RedisOptions,
1✔
47
 *   'port' | 'host' | 'family' | 'path' | 'db' | 'password' | 'username' | 'tls'
1✔
48
 * >} RedisOptions
1✔
49
 * @typedef {{
1✔
50
 *   port?: number,
1✔
51
 *   sqlite?: string,
1✔
52
 *   mongo?: string,
1✔
53
 *   redis?: string | RedisOptions,
1✔
54
 *   logger?: import('pino').LoggerOptions,
1✔
55
 * } & import('./HttpApi.js').HttpApiOptions} Options
1✔
56
 */
1✔
57

1✔
58
class UwaveServer extends EventEmitter {
92✔
59
  /** @type {import('ioredis').default} */
92✔
60
  redis;
92✔
61

92✔
62
  /** @type {import('http').Server} */
92✔
63
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
64
  server;
92✔
65

92✔
66
  /** @type {import('express').Application} */
92✔
67
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
68
  express;
92✔
69

92✔
70
  /** @type {import('./plugins/acl.js').Acl} */
92✔
71
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
72
  acl;
92✔
73

92✔
74
  /** @type {import('./plugins/bans.js').Bans} */
92✔
75
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
76
  bans;
92✔
77

92✔
78
  /** @type {import('./plugins/booth.js').Booth} */
92✔
79
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
80
  booth;
92✔
81

92✔
82
  /** @type {import('./plugins/chat.js').Chat} */
92✔
83
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
84
  chat;
92✔
85

92✔
86
  /** @type {import('./plugins/configStore.js').ConfigStore} */
92✔
87
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
88
  config;
92✔
89

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

92✔
93
  /** @type {import('./plugins/history.js').HistoryRepository} */
92✔
94
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
95
  history;
92✔
96

92✔
97
  /** @type {import('./plugins/migrations.js').Migrate} */
92✔
98
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
99
  migrate;
92✔
100

92✔
101
  /** @type {import('./plugins/motd.js').MOTD} */
92✔
102
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
103
  motd;
92✔
104

92✔
105
  /** @type {import('./plugins/passport.js').Passport} */
92✔
106
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
107
  passport;
92✔
108

92✔
109
  /** @type {import('./plugins/playlists.js').PlaylistsRepository} */
92✔
110
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
111
  playlists;
92✔
112

92✔
113
  /** @type {import('./plugins/users.js').UsersRepository} */
92✔
114
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
115
  users;
92✔
116

92✔
117
  /** @type {import('./plugins/waitlist.js').Waitlist} */
92✔
118
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
119
  waitlist;
92✔
120

92✔
121
  /** @type {import('./HttpApi.js').HttpApi} */
92✔
122
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
123
  httpApi;
92✔
124

92✔
125
  /** @type {import('./SocketServer.js').default} */
92✔
126
  // @ts-expect-error TS2564 Definitely assigned in a plugin
92✔
127
  socketServer;
92✔
128

92✔
129
  /**
92✔
130
   * @type {Map<string, Source>}
92✔
131
   */
92✔
132
  #sources = new Map();
92✔
133

92✔
134
  /**
92✔
135
   * @param {Options} options
92✔
136
   */
92✔
137
  constructor(options) {
92✔
138
    super();
92✔
139

92✔
140
    const boot = avvio(this);
92✔
141

92✔
142
    this.logger = pino({
92✔
143
      ...options.logger,
92✔
144
      redact: ['req.headers.cookie', 'res.headers["set-cookie"]'],
92✔
145
    });
92✔
146
    this.locale = i18n.cloneInstance();
92✔
147

92✔
148
    this.options = {
92✔
149
      mongo: DEFAULT_MONGO_URL,
92✔
150
      redis: DEFAULT_REDIS_URL,
92✔
151
      ...options,
92✔
152
    };
92✔
153

92✔
154
    /** @type {Kysely<import('./schema.js').Database>} */
92✔
155
    this.db = new Kysely({
92✔
156
      dialect: new SqliteDialect({
92✔
157
        database: () => connectSqlite(options.sqlite ?? 'uwave_local.sqlite'),
92!
158
      }),
92✔
159
      // dialect: new PostgresDialect({
92✔
160
      //   pool: new pg.Pool({
92✔
161
      //     user: 'uwave',
92✔
162
      //     database: 'uwave_test',
92✔
163
      //   }),
92✔
164
      // }),
92✔
165
      plugins: [
92✔
166
        new UwCamelCasePlugin(),
92✔
167
        new SqliteDateColumnsPlugin(['createdAt', 'updatedAt', 'expiresAt', 'playedAt', 'lastSeenAt']),
92✔
168
      ],
92✔
169
    });
92✔
170

92✔
171
    if (typeof options.redis === 'string') {
92✔
172
      this.redis = new Redis(options.redis, { lazyConnect: true });
92✔
173
    } else {
92!
UNCOV
174
      this.redis = new Redis({ ...options.redis, lazyConnect: true });
×
175
    }
×
176

92✔
177
    this.configureRedis();
92✔
178

92✔
179
    boot.onClose(() => Promise.all([
92✔
180
      this.redis.quit(),
92✔
181
    ]));
92✔
182

92✔
183
    class KeyValue {
92✔
184
      #db;
92✔
185

92✔
186
      /** @param {Kysely<import('./schema.js').Database>} db */
92✔
187
      constructor(db) {
92✔
188
        this.#db = db;
92✔
189
      }
92✔
190

92✔
191
      /** @param {string} key */
92✔
192
      async get(key, db = this.#db) {
92✔
NEW
UNCOV
193
        const row = await db.selectFrom('keyval')
×
NEW
UNCOV
194
          .select('value')
×
NEW
UNCOV
195
          .where('key', '=', key)
×
NEW
UNCOV
196
          .executeTakeFirst();
×
NEW
UNCOV
197
        return row != null ? JSON.parse(/** @type {string} */ (/** @type {unknown} */ (row.value))) : null;
×
NEW
UNCOV
198
      }
×
199

92✔
200
      /**
92✔
201
       * @param {string} key
92✔
202
       * @param {import('type-fest').JsonValue} value
92✔
203
       */
92✔
204
      async set(key, value, db = this.#db) {
92✔
NEW
UNCOV
205
        await db.insertInto('keyval')
×
NEW
UNCOV
206
          .values({ key, value: jsonb(value) })
×
NEW
UNCOV
207
          .onConflict((oc) => oc.column('key').doUpdateSet({ value: jsonb(value) }))
×
NEW
UNCOV
208
          .execute();
×
NEW
UNCOV
209
      }
×
210
    }
92✔
211

92✔
212
    this.keyv = new KeyValue(this.db);
92✔
213

92✔
214
    boot.use(migrations);
92✔
215
    boot.use(configStore);
92✔
216

92✔
217
    boot.use(passport, {
92✔
218
      secret: this.options.secret,
92✔
219
    });
92✔
220

92✔
221
    // Initial API setup
92✔
222
    boot.use(httpApi, {
92✔
223
      secret: this.options.secret,
92✔
224
      helmet: this.options.helmet,
92✔
225
      mailTransport: this.options.mailTransport,
92✔
226
      recaptcha: this.options.recaptcha,
92✔
227
      createPasswordResetEmail: this.options.createPasswordResetEmail,
92✔
228
      onError: this.options.onError,
92✔
229
    });
92✔
230
    boot.use(SocketServer.plugin);
92✔
231

92✔
232
    boot.use(acl);
92✔
233
    boot.use(chat);
92✔
234
    boot.use(motd);
92✔
235
    boot.use(playlists);
92✔
236
    boot.use(users);
92✔
237
    boot.use(bans);
92✔
238
    boot.use(history);
92✔
239
    boot.use(waitlist);
92✔
240
    boot.use(booth);
92✔
241

92✔
242
    boot.use(errorHandling);
92✔
243
  }
92✔
244

92✔
245
  /**
92✔
246
   * An array of registered sources.
92✔
247
   *
92✔
248
   * @type {Source[]}
92✔
249
   */
92✔
250
  get sources() {
92✔
UNCOV
251
    return [...this.#sources.values()];
×
UNCOV
252
  }
×
253

92✔
254
  /**
92✔
255
   * Get or register a source plugin.
92✔
256
   * If the first parameter is a string, returns an existing source plugin.
92✔
257
   * Else, adds a source plugin and returns its wrapped source plugin.
92✔
258
   *
92✔
259
   * @typedef {((uw: UwaveServer, opts: object) => SourcePlugin)} SourcePluginFactory
92✔
260
   * @typedef {SourcePlugin | SourcePluginFactory} ToSourcePlugin
92✔
261
   * @param {string | Omit<ToSourcePlugin, 'default'> | { default: ToSourcePlugin }} sourcePlugin
92✔
262
   *     Source name or definition.
92✔
263
   *     When a string: Source type name.
92✔
264
   *     Used to signal where a given media item originated from.
92✔
265
   *     When a function or object: Source plugin or plugin factory.
92✔
266
   * @param {object} opts Options to pass to the source plugin. Only used if
92✔
267
   *     a source plugin factory was passed to `sourcePlugin`.
92✔
268
   */
92✔
269
  source(sourcePlugin, opts = {}) {
92✔
270
    if (typeof sourcePlugin === 'string') {
125✔
271
      return this.#sources.get(sourcePlugin);
72✔
272
    }
72✔
273

53✔
274
    const sourceFactory = 'default' in sourcePlugin && sourcePlugin.default ? sourcePlugin.default : sourcePlugin;
125!
275
    if (typeof sourceFactory !== 'function' && typeof sourceFactory !== 'object') {
125!
276
      throw new TypeError(`Source plugin should be a function, got ${typeof sourceFactory}`);
×
UNCOV
277
    }
×
278

53✔
279
    const sourceDefinition = typeof sourceFactory === 'function'
53✔
280
      ? sourceFactory(this, opts)
125✔
281
      : sourceFactory;
125✔
282
    const sourceType = sourceDefinition.name;
125✔
283
    if (typeof sourceType !== 'string') {
125!
UNCOV
284
      throw new TypeError('Source plugin does not specify a name. It may be incompatible with this version of üWave.');
×
UNCOV
285
    }
×
286
    const newSource = new Source(this, sourceType, sourceDefinition);
53✔
287

53✔
288
    this.#sources.set(sourceType, newSource);
53✔
289

53✔
290
    return newSource;
53✔
291
  }
125✔
292

92✔
293
  /**
92✔
294
   * @private
92✔
295
   */
92✔
296
  configureRedis() {
92✔
297
    const log = this.logger.child({ ns: 'uwave:redis' });
92✔
298

92✔
299
    this.redis.on('error', (error) => {
92✔
UNCOV
300
      log.error(error);
×
UNCOV
301
      this.emit('redisError', error);
×
302
    });
92✔
303
    this.redis.on('reconnecting', () => {
92✔
UNCOV
304
      log.info('trying to reconnect...');
×
305
    });
92✔
306

92✔
307
    this.redis.on('end', () => {
92✔
308
      log.info('disconnected');
92✔
309
      this.emit('redisDisconnect');
92✔
310
    });
92✔
311

92✔
312
    this.redis.on('connect', () => {
92✔
313
      log.info('connected');
92✔
314
      this.emit('redisConnect');
92✔
315
    });
92✔
316
  }
92✔
317

92✔
318
  /**
92✔
319
   * Publish an event to the üWave channel.
92✔
320
   *
92✔
321
   * @template {keyof import('./redisMessages.js').ServerActionParameters} CommandName
92✔
322
   * @param {CommandName} command
92✔
323
   * @param {import('./redisMessages.js').ServerActionParameters[CommandName]} data
92✔
324
   */
92✔
325
  publish(command, data) {
92✔
326
    return this.redis.publish('uwave', JSON.stringify({ command, data }));
67✔
327
  }
67✔
328

92✔
329
  async listen() {
92✔
330
    /** @type {import('avvio').Avvio<this>} */
92✔
331
    // @ts-expect-error TS2322
92✔
332
    const boot = this; // eslint-disable-line @typescript-eslint/no-this-alias
92✔
333
    await boot.ready();
92✔
334

92✔
335
    /** @type {(this: import('net').Server, port?: number) => Promise<void>} */
92✔
336
    const listen = promisify(this.server.listen);
92✔
337
    await listen.call(this.server, this.options.port);
92✔
338
  }
92✔
339
}
92✔
340

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