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

u-wave / core / 12038920397

26 Nov 2024 08:59PM UTC coverage: 82.71% (-0.06%) from 82.772%
12038920397

Pull #675

github

web-flow
Merge f3558602c into 5099496ff
Pull Request #675: Make the migration script more robust

850 of 1025 branches covered (82.93%)

Branch coverage included in aggregate %.

0 of 34 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

9588 of 11595 relevant lines covered (82.69%)

84.35 hits per line

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

63.54
/src/migrations/003-populate-sql.cjs
1
'use strict';
1✔
2

1✔
3
const { randomUUID } = require('node:crypto');
1✔
4
const mongoose = require('mongoose');
1✔
5
const { sql } = require('kysely');
1✔
6

1✔
7
const { Types } = mongoose.Schema;
1✔
8

1✔
9
/** @param {unknown} value */
1✔
10
function jsonb(value) {
×
11
  return sql`jsonb(${JSON.stringify(value)})`;
×
12
}
×
13

1✔
14
/**
1✔
15
 * @typedef {object} LeanAclRole
1✔
16
 * @prop {string} _id
1✔
17
 * @prop {string[]} roles
1✔
18
 * @typedef {mongoose.Document<LeanAclRole["_id"], {}, LeanAclRole> & LeanAclRole} AclRole
1✔
19
 */
1✔
20

1✔
21
/**
1✔
22
 * @type {mongoose.Schema<AclRole, mongoose.Model<AclRole>>}
1✔
23
 */
1✔
24
const aclRoleSchema = new mongoose.Schema({
1✔
25
  _id: String,
1✔
26
  roles: [{ type: String, ref: 'AclRole', index: true }],
1✔
27
}, {
1✔
28
  collection: 'acl_roles',
1✔
29
  minimize: true,
1✔
30
});
1✔
31

1✔
32
/**
1✔
33
 * @typedef {object} LeanAuthentication
1✔
34
 * @prop {import('mongodb').ObjectId} _id
1✔
35
 * @prop {import('mongodb').ObjectId} user
1✔
36
 * @prop {string} type
1✔
37
 * @prop {string} [email]
1✔
38
 * @prop {string} [hash]
1✔
39
 * @prop {string} [id]
1✔
40
 * @prop {string} [avatar]
1✔
41
 * @typedef {mongoose.Document<LeanAuthentication["_id"], {}, LeanAuthentication> &
1✔
42
 *           LeanAuthentication} Authentication
1✔
43
 */
1✔
44

1✔
45
/**
1✔
46
 * @type {mongoose.Schema<Authentication, mongoose.Model<Authentication>>}
1✔
47
 */
1✔
48
const authenticationSchema = new mongoose.Schema({
1✔
49
  user: { type: Types.ObjectId, ref: 'User', index: true },
1✔
50
  type: { type: String, required: true, default: 'local' },
1✔
51
  // Local login
1✔
52
  email: {
1✔
53
    type: String, max: 254, unique: true, index: true,
1✔
54
  },
1✔
55
  hash: { type: String },
1✔
56
  // Social login
1✔
57
  id: { type: String },
1✔
58
  avatar: { type: String, required: false },
1✔
59
}, {
1✔
60
  timestamps: true,
1✔
61
  minimize: false,
1✔
62
});
1✔
63

1✔
64
/**
1✔
65
 * @typedef {object} LeanConfig
1✔
66
 * @prop {string} _id
1✔
67
 * @typedef {mongoose.Document<LeanConfig["_id"], {}, LeanConfig> &
1✔
68
 *           LeanConfig} Config
1✔
69
 */
1✔
70

1✔
71
/**
1✔
72
 * @type {mongoose.Schema<Config, mongoose.Model<Config>>}
1✔
73
 */
1✔
74
const configSchema = new mongoose.Schema({
1✔
75
  _id: { type: String },
1✔
76
}, {
1✔
77
  collection: 'config_store',
1✔
78
  strict: false,
1✔
79
  toJSON: { versionKey: false },
1✔
80
});
1✔
81

1✔
82
const listOfUsers = [{ type: Types.ObjectId, ref: 'User' }];
1✔
83

