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

u-wave / core / 19337281714

13 Nov 2025 03:48PM UTC coverage: 85.319% (+0.2%) from 85.077%
19337281714

push

github

web-flow
Store waitlist and booth state in SQLite instead of Redis (#657)

954 of 1135 branches covered (84.05%)

Branch coverage included in aggregate %.

194 of 230 new or added lines in 7 files covered. (84.35%)

3 existing lines in 2 files now uncovered.

10047 of 11759 relevant lines covered (85.44%)

96.19 hits per line

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

88.86
/src/plugins/waitlist.js
1
import fs from 'node:fs';
1✔
2
import {
1✔
3
  PermissionError,
1✔
4
  UserNotFoundError,
1✔
5
  EmptyPlaylistError,
1✔
6
  WaitlistLockedError,
1✔
7
  AlreadyInWaitlistError,
1✔
8
  UserNotInWaitlistError,
1✔
9
  UserIsPlayingError,
1✔
10
} from '../errors/index.js';
1✔
11
import routes from '../routes/waitlist.js';
1✔
12
import { Permissions } from './acl.js';
1✔
13

1✔
14
const schema = JSON.parse(
1✔
15
  fs.readFileSync(new URL('../schemas/waitlist.json', import.meta.url), 'utf8'),
1✔
16
);
1✔
17

1✔
18
const KEY_WAITLIST = 'waitlist';
1✔
19
const KEY_CURRENT_DJ_ID = 'booth:currentDJ';
1✔
20
const KEY_HISTORY_ID = 'booth:historyID';
1✔
21

1✔
22
/**
1✔
23
 * @typedef {import('../schema.js').UserID} UserID
1✔
24
 * @typedef {import('../schema.js').User} User
1✔
25
 * @typedef {{ cycle: boolean, locked: boolean }} WaitlistSettings
1✔
26
 */
1✔
27

1✔
28
class Waitlist {
1✔
29
  #uw;
149✔
30

149✔
31
  /**
149✔
32
   * @param {import('../Uwave.js').Boot} uw
149✔
33
   */
149✔
34
  constructor(uw) {
149✔
35
    this.#uw = uw;
149✔
36

149✔
37
    uw.config.register(schema['uw:key'], schema);
149✔
38

149✔
39
    const unsubscribe = uw.config.subscribe(
149✔
40
      schema['uw:key'],
149✔
41
      /**
149✔
42
       * @param {WaitlistSettings} _settings
149✔
43
       * @param {UserID|null} userID
149✔
44
       * @param {Partial<WaitlistSettings>} patch
149✔
45
       */
149✔
46
      (_settings, userID, patch) => {
149✔
47
        // TODO This userID != null check is wrong. It should always pass as
6✔
48
        // long as all the cases where waitlist settings can be updated provide
6✔
49
        // the moderator's user ID. There's no type level guarantee of that happening
6✔
50
        // though and if it doesn't, clients will get out of sync because of this check.
6✔
51
        if ('locked' in patch && patch.locked != null && userID != null) {
6✔
52
          this.#uw.publish('waitlist:lock', {
6✔
53
            moderatorID: userID,
6✔
54
            locked: patch.locked,
6✔
55
          });
6✔
56
        }
6✔
57
      },
149✔
58
    );
149✔
59
    uw.onClose(unsubscribe);
149✔
60
  }
149✔
61

149✔
62
  async #isBoothEmpty() {
149✔
63
    return !(await this.#uw.keyv.get(KEY_HISTORY_ID));
32✔
64
  }
32✔
65

149✔
66
  /**
149✔
67
   * @param {User} user
149✔
68
   * @returns {Promise<boolean>}
149✔
69
   */
149✔
70
  async #hasPlayablePlaylist(user, tx = this.#uw.db) {
149✔
71
    const { playlists } = this.#uw;
38✔
72
    if (!user.activePlaylistID) {
38✔
73
      return false;
1✔
74
    }
1✔
75

