fix(db): refuse a database or backup a newer TREK migrated, and keep a copy before a restore

Rolling an image back, or restoring a backup a newer release made, ran
older code against a schema it does not know and failed later, far from
the cause. Boot now refuses a database that records migrations this
build does not ship, and the restore check refuses such a backup while
the current database is still in place. A restore also keeps the
database it replaces as data/pre-restore-<time>.db.
This commit is contained in:
Maurice
2026-10-07 16:03:19 +02:00
parent 99263fdbeb
commit 71a0af02b0
8 changed files with 212 additions and 3 deletions
+27
View File
@@ -0,0 +1,27 @@
import fs from 'node:fs';
import path from 'node:path';
const MIGRATION_FILE = /^(Migration\d+_\w+)\.(?:js|ts)$/;
/**
* The migrations this build ships, by the name the migrator records in
* `mikro_orm_migrations`. Read from the directory the migrator itself reads:
* `src/db/migrations` under tsx and vitest, `dist/db/migrations` in a build.
*/
export function knownMigrationNames(dir = path.join(__dirname, 'migrations')): Set<string> {
const names = new Set<string>();
for (const entry of fs.readdirSync(dir)) {
const match = MIGRATION_FILE.exec(entry);
if (match) names.add(match[1]);
}
return names;
}
/**
* Recorded migrations this build does not ship: the database was migrated by
* a newer TREK. Running older code against it fails later and far from the
* cause, so the callers refuse instead.
*/
export function unknownMigrations(executed: Iterable<string>, known: Set<string> = knownMigrationNames()): string[] {
return [...executed].filter((name) => !known.has(name));
}
+18
View File
@@ -2,6 +2,7 @@ import type { Connection, MigrationInfo } from '@mikro-orm/core';
import type { Migrator } from '@mikro-orm/migrations';
import fs from 'node:fs';
import { knownMigrationNames, unknownMigrations } from './known-migrations';
/**
* Upgrading an install the retired hand-written runner migrated.
@@ -162,8 +163,10 @@ export async function migrateToHead(
connection: Connection,
migrator: Migrator,
readSource: ReadSource = readFromDisk,
known: Set<string> = knownMigrationNames(),
): Promise<void> {
const baselined = await planLegacyBaseline(connection, migrator, readSource);
refuseNewerDatabase((await migrator.getExecuted()).map((row) => row.name), known);
const already = new Set(baselined);
const pending = (await migrator.getPending()).filter((migration) => !already.has(migration.name));
if (pending.length === 0 && baselined.length === 0) return;
@@ -180,3 +183,18 @@ export async function migrateToHead(
await migrator.up({ transaction: trx });
});
}
/**
* A database a newer TREK migrated carries migrations this build does not
* ship. An image rolled back, or a newer backup restored, would otherwise run
* older code against a schema it does not know and fail later, far from the
* cause. Refused before anything is written.
*/
export function refuseNewerDatabase(executed: string[], known: Set<string>): void {
const unknown = unknownMigrations(executed, known);
if (unknown.length === 0) return;
throw new Error(
`${REFUSAL} the database was migrated by a newer TREK (${unknown.length} unknown migration(s), latest ${unknown[unknown.length - 1]}). ` +
'Run that version again, or restore a backup taken with this one.',
);
}
+11
View File
@@ -3,6 +3,7 @@ import path from 'path';
import unzipper from 'unzipper';
import { readEnv } from '../../app-config';
import { openDatabase } from '../../db/connection';
import { unknownMigrations } from '../../db/known-migrations';
/**
* Reading a backup archive, without touching the live database.
@@ -113,6 +114,16 @@ export function checkBackupDatabase(extractDir: string): ArchiveRefusal | null {
return { error: `Uploaded database is missing required table: ${table}. This does not appear to be a TREK backup.`, status: 400 };
}
}
// A backup a newer TREK made would be swapped in and then refused at boot
// (legacy-baseline.ts), leaving the install down. Refuse it here, while the
// current database is still in place.
if (tableNames.has('mikro_orm_migrations')) {
const executed = uploadedDb.prepare('SELECT name FROM mikro_orm_migrations').all() as { name: string }[];
const unknown = unknownMigrations(executed.map(row => row.name));
if (unknown.length > 0) {
return { error: 'This backup was made by a newer TREK version. Update TREK before restoring it.', status: 400 };
}
}
return null;
} catch {
return { error: 'Uploaded file is not a valid SQLite database', status: 400 };
+20 -1
View File
@@ -6,7 +6,7 @@ import fs from 'fs';
import { RequestContext } from '@mikro-orm/core';
import { closeDb, reinitialize } from '../../db/database';
import { MaintenanceRepository } from '../../db/repositories/MaintenanceRepository';
import { logWarn } from '../audit/audit-log.logger';
import { logInfo, logWarn } from '../audit/audit-log.logger';
import { VALID_INTERVALS } from './auto-backup.settings';
import { checkBackupDatabase, extractBackupArchive } from './backup-archive';
import { invalidatePermissionsCache } from '../permissions/permissions-cache';
@@ -415,6 +415,24 @@ export async function rehydrateUploads(storage: StorageService, extractedUploads
}
}
/**
* A copy of the database a restore is about to replace, next to it in data/.
* The swap below deletes the current file, and a restore of the wrong archive
* used to leave nothing to go back to. Best effort: a database broken enough to
* need the restore may not snapshot, and that must not block the recovery.
*/
async function snapshotBeforeRestore(): Promise<void> {
const target = path.join(dataDir, `pre-restore-${Date.now()}.db`);
try {
const em = RequestContext.getEntityManager();
if (!em) throw new Error('no EntityManager available for VACUUM INTO');
await new MaintenanceRepository(em).vacuumInto(target);
logInfo(`Restore: the replaced database was kept as ${target}`);
} catch (err) {
logWarn(`Restore: could not keep a copy of the current database (${err instanceof Error ? err.message : String(err)})`);
}
}
export async function restoreFromZip(storage: StorageService, zipPath: string): Promise<RestoreResult> {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
let reinitFailed: unknown = null;
@@ -429,6 +447,7 @@ export async function restoreFromZip(storage: StorageService, zipPath: string):
}
const extractedDb = path.join(extractDir, 'travel.db');
await snapshotBeforeRestore();
closeDb();
try {