1✔
84
/**
1✔
85
 * @typedef {import('type-fest').JsonObject} HistorySourceData
1✔
86
 */
1✔
87

1✔
88
/**
1✔
89
 * @typedef {object} HistoryMedia
1✔
90
 * @prop {import('mongodb').ObjectId} media
1✔
91
 *     Reference to the `Media` object that is being played.
1✔
92
 * @prop {string} artist
1✔
93
 *     Snapshot of the media artist name at the time this entry was played.
1✔
94
 * @prop {string} title
1✔
95
 *     Snapshot of the media title at the time this entry was played.
1✔
96
 * @prop {number} start
1✔
97
 *     Time to start playback at.
1✔
98
 * @prop {number} end
1✔
99
 *     Time to stop playback at.
1✔
100
 * @prop {HistorySourceData} sourceData
1✔
101
 *     Arbitrary source-specific data required for media playback.
1✔
102
 */
1✔
103

1✔
104
/**
1✔
105
 * @typedef {object} LeanHistoryEntry
1✔
106
 * @prop {import('mongodb').ObjectId} _id
1✔
107
 * @prop {import('mongodb').ObjectId} user
1✔
108
 * @prop {import('mongodb').ObjectId} playlist
1✔
109
 * @prop {import('mongodb').ObjectId} item
1✔
110
 * @prop {mongoose.Document<never, {}, HistoryMedia> & HistoryMedia} media
1✔
111
 * @prop {Date} playedAt
1✔
112
 * @prop {import('mongodb').ObjectId[]} upvotes
1✔
113
 * @prop {import('mongodb').ObjectId[]} downvotes
1✔
114
 * @prop {import('mongodb').ObjectId[]} favorites
1✔
115
 */
1✔
116

1✔
117
/**
1✔
118
 * @typedef {mongoose.Document<LeanHistoryEntry["_id"], {}, LeanHistoryEntry> &
1✔
119
 *           LeanHistoryEntry} HistoryEntry
1✔
120
 */
1✔
121

1✔
122
/**
1✔
123
 * @type {mongoose.Schema<HistoryEntry, mongoose.Model<HistoryEntry>>}
1✔
124
 */
1✔
125
const historySchema = new mongoose.Schema({
1✔
126
  user: {
1✔
127
    type: Types.ObjectId, ref: 'User', required: true, index: true,
1✔
128
  },
1✔
129
  playlist: { type: Types.ObjectId, ref: 'Playlist' },
1✔
130
  item: { type: Types.ObjectId, ref: 'PlaylistItem' },
1✔
131
  media: {
1✔
132
    media: { type: Types.ObjectId, ref: 'Media', required: true },
1✔
133
    artist: {
1✔
134
      type: String,
1✔
135
      index: true,
1✔
136
      /** @type {(name: string) => string} */
1✔
137
      set: (artist) => artist.normalize('NFKC'),
1✔
138
    },
1✔
139
    title: {
1✔
140
      type: String,
1✔
141
      index: true,
1✔
142
      /** @type {(name: string) => string} */
1✔
143
      set: (title) => title.normalize('NFKC'),
1✔
144
    },
1✔
145
    start: { type: Number, default: 0 },
1✔
146
    end: { type: Number, default: 0 },
1✔
147
    // Bypass typecheck as JsonObject is a recursive structure & causes infinite looping here.
1✔
148
    /** @type {any} */
1✔
149
    sourceData: { type: Object, select: false },
1✔
150
  },
1✔
151
  playedAt: { type: Date, default: () => new Date(), index: true },
1✔
152
  upvotes: listOfUsers,
1✔
153
  downvotes: listOfUsers,
1✔
154
  favorites: listOfUsers,
1✔
155
}, {
1✔
156
  collection: 'historyentries',
1✔
157
  minimize: false,
1✔
158
});
1✔
159