37✔
76
    const playlist = await playlists.getUserPlaylist(user, user.activePlaylistID, tx);
37✔
77
    return playlist && playlist.size > 0;
38✔
78
  }
38✔
79

149✔
80
  /**
149✔
81
   * @returns {Promise<WaitlistSettings>}
149✔
82
   */
149✔
83
  async #getSettings() {
149✔
84
    const { config } = this.#uw;
64✔
85

64✔
86
    const settings = /** @type {WaitlistSettings} */ (await config.get(schema['uw:key']));
64✔
87
    return settings;
64✔
88
  }
64✔
89

149✔
90
  /**
149✔
91
   * @returns {Promise<boolean>}
149✔
92
   */
149✔
93
  async isLocked() {
149✔
94
    const settings = await this.#getSettings();
40✔
95
    return settings.locked;
40✔
96
  }
40✔
97

149✔
98
  /**
149✔
99
   * @returns {Promise<boolean>}
149✔
100
   */
149✔
101
  async isCycleEnabled() {
149✔
102
    const settings = await this.#getSettings();
18✔
103
    return settings.cycle;
18✔
104
  }
18✔
105

149✔
106
  /**
149✔
107
   * @returns {Promise<UserID[]>}
149✔
108
   */
149✔
109
  async getUserIDs(tx = this.#uw.db) {
149✔
110
    const userIDs = /** @type {UserID[] | null} */ (await this.#uw.keyv.get(KEY_WAITLIST, tx));
120✔
111
    return userIDs ?? [];
120✔
112
  }
120✔
113

149✔
114
  /**
149✔
115
   * @param {UserID|null} previous
149✔
116
   * @param {{ remove?: boolean }} options
149✔
117
   */
149✔
118
  async cycle(previous, options) {
149✔
119
    // TODO: This must happen in a transaction
18✔
120
    const waitlist = await this.getUserIDs();
18✔
121
    if (waitlist.length > 0) {
18✔
122
      waitlist.shift();
18✔
123
      if (previous && !options.remove) {
18!
NEW
124
        // The previous DJ should only be added to the waitlist again if it was
×
NEW
125
        // not empty. If it was empty, the previous DJ is already in the booth.
×
NEW
126
        waitlist.push(previous);
×
NEW
127
      }
×
128

18✔
129
      await this.#uw.keyv.set(KEY_WAITLIST, waitlist);
18✔
130
    }
18✔
131
  }
18✔
132

149✔
133
  /**
149✔
134
   * Add a user to the waitlist.
149✔
135
   *
149✔
136
   * @param {UserID} userID
149✔
137
   * @param {{moderator?: User}} [options]
149✔
138
   */
149✔
139
  async addUser(userID, options = {}) {
149✔
140
    const { moderator } = options;
38✔
141
    const { acl, users } = this.#uw;
38✔
142

38✔
143
    const user = await users.getUser(userID);
38✔
144
    if (!user) throw new UserNotFoundError({ id: userID });
38!
145

38✔
146
    const isAddingOtherUser = moderator && user.id !== moderator.id;
38✔
147
    if (isAddingOtherUser) {
38✔
148
      if (!(await acl.isAllowed(moderator, Permissions.WaitlistAdd))) {
2✔
149
        throw new PermissionError({
1✔
150
          requiredRole: 'waitlist.add',
1✔
151
        });
1✔
152
      }
1✔
153
    }
2✔
154

37✔
155
    const canForceJoin = await acl.isAllowed(user, Permissions.WaitlistJoinLocked);
37✔
156
    if (!isAddingOtherUser && !canForceJoin && await this.isLocked()) {
38✔
157
      throw new WaitlistLockedError();
2✔
158
    }
2✔
159

35✔
160
    if (!(await this.#hasPlayablePlaylist(user))) {
38✔
161
      throw new EmptyPlaylistError();
2✔
162
    }
2✔
163

33✔
164
    const waitlist = await this.getUserIDs();
33✔
165
    const isInWaitlist = waitlist.includes(user.id);
33✔
166
    const currentDJ = /** @type {UserID|null} */ (
33✔
167
      await this.#uw.keyv.get(KEY_CURRENT_DJ_ID)
33✔
168
    );
33✔
169
    if (isInWaitlist || currentDJ === user.id) {
38✔
170
      throw new AlreadyInWaitlistError();
1✔
171
    }
1✔
172

32✔
173
    waitlist.push(user.id);
32✔
174

32✔
175
    await this.#uw.keyv.set(KEY_WAITLIST, waitlist);
32✔
176

32✔
177
    if (isAddingOtherUser) {
38✔
178
      this.#uw.publish('waitlist:add', {
1✔
179
        userID: user.id,
1✔
180
        moderatorID: moderator.id,
1✔
181
        position: waitlist.indexOf(user.id),
1✔
182
        waitlist,
1✔
183
      });
1✔
184
    } else {
38✔
185
      this.#uw.publish('waitlist:join', {
31✔
186
        userID: user.id,
31✔
187
        waitlist,
31✔
188
      });
