459d636ffb
A long-lived branch can add a migration whose timestamped filename sorts BEFORE migrations already applied in prod (#234's 20260627T130000-ai-chat-runs merged after 20260704T120000-client-metrics was live). Kysely's migrator with the default ordered setting then rejects the applied set as "corrupted migrations" (no longer a prefix of the sorted list), throws, and the app crash-loops on boot — exactly incident #361 (502s for ~11 min after a develop deploy). #119 and #120 (June branches) are the next such threats. Two levels, both: 1. CI migration-order gate (a new `migration-order` job in test.yml, PR-only): fails the PR when an added migration sorts at/before the newest migration on the base branch, with an actionable message to rename it to a current timestamp before merge. This is the primary defense — makes back-dating impossible to merge accidentally. 2. `allowUnorderedMigrations: true` on BOTH Migrators (migration.service.ts startup auto-migrate + migrate.ts CLI): the runtime safety net — Kysely applies a not-yet-applied older migration instead of bricking startup, so a back-dated migration that bypasses the gate (manual push / hotfix branch) still boots. Trade-off documented inline: apply order across instances may differ from lexicographic, so migrations must stay independent (ours each create their own objects); the CI gate remains the primary line. Verified: allowUnorderedMigrations is a valid Kysely 0.28.17 Migrator option; server tsc clean; the gate script rejects a back-dated filename and passes a current one. No new deps, no migration, no runtime behavior change beyond the migrator resilience. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
58 lines
2.1 KiB
TypeScript
58 lines
2.1 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import * as path from 'path';
|
|
import { promises as fs } from 'fs';
|
|
import { Migrator, FileMigrationProvider } from 'kysely';
|
|
import { InjectKysely } from 'nestjs-kysely';
|
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|
|
|
@Injectable()
|
|
export class MigrationService {
|
|
private readonly logger = new Logger(`Database${MigrationService.name}`);
|
|
|
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
|
|
|
async migrateToLatest(): Promise<void> {
|
|
const migrator = new Migrator({
|
|
db: this.db,
|
|
provider: new FileMigrationProvider({
|
|
fs,
|
|
path,
|
|
migrationFolder: path.join(__dirname, '..', 'migrations'),
|
|
}),
|
|
// A long-lived branch can add a migration whose timestamped filename sorts
|
|
// BEFORE migrations already applied in prod (e.g. #234's 20260627 landing
|
|
// after 20260704 was live). With the default (ordered) setting the startup
|
|
// migrator then sees "corrupted migrations" — the applied set is no longer a
|
|
// prefix of the sorted list — throws, and the app crash-loops on boot
|
|
// (incident #361: 502s for ~11 min). allowUnorderedMigrations runs any
|
|
// not-yet-applied migration regardless of filename order, so a back-dated
|
|
// migration is applied instead of bricking startup. A CI order-gate still
|
|
// discourages back-dating; this is the runtime safety net.
|
|
allowUnorderedMigrations: true,
|
|
});
|
|
|
|
const { error, results } = await migrator.migrateToLatest();
|
|
|
|
if (results && results.length === 0) {
|
|
this.logger.log('No pending database migrations');
|
|
return;
|
|
}
|
|
|
|
results?.forEach((it) => {
|
|
if (it.status === 'Success') {
|
|
this.logger.log(
|
|
`Migration "${it.migrationName}" executed successfully`,
|
|
);
|
|
} else if (it.status === 'Error') {
|
|
this.logger.error(`Failed to execute migration "${it.migrationName}"`);
|
|
}
|
|
});
|
|
|
|
if (error) {
|
|
this.logger.error('Failed to run database migration. Exiting program.');
|
|
this.logger.error(error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|