1✔
160
/**
1✔
161
 * @typedef {object} LeanMedia
1✔
162
 * @prop {import('mongodb').ObjectId} _id
1✔
163
 * @prop {string} sourceID
1✔
164
 * @prop {string} sourceType
1✔
165
 * @prop {object} sourceData
1✔
166
 * @prop {string} artist
1✔
167
 * @prop {string} title
1✔
168
 * @prop {number} duration
1✔
169
 * @prop {string} thumbnail
1✔
170
 * @prop {Date} createdAt
1✔
171
 * @prop {Date} updatedAt
1✔
172
 * @typedef {mongoose.Document<LeanMedia["_id"], {}, LeanMedia> & LeanMedia} Media
1✔
173
 */
1✔
174

1✔
175
/**
1✔
176
 * @type {mongoose.Schema<Media, mongoose.Model<Media>>}
1✔
177
 */
1✔
178
const mediaSchema = new mongoose.Schema({
1✔
179
  sourceID: {
1✔
180
    type: String, max: 128, required: true, index: true,
1✔
181
  },
1✔
182
  sourceType: {
1✔
183
    type: String, max: 128, required: true, index: true,
1✔
184
  },
1✔
185
  sourceData: {},
1✔
186
  artist: {
1✔
187
    type: String,
1✔
188
    max: 128,
1✔
189
    required: true,
1✔
190
    /** @type {(name: string) => string} */
1✔
191
    set: (artist) => artist.normalize('NFKC'),
1✔
192
  },
1✔
193
  title: {
1✔
194
    type: String,
1✔
195
    max: 128,
1✔
196
    required: true,
1✔
197
    /** @type {(name: string) => string} */
1✔
198
    set: (title) => title.normalize('NFKC'),
1✔
199
  },
1✔
200
  duration: { type: Number, min: 0, default: 0 },
1✔
201
  thumbnail: { type: String, max: 256, default: '' },
1✔
202
}, {
1✔
203
  timestamps: true,
1✔
204
  minimize: false,
1✔
205
});
1✔
206

1✔
207
/**
1✔
208
 * @typedef {object} LeanMigration
1✔
209
 * @prop {import('mongodb').ObjectId} _id
1✔
210
 * @prop {string} migrationName
1✔
211
 * @prop {Date} createdAt
1✔
212
 * @prop {Date} updatedAt
1✔
213
 * @typedef {mongoose.Document<LeanMigration["_id"], {}, LeanMigration> & LeanMigration} Migration
1✔
214
 */
1✔
215

1✔
216
/**
1✔
217
 * @type {mongoose.Schema<Migration, mongoose.Model<Migration>>}
1✔
218
 */
1✔
219
const migrationSchema = new mongoose.Schema({
1✔
220
  migrationName: { type: String, required: true },
1✔
221
}, {
1✔
222
  timestamps: true,
1✔
223
  collection: 'migrations',
1✔
224
});
1✔
225

1✔
226
/**
1✔
227
 * @typedef {object} LeanPlaylist
1✔
228
 * @prop {import('mongodb').ObjectId} _id
1✔
229
 * @prop {string} name
1✔
230
 * @prop {string} description
1✔
231
 * @prop {import('mongodb').ObjectId} author
1✔
232
 * @prop {import('mongodb').ObjectId[]} media
1✔
233
 * @prop {Date} createdAt
1✔
234
 * @prop {Date} updatedAt
1✔
235
 * @typedef {mongoose.Document<LeanPlaylist["_id"], {}, LeanPlaylist> & LeanPlaylist & {
1✔
236
 *  readonly size: number
1✔
237
 * }} Playlist
1✔
238
 */
1✔
239

1✔
240
/**
1✔
241
 * @type {mongoose.Schema<Playlist, mongoose.Model<Playlist>>}
1✔
242
 */
1✔
243
const playlistSchema = new mongoose.Schema({
1✔
244
  name: {
1✔
245
    type: String,
1✔
246
    min: 0,
1✔
247
    max: 128,
1✔
248
    required: true,
1✔
249
    /** @type {(name: string) => string} */
1✔
250
    set: (name) => name.normalize('NFKC'),
1✔
251
  },
1✔
252
  description: { type: String, min: 0, max: 512 },
1✔
253
  author: {
1✔
254
    type: Types.ObjectId, ref: 'User', required: true, index: true,
1✔
255
  },
1✔
256
  media: [{
1✔
257
    type: Types.ObjectId,
1✔
258
    ref: 'PlaylistItem',
1✔
259
    required: true,
1✔
260
    index: true,
1✔
261
  }],
1✔
262
}, {
1✔
263
  collection: 'playlists',
1✔
264
  timestamps: true,
1✔
265
  toJSON: { getters: true },
1✔
266
  minimize: false,
1✔
267
});
1✔
268

