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

u-wave / core / 19327648999

13 Nov 2025 09:59AM UTC coverage: 85.015%. First build
19327648999

Pull #657

github

web-flow
Merge bb29c4618 into cc369fe03
Pull Request #657: SQLite Key-value store to replace Redis

953 of 1129 branches covered (84.41%)

Branch coverage included in aggregate %.

173 of 228 new or added lines in 7 files covered. (75.88%)

10002 of 11757 relevant lines covered (85.07%)

92.34 hits per line

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

82.87
/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;
144✔
30

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

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

144✔
39
    const unsubscribe = uw.config.subscribe(
144✔
40
      schema['uw:key'],
144✔
41
      /**
144✔
42
       * @param {WaitlistSettings} _settings
144✔
43
       * @param {UserID|null} userID
144✔
44
       * @param {Partial<WaitlistSettings>} patch
144✔
45
       */
144✔
46
      (_settings, userID, patch) => {
144✔
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
      },
144✔
58
    );
144✔
59
    uw.onClose(unsubscribe);
144✔
60
  }
144✔
61

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

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

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

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

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

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

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

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

144✔
114
  /**
144✔
115
   * @param {UserID|null} previous
144✔
116
   * @param {{ remove?: boolean }} options
144✔
117
   */
144✔
118
  async cycle(previous, options) {
144✔
119
    // TODO: This must happen in a transaction
15✔
120
    const waitlist = await this.getUserIDs();
15✔
121
    if (waitlist.length > 0) {
15✔
122
      waitlist.shift();
15✔
123
      if (previous && !options.remove) {
15!
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

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

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

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

25✔
146
    const isAddingOtherUser = moderator && user.id !== moderator.id;
25✔
147
    if (isAddingOtherUser) {
25✔
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

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

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

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

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

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

19✔
177
    if (isAddingOtherUser) {
25✔
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 {
25✔
185
      this.#uw.publish('waitlist:join', {
18✔
186
        userID: user.id,
18✔
187
        waitlist,
18✔
188
      });
18✔
189
    }
18✔
190

19✔
191
    if (await this.#isBoothEmpty()) {
25✔
192
      await this.#uw.booth.advance();
15✔
193
    }
15✔
194
  }
25✔
195

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

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

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

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

×
NEW
226
    waitlist.splice(previousPosition, 1);
×
NEW
227
    waitlist.splice(position, 0, user.id);
×
NEW
228

×
NEW
229
    await this.#uw.keyv.set(KEY_WAITLIST, waitlist);
×
NEW
230

×
NEW
231
    this.#uw.publish('waitlist:move', {
×
NEW
232
      userID: user.id,
×
NEW
233
      moderatorID: moderator.id,
×
NEW
234
      position: waitlist.indexOf(user.id),
×
NEW
235
      waitlist,
×
NEW
236
    });
×
237
  }
×
238

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

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

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

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

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

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

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

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

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

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

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

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

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

1✔
348
export default waitlistPlugin;
1✔
349
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