31✔
189
    }
31✔
190

32✔
191
    if (await this.#isBoothEmpty()) {
38✔
192
      await this.#uw.booth.advance();
18✔
193
    }
18✔
194
  }
38✔
195

149✔
196
  /**
149✔
197
   * @param {UserID} userID
149✔
198
   * @param {number} position
149✔
199
   * @param {{moderator: User}} options
149✔
200
   * @returns {Promise<void>}
149✔
201
   */
149✔
202
  async moveUser(userID, position, { moderator }) {
149✔
203
    const { users } = this.#uw;
3✔
204

3✔
205
    const user = await users.getUser(userID);
3✔
206
    if (!user) {
3!
207
      throw new UserNotFoundError({ id: userID });
×
208
    }
×
209

3✔
210
    if (!(await this.#hasPlayablePlaylist(user))) {
3!
211
      throw new EmptyPlaylistError();
×
212
    }
×
213

3✔
214
    const waitlist = await this.getUserIDs();
3✔
215
    const previousPosition = waitlist.indexOf(user.id);
3✔
216
    if (previousPosition === -1) {
3!
NEW
217
      throw new UserNotInWaitlistError({ id: user.id });
×
NEW
218
    }
×
219
    const currentDJ = /** @type {UserID|null} */ (
3✔
220
      await this.#uw.keyv.get(KEY_CURRENT_DJ_ID)
3✔
221
    );
3✔
222
    if (currentDJ === user.id) {
3!
NEW
223
      throw new UserIsPlayingError({ id: user.id });
×
224
    }
×
225

3✔
226
    waitlist.splice(previousPosition, 1);
3✔
227
    // `position` might be _past_ the end of the array,
3✔
228
    // in which case this is equivalent to a `.push`.
3✔
229
    waitlist.splice(position, 0, user.id);
3✔
230

3✔
231
    await this.#uw.keyv.set(KEY_WAITLIST, waitlist);
3✔
232

3✔
233
    this.#uw.publish('waitlist:move', {
3✔
234
      userID: user.id,
3✔
235
      moderatorID: moderator.id,
3✔
236
      position: waitlist.indexOf(user.id),
3✔
237
      waitlist,
3✔
238
    });
3✔
239
  }
3✔
240

149✔
241
  /**
149✔
242
   * @param {UserID} userID
149✔
243
   * @param {{moderator?: User}} [options]
149✔
244
   * @returns {Promise<void>}
149✔
245
   */