1✔
269
/**
1✔
270
 * @typedef {object} LeanPlaylistItem
1✔
271
 * @prop {import('mongodb').ObjectId} _id
1✔
272
 * @prop {import('mongodb').ObjectId} media
1✔
273
 * @prop {string} artist
1✔
274
 * @prop {string} title
1✔
275
 * @prop {number} start
1✔
276
 * @prop {number} end
1✔
277
 * @prop {Date} createdAt
1✔
278
 * @prop {Date} updatedAt
1✔
279
 * @typedef {mongoose.Document<LeanPlaylistItem["_id"], {}, LeanPlaylistItem> &
1✔
280
 *           LeanPlaylistItem} PlaylistItem
1✔
281
 */
1✔
282

1✔
283
/**
1✔
284
 * @type {mongoose.Schema<PlaylistItem, mongoose.Model<PlaylistItem>>}
1✔
285
 */
1✔
286
const playlistItemSchema = new mongoose.Schema({
1✔
287
  media: {
1✔
288
    type: Types.ObjectId,
1✔
289
    ref: 'Media',
1✔
290
    required: true,
1✔
291
    index: true,
1✔
292
  },
1✔
293
  artist: {
1✔
294
    type: String,
1✔
295
    max: 128,
1✔
296
    required: true,
1✔
297
    index: true,
1✔
298
    /** @type {(name: string) => string} */
1✔
299
    set: (artist) => artist.normalize('NFKC'),
1✔
300
  },
1✔
301
  title: {
1✔
302
    type: String,
1✔
303
    max: 128,
1✔
304
    required: true,
1✔
305
    index: true,
1✔
306
    /** @type {(name: string) => string} */
1✔
307
    set: (title) => title.normalize('NFKC'),
1✔
308
  },
1✔
309
  start: { type: Number, min: 0, default: 0 },
1✔
310
  end: { type: Number, min: 0, default: 0 },
1✔
311
}, {
1✔
312
  timestamps: true,
1✔
313
  minimize: false,
1✔
314
});
1✔
315

1✔
316
/**
1✔
317
 * @typedef {object} LeanBanned
1✔
318
 * @prop {import('mongodb').ObjectId} moderator
1✔
319
 * @prop {number} duration
1✔
320
 * @prop {Date} [expiresAt]
1✔
321
 * @prop {string} reason
1✔
322
 */
1✔
323

1✔
324
/**
1✔
325
 * @typedef {object} LeanUser
1✔
326
 * @prop {import('mongodb').ObjectId} _id
1✔
327
 * @prop {string} username
1✔
328
 * @prop {string} language
1✔
329
 * @prop {string[]} roles
1✔
330
 * @prop {string} avatar
1✔
331
 * @prop {string} slug
1✔
332
 * @prop {import('mongodb').ObjectId|null} activePlaylist
1✔
333
 * @prop {Date} lastSeenAt
1✔
334
 * @prop {LeanBanned|undefined} banned
1✔
335
 * @prop {string|undefined} pendingActivation
1✔
336
 * @prop {Date} createdAt
1✔
337
 * @prop {Date} updatedAt
1✔
338
 * @prop {number} role - Deprecated, do not use
1✔
339
 * @prop {number} level - Deprecated, do not use
1✔
340
 * @prop {boolean} exiled - Deprecated, do not use
1✔
341
 * @typedef {mongoose.Document<LeanUser["_id"], {}, LeanUser> & LeanUser} User
1✔
342
 */
1✔
343

1✔
344
const bannedSchema = new mongoose.Schema({
1✔
345
  moderator: { type: Types.ObjectId, ref: 'User', index: true },
1✔
346
  duration: { type: Number, required: true },
1✔
347
  expiresAt: { type: Date, required: true, index: true },
1✔
348
  reason: { type: String, default: '' },
1✔
349
});
1✔
350

