Skip to content

Commit efd3b04

Browse files
authored
Add database migration infrastructure + nbsVersion field on song stats (#94)
2 parents 0ac564d + 5bcc875 commit efd3b04

18 files changed

Lines changed: 455 additions & 0 deletions

File tree

apps/backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { EmailLoginModule } from './email-login/email-login.module';
1212
import { FileModule } from './file/file.module';
1313
import { ParseTokenPipe } from './lib/parseToken';
1414
import { MailingModule } from './mailing/mailing.module';
15+
import { MigrationModule } from './migration/migration.module';
1516
import { SeedModule } from './seed/seed.module';
1617
import { SongModule } from './song/song.module';
1718
import { UserModule } from './user/user.module';
@@ -78,6 +79,7 @@ import { UserModule } from './user/user.module';
7879
SeedModule.forRoot(),
7980
EmailLoginModule,
8081
MailingModule,
82+
MigrationModule,
8183
],
8284
controllers: [],
8385
providers: [

apps/backend/src/main.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as express from 'express';
55
import { AppModule } from './app.module';
66
import { initializeSwagger } from './lib/initializeSwagger';
77
import { ParseTokenPipe } from './lib/parseToken';
8+
import { MigrationService } from './migration/migration.service';
89

910
const logger: Logger = new Logger('main.ts');
1011

@@ -47,6 +48,10 @@ async function bootstrap() {
4748

4849
app.use('/v1', express.static('public'));
4950

51+
const migrationService = app.get(MigrationService);
52+
const migrationResults = await migrationService.runAllPendingMigrations();
53+
logger.log(`Migrations complete: ${JSON.stringify(migrationResults)}`);
54+
5055
const port = process.env.PORT || '4000';
5156

5257
logger.log('Note Block World API Backend 🎶🌎🌍🌏 ');
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Module } from '@nestjs/common';
2+
import { MongooseModule } from '@nestjs/mongoose';
3+
4+
import { Song, SongSchema } from '@nbw/database';
5+
6+
import { MigrationService } from './migration.service';
7+
8+
@Module({
9+
imports: [
10+
MongooseModule.forFeature([{ name: Song.name, schema: SongSchema }]),
11+
],
12+
providers: [MigrationService],
13+
exports: [MigrationService],
14+
})
15+
export class MigrationModule {}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { InjectModel } from '@nestjs/mongoose';
3+
import { Model } from 'mongoose';
4+
5+
import {
6+
Song,
7+
SONG_MIGRATION_REGISTRY,
8+
SONG_PENDING_QUERY_CLAUSES,
9+
buildPendingQuery,
10+
isFullyMigrated,
11+
migrateDocument,
12+
type MigrationRegistry,
13+
} from '@nbw/database';
14+
15+
export interface MigrationBatchResult {
16+
collection: string;
17+
matched: number;
18+
migrated: number;
19+
errors: string[];
20+
}
21+
22+
export interface MigrationStatus {
23+
collection: string;
24+
currentVersion: number;
25+
pendingCount: number;
26+
}
27+
28+
const DEFAULT_BATCH_SIZE = 500;
29+
30+
@Injectable()
31+
export class MigrationService {
32+
private readonly logger = new Logger(MigrationService.name);
33+
34+
private readonly registries = new Map<string, MigrationRegistry<object>>([
35+
[SONG_MIGRATION_REGISTRY.collection, SONG_MIGRATION_REGISTRY],
36+
]);
37+
38+
constructor(
39+
@InjectModel(Song.name)
40+
private readonly songModel: Model<Song>,
41+
) {}
42+
43+
async getMigrationStatus(collection: string): Promise<MigrationStatus> {
44+
const registry = this.getRegistry(collection);
45+
const model = this.getModel(collection);
46+
const pendingQuery = this.buildRegistryPendingQuery(collection);
47+
48+
const pendingCount = await model.countDocuments(pendingQuery);
49+
50+
return {
51+
collection,
52+
currentVersion: registry.currentVersion,
53+
pendingCount,
54+
};
55+
}
56+
57+
async runAllPendingMigrations(options?: {
58+
batchSize?: number;
59+
}): Promise<MigrationBatchResult[]> {
60+
const results: MigrationBatchResult[] = [];
61+
62+
for (const collection of this.registries.keys()) {
63+
results.push(await this.runBulkMigration(collection, options));
64+
}
65+
66+
return results;
67+
}
68+
69+
async runBulkMigration(
70+
collection: string,
71+
options?: { batchSize?: number },
72+
): Promise<MigrationBatchResult> {
73+
const registry = this.getRegistry(collection);
74+
const model = this.getModel(collection);
75+
const batchSize = options?.batchSize ?? DEFAULT_BATCH_SIZE;
76+
const pendingQuery = this.buildRegistryPendingQuery(collection);
77+
78+
const matched = await model.countDocuments(pendingQuery);
79+
80+
if (matched === 0) {
81+
this.logger.log(
82+
`No pending migrations for ${collection} (current version ${registry.currentVersion})`,
83+
);
84+
85+
return {
86+
collection,
87+
matched: 0,
88+
migrated: 0,
89+
errors: [],
90+
};
91+
}
92+
93+
this.logger.log(
94+
`Migrating ${matched} ${collection} document(s) to schema version ${registry.currentVersion}`,
95+
);
96+
97+
let migrated = 0;
98+
const errors: string[] = [];
99+
let lastId: unknown;
100+
101+
while (true) {
102+
const query = lastId
103+
? { ...pendingQuery, _id: { $gt: lastId } }
104+
: pendingQuery;
105+
106+
const batch = await model
107+
.find(query)
108+
.sort({ _id: 1 })
109+
.limit(batchSize)
110+
.exec();
111+
112+
if (batch.length === 0) {
113+
break;
114+
}
115+
116+
for (const doc of batch) {
117+
try {
118+
if (isFullyMigrated(registry, doc)) {
119+
lastId = doc._id;
120+
continue;
121+
}
122+
123+
const updated = migrateDocument(registry, doc.toObject());
124+
125+
doc.set(updated);
126+
await doc.save();
127+
migrated++;
128+
} catch (error) {
129+
const message =
130+
error instanceof Error ? error.message : String(error);
131+
errors.push(`${doc._id}: ${message}`);
132+
}
133+
134+
lastId = doc._id;
135+
}
136+
}
137+
138+
if (errors.length > 0) {
139+
throw new Error(
140+
`Migration failed for ${collection}: ${errors.join('; ')}`,
141+
);
142+
}
143+
144+
this.logger.log(
145+
`Migrated ${migrated}/${matched} ${collection} document(s)`,
146+
);
147+
148+
return {
149+
collection,
150+
matched,
151+
migrated,
152+
errors,
153+
};
154+
}
155+
156+
private getRegistry(collection: string): MigrationRegistry<object> {
157+
const registry = this.registries.get(collection);
158+
159+
if (!registry) {
160+
throw new Error(`No migration registry registered for ${collection}`);
161+
}
162+
163+
return registry;
164+
}
165+
166+
private getModel(collection: string): Model<Song> {
167+
if (collection === SONG_MIGRATION_REGISTRY.collection) {
168+
return this.songModel;
169+
}
170+
171+
throw new Error(`No model registered for ${collection}`);
172+
}
173+
174+
private buildRegistryPendingQuery(collection: string) {
175+
if (collection === SONG_MIGRATION_REGISTRY.collection) {
176+
return buildPendingQuery(
177+
SONG_MIGRATION_REGISTRY,
178+
SONG_PENDING_QUERY_CLAUSES,
179+
);
180+
}
181+
182+
return buildPendingQuery(this.getRegistry(collection));
183+
}
184+
}

apps/backend/src/song/song-upload/song-upload.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { Types } from 'mongoose';
1010

1111
import {
12+
CURRENT_SONG_SCHEMA_VERSION,
1213
SongDocument,
1314
Song as SongEntity,
1415
SongStats,
@@ -111,6 +112,7 @@ export class SongUploadService {
111112
file: Express.Multer.File,
112113
): Promise<SongEntity> {
113114
const song = new SongEntity();
115+
song.schemaVersion = CURRENT_SONG_SCHEMA_VERSION;
114116
song.uploader = await this.validateUploader(user);
115117
song.publicId = publicId;
116118
song.title = removeExtraSpaces(body.title);

apps/backend/src/song/song.service.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ describe('SongService', () => {
133133
vanillaInstrumentCount: 10,
134134
customInstrumentCount: 0,
135135
firstCustomInstrumentIndex: 0,
136+
nbsVersion: 5,
137+
defaultInstrumentCount: 16,
136138
outOfRangeNoteCount: 0,
137139
detunedNoteCount: 0,
138140
customInstrumentNoteCount: 0,
@@ -351,6 +353,8 @@ describe('SongService', () => {
351353
vanillaInstrumentCount: 10,
352354
customInstrumentCount: 0,
353355
firstCustomInstrumentIndex: 0,
356+
nbsVersion: 5,
357+
defaultInstrumentCount: 16,
354358
outOfRangeNoteCount: 0,
355359
detunedNoteCount: 0,
356360
customInstrumentNoteCount: 0,

packages/database/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export * from './song/dto/UploadSongResponseDto.dto';
1515
export * from './song/dto/types';
1616
export * from './song/entity/song.entity';
1717

18+
export * from './migration';
19+
1820
export * from './user/dto/CreateUser.dto';
1921
export * from './user/dto/GetUser.dto';
2022
export * from './user/dto/Login.dto copy';
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { Song } from '../../song/entity/song.entity';
2+
import type { MigrationRegistry } from '../types';
3+
4+
export const CURRENT_SONG_SCHEMA_VERSION = 1;
5+
6+
type SongMigrationDoc = Pick<Song, 'schemaVersion' | 'stats'>;
7+
8+
function songNeedsMigration1(doc: SongMigrationDoc): boolean {
9+
const schemaVersion = doc.schemaVersion ?? 0;
10+
11+
return (
12+
schemaVersion < 1 ||
13+
doc.stats?.nbsVersion == null ||
14+
doc.stats?.defaultInstrumentCount == null
15+
);
16+
}
17+
18+
function applySongMigration1(doc: SongMigrationDoc): SongMigrationDoc {
19+
return {
20+
...doc,
21+
stats: {
22+
...doc.stats,
23+
nbsVersion: doc.stats?.nbsVersion ?? 5,
24+
defaultInstrumentCount: doc.stats?.defaultInstrumentCount ?? 16,
25+
},
26+
};
27+
}
28+
29+
export const SONG_MIGRATION_REGISTRY: MigrationRegistry<SongMigrationDoc> = {
30+
collection: 'songs',
31+
currentVersion: CURRENT_SONG_SCHEMA_VERSION,
32+
migrations: [
33+
{
34+
version: 1,
35+
name: 'add-nbs-version-stats',
36+
collection: 'songs',
37+
needsMigration: songNeedsMigration1,
38+
apply: applySongMigration1,
39+
},
40+
],
41+
};
42+
43+
export const SONG_PENDING_QUERY_CLAUSES = [
44+
{ 'stats.nbsVersion': { $exists: false } },
45+
{ 'stats.defaultInstrumentCount': { $exists: false } },
46+
];
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './types';
2+
export * from './runner';
3+
export * from './collections/song.migrations';

0 commit comments

Comments
 (0)