149✔
246
  async removeUser(userID, { moderator } = {}) {
149✔
247
    const { acl, users } = this.#uw;
2✔
248
    const user = await users.getUser(userID);
2✔
249
    if (!user) {
2!
250
      throw new UserNotFoundError({ id: userID });
×
251
    }
×
252

2✔
253
    const isRemoving = moderator != null && user.id !== moderator.id;
2✔
254
    if (isRemoving && !(await acl.isAllowed(moderator, Permissions.WaitlistRemove))) {
2!
255
      throw new PermissionError({
×
256
        requiredRole: 'waitlist.remove',
×
257
      });
×
258
    }
×
259

2✔
260
    const waitlist = await this.#uw.db.transaction().execute(async (tx) => {
2✔
261
      const waitlist = await this.getUserIDs(tx);
2✔
262
      let index;
2✔
263
      let removedCount = 0;
2✔
264
      while ((index = waitlist.indexOf(user.id)) !== -1) {
2✔
265
        waitlist.splice(index, 1);
1✔
266
        removedCount += 1;
1✔
267
      }
1✔
268

2✔
269
      if (removedCount === 0) {
2✔
270
        throw new UserNotInWaitlistError({ id: user.id });
1✔
271
      }
1✔
272

1✔
273
      await this.#uw.keyv.set(KEY_WAITLIST, waitlist, tx);
1✔
274
      return waitlist;
1✔
275
    });
2✔
276

1✔
277
    if (isRemoving) {
2!
UNCOV
278
      this.#uw.publish('waitlist:remove', {
×
279
        userID: user.id,
×
280
        moderatorID: moderator.id,
×
281
        waitlist,
×
282
      });
×
283
    } else {
2✔
284
      this.#uw.publish('waitlist:leave', {
1✔
285
        userID: user.id,
1✔
286
        waitlist,
1✔
287
      });
1✔
288
    }
1✔
289
  }
2✔
290

149✔
291
  /**
149✔
292
   * @param {{moderator: User}} options
149✔
293
   * @returns {Promise<void>}
149✔
294
   */
149✔
295
  async clear({ moderator }) {
149✔
NEW
296
    await this.#uw.keyv.delete(KEY_WAITLIST);
×
297

×
298
    const waitlist = await this.getUserIDs();
×
299
    if (waitlist.length !== 0) {
×
300
      throw new Error('Could not clear the waitlist. Please try again.');
×
301
    }
×
302

×
303
    this.#uw.publish('waitlist:clear', {
×
304
      moderatorID: moderator.id,
×
305
    });
×
306
  }
×
307

149✔
308
  /**
149✔
309
   * @param {boolean} lock
149✔
310
   * @param {User} moderator
149✔
311
   * @returns {Promise<void>}
149✔
312
   */
149✔
313
  async #setWaitlistLocked(lock, moderator) {
149✔
314
    const settings = await this.#getSettings();
6✔
315
    await this.#uw.config.set(schema['uw:key'], { ...settings, locked: lock }, { user: moderator });
6✔
316
  }
6✔
317

149✔
318
  /**
149✔
319
   * Lock the waitlist. Only users with the `waitlist.join.locked` permission
149✔
320
   * will be able to join.
149✔
321
   *
149✔
322
   * @param {{moderator: User}} options
149✔
323
   * @returns {Promise<void>}
149✔
324
   */
149✔
325
  lock({ moderator }) {
149✔
326
    return this.#setWaitlistLocked(true, moderator);
4✔
327
  }
4✔
328

149✔
329
  /**
149✔
330
   * Unlock the waitlist. All users with the `waitlist.join` permission
149✔
331
   * will be able to join.
149✔
332
   *
149✔
333
   * @param {{moderator: User}} options
149✔
334
   * @returns {Promise<void>}
149✔
335
   */
149✔
336
  unlock({ moderator }) {
149✔
337
    return this.#setWaitlistLocked(false, moderator);
2✔
338
  }
2✔
339
}
149✔
340

1✔
341
/**
1✔
342
 * @param {import('../Uwave.js').Boot} uw
1✔
343
 * @returns {Promise<void>}
1✔
344
 */
1✔
345
async function waitlistPlugin(uw) {
149✔
346
  uw.waitlist = new Waitlist(uw);
149✔
347
  uw.httpApi.use('/waitlist', routes());
149✔
348
}
149✔
349

1✔
350
export default waitlistPlugin;
1✔
351
export { Waitlist };
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