1✔
351
/**
1✔
352
 * @type {mongoose.Schema<User, mongoose.Model<User, {}, {}>, {}>}
1✔
353
 */
1✔
354
const userSchema = new mongoose.Schema({
1✔
355
  username: {
1✔
356
    type: String,
1✔
357
    minlength: [3, 'Usernames have to be at least 3 characters long.'],
1✔
358
    maxlength: [32, 'Usernames can be at most 32 characters long.'],
1✔
359
    match: /^[^\s]+$/,
1✔
360
    required: true,
1✔
361
    unique: true,
1✔
362
    index: true,
1✔
363
    /** @type {(name: string) => string} */
1✔
364
    set: (name) => name.normalize('NFKC'),
1✔
365
  },
1✔
366
  language: {
1✔
367
    type: String, min: 2, max: 2, default: 'en',
1✔
368
  },
1✔
369
  roles: [{ type: String, ref: 'AclRole' }],
1✔
370
  // Deprecated, `roles` should be used instead.
1✔
371
  // However some clients (*cough* u-wave-web *cough*) haven't updated to the
1✔
372
  // ACL system so they need this key to exist.
1✔
373
  role: { type: Number, min: 0, default: 0 },
1✔
374
  avatar: {
1✔
375
    type: String, min: 0, max: 256, default: '',
1✔
376
  },
1✔
377
  slug: {
1✔
378
    type: String,
1✔
379
    unique: true,
1✔
380
    required: [true, 'Usernames must not consist of punctuation only.'],
1✔
381
    index: true,
1✔
382
  },
1✔
383
  activePlaylist: {
1✔
384
    type: Types.ObjectId,
1✔
385
    ref: 'Playlist',
1✔
386
  },
1✔
387
  level: {
1✔
388
    type: Number, min: 0, max: 9001, default: 0,
1✔
389
  },
1✔
390
  lastSeenAt: { type: Date, default: () => new Date() },
1✔
391
  exiled: { type: Boolean, default: false },
1✔
392
  banned: bannedSchema,
1✔
393
  pendingActivation: { type: String, required: false },
1✔
394
}, {
1✔
395
  timestamps: true,
1✔
396
  minimize: false,
1✔
397
});
1✔
398

1✔
399
/**
1✔
400
 * @param {import('umzug').MigrationParams<import('../Uwave').default>} params
1✔
401
 */
1✔
402
async function up({ context: uw }) {
132✔
403
  const { db } = uw;
132✔
404

132✔
405
  if (uw.options.mongo == null) {
132✔
406
    return;
132✔
407
  }
132✔
408

×
409
  const mongo = await mongoose.connect(uw.options.mongo).catch(() => null);
×
410
  if (mongo == null) {
×
411
    return;
×
412
  }
×
413

×
414
  const models = {
×
415
    AclRole: mongo.model('AclRole', aclRoleSchema),
×
416
    Authentication: mongo.model('Authentication', authenticationSchema),
×
417
    Config: mongo.model('Config', configSchema),
×
418
    HistoryEntry: mongo.model('History', historySchema),
×
419
    Media: mongo.model('Media', mediaSchema),
×
420
    Migration: mongo.model('Migration', migrationSchema),
×
421
    Playlist: mongo.model('Playlist', playlistSchema),
×
422
    PlaylistItem: mongo.model('PlaylistItem', playlistItemSchema),
×
423
    User: mongo.model('User', userSchema),
×
424
  };
×
425

×
426
  // For now redis is still required.
×
427
  const motd = await uw.redis.get('motd');
×
428

×
429
  /** @type {Map<string, string>} */
×
NEW
430
  const mediaIDs = new Map();
×
NEW
431
  /** @type {Map<string, string>} */
×
NEW
432
  const userIDs = new Map();
×
NEW
433
  /** @type {Map<string, string>} */
×
NEW
434
  const playlistIDs = new Map();
×
NEW
435
  /** @type {Map<string, string>} */
×
NEW
436
  const playlistItemIDs = new Map();
×
437

×
438
  await db.transaction().execute(async (tx) => {
×
439
    for await (const config of models.Config.find().lean()) {
×
440
      const { _id: name, ...value } = config;
×
441
      await tx.insertInto('configuration')
×
442
        .values({ name, value: jsonb(value) })
×
443
        .execute();
×
444
    }
×
445

×
446
    if (motd != null && motd !== '') {
×
447
      await tx.insertInto('configuration')
×
448
        .values({ name: 'u-wave:motd', value: jsonb(motd) })
×
449
        .execute();
×
450
    }
×
451

×
452
    for await (const media of models.Media.find().lean()) {
×
NEW
453
      const { id } = await tx.insertInto('media')
×
454
        .values({
×
NEW
455
          id: randomUUID(),
×
456
          sourceType: media.sourceType,
×
457
          sourceID: media.sourceID,
×
458
          sourceData: jsonb(media.sourceData),
×
459
          artist: media.artist,
×
NEW
460
          title: media.title ?? '',
×
461
          duration: media.duration,
×
462
          thumbnail: media.thumbnail,
×
NEW
463
          createdAt: (media.createdAt ?? media.updatedAt ?? new Date()).toISOString(),
×
NEW
464
          updatedAt: (media.updatedAt ?? new Date()).toISOString(),
×
465
        })
×
466
        .onConflict((conflict) => conflict.columns(['sourceType', 'sourceID']).doUpdateSet({
×
467
          updatedAt: (eb) => eb.ref('excluded.updatedAt'),
×
468
        }))
×
NEW
469
        .returning('id')
×
NEW
470
        .executeTakeFirstOrThrow();
×
471

×
NEW
472
      mediaIDs.set(media._id.toString(), id);
×
473
    }
×
474

×
475
    const roles = await models.AclRole.find().lean();
×
476
    /** @type {Record<string, string[]>} */
×
477
    const roleMap = Object.create(null);
×
478
    for (const role of roles) {
×
479
      if (role._id.includes('.') || role._id === '*') {
×
480
        continue;
×
481
      }
×
482

×
483
      roleMap[role._id] = role.roles ?? [];
×
484
    }
×
485
    const permissionRows = Object.entries(roleMap).map(([role, permissions]) => ({
×
486
      id: role,
×
487
      permissions: jsonb(
×
488
        permissions.flatMap((perm) => perm.includes('.') || perm === '*' ? [perm] : roleMap[perm]),
×
489
      ),
×
490
    }));
×
491

×
492
    if (permissionRows.length > 0) {
×
493
      await tx.insertInto('roles')
×
494
        .values(permissionRows)
×
495
        .execute();
×
496
    }
×
497

×
498
    for await (const user of models.User.find().lean()) {
×
499
      const userID = randomUUID();
×
NEW
500
      userIDs.set(user._id.toString(), userID);
×
501

×
502
      await tx.insertInto('users')
×
503
        .values({
×
504
          id: userID,
×
505
          username: user.username,
×
506
          slug: user.slug,
×
507
          createdAt: user.createdAt.toISOString(),
×
NEW
508
          updatedAt: (user.updatedAt ?? user.createdAt).toISOString(),
×
509
        })
×
510
        .execute();
×
511

×
512
      if (user.roles.length > 0) {
×
513
        await tx.insertInto('userRoles')
×
514
          .values(user.roles.map((role) => ({ userID, role })))
×
515
          .execute();
×
516
      }
×
517

×
518
      for await (const playlist of models.Playlist.where('author', user._id).lean()) {
×
519
        const playlistID = randomUUID();
×
NEW
520
        playlistIDs.set(playlist._id.toString(), playlistID);
×
521

×
522
        await tx.insertInto('playlists')
×
523
          .values({
×
524
            id: playlistID,
×
525
            name: playlist.name,
×
526
            userID,
×
NEW
527
            // Old objects use the `.created` property
×
NEW
528
            createdAt: (playlist.createdAt ?? playlist.created).toISOString(),
×
NEW
529
            updatedAt: (playlist.updatedAt ?? playlist.created).toISOString(),
×
530
          })
×
531
          .execute();
×
532

×
533
        const items = [];
×
534
        for (const itemMongoID of playlist.media) {
×
535
          const itemID = randomUUID();
×
NEW
536
          playlistItemIDs.set(itemMongoID.toString(), itemID);
×
537

×
538
          const item = await models.PlaylistItem.findById(itemMongoID).lean();
×
NEW
539
          const mediaID = mediaIDs.get(item.media.toString());
×
NEW
540

×
541
          await tx.insertInto('playlistItems')
×
542
            .values({
×
543
              id: itemID,
×
544
              playlistID,
×
NEW
545
              mediaID,
×
546
              artist: item.artist,
×
547
              title: item.title,
×
548
              start: item.start,
×
NEW
549
              end: item.end ?? 0, // Not ideal, but what can we do
×
NEW
550
              createdAt: (item.createdAt ?? item.updatedAt ?? new Date()).toISOString(),
×
NEW
551
              updatedAt: (item.updatedAt ?? new Date()).toISOString(),
×
552
            })
×
553
            .execute();
×
554

×
555
          items.push(itemID);
×
556
        }
×
557

×
558
        await tx.updateTable('playlists')
×
559
          .where('id', '=', playlistID)
×
560
          .set({ items: jsonb(items) })
×
561
          .execute();
×
562
      }
×
563
    }
×
564

×
565
    for await (const entry of models.Authentication.find().lean()) {
×
NEW
566
      const userID = userIDs.get(entry.user.toString());
×
567
      if (userID == null) {
×
568
        throw new Error('Migration failure: unknown user ID');
×
569
      }
×
570

×
571
      if (entry.email != null) {
×
572
        await tx.updateTable('users')
×
573
          .where('id', '=', userID)
×
574
          .set({ email: entry.email })
×
575
          .execute();
×
576
      }
×
577

×
578
      if (entry.hash != null) {
×
579
        await tx.updateTable('users')
×
580
          .where('id', '=', userID)
×
581
          .set({ password: entry.hash })
×
582
          .execute();
×
583
      }
×
584
    }
×
585

×
586
    for await (const entry of models.HistoryEntry.find().lean()) {
×
587
      const entryID = randomUUID();
×
NEW
588
      const userID = userIDs.get(entry.user.toString());
×
NEW
589
      const mediaID = mediaIDs.get(entry.media.media.toString());
×
590
      await tx.insertInto('historyEntries')
×
591
        .values({
×
592
          id: entryID,
×
593
          mediaID,
×
594
          userID,
×
595
          artist: entry.media.artist,
×
596
          title: entry.media.title,
×
597
          start: entry.media.start,
×
598
          end: entry.media.end,
×
599
          sourceData: jsonb(entry.media.sourceData),
×
600
          createdAt: entry.playedAt.toISOString(),
×
601
        })
×
602
        .execute();
×
603

×
604
      const feedback = new Map();
×
605
      for (const id of entry.upvotes) {
×
606
        feedback.set(id.toString(), {
×
607
          historyEntryID: entryID,
×
NEW
608
          userID: userIDs.get(id.toString()),
×
609
          vote: 1,
×
610
        });
×
611
      }
×
612
      for (const id of entry.downvotes) {
×
613
        feedback.set(id.toString(), {
×
614
          historyEntryID: entryID,
×
NEW
615
          userID: userIDs.get(id.toString()),
×
616
          vote: -1,
×
617
        });
×
618
      }
×
619
      for (const id of entry.favorites) {
×
620
        const entry = feedback.get(id.toString());
×
621
        if (entry != null) {
×
622
          entry.favorite = 1;
×
623
        } else {
×
624
          feedback.set(id.toString(), {
×
625
            historyEntryID: entryID,
×
NEW
626
            userID: userIDs.get(id.toString()),
×
627
            favorite: 1,
×
628
          });
×
629
        }
×
630
      }
×
631

×
632
      if (feedback.size > 0) {
×
633
        await tx.insertInto('feedback')
×
634
          .values(Array.from(feedback.values()))
×
635
          .execute();
×
636
      }
×
637
    }
×
638
  })
×
639
    .finally(() => mongo.disconnect());
×
640
}
132✔
641

1✔
642
/**
1✔
643
 * @param {import('umzug').MigrationParams<import('../Uwave').default>} params
1✔
644
 */
1✔
645
async function down() {}
×
646

1✔
647
module.exports = { up, down };
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