Files
TREK/server/tests/unit/db/pre-migrate-snapshot.test.ts
T
Maurice ff3615fdf0 feat(db): copy the database before a boot migrates it
Migrations only go forward, and since #2610 an older image refuses a
database a newer one migrated. Going back after a bad update therefore
needs a copy from before it, and the only copies were backups the admin
takes by hand or auto-backup, which is off by default. The first boot
on 4.4 migrates every 4.3.x install through the legacy baseline and the
new migrations without either.

Before applying pending migrations to a file database that holds
anything, the boot now writes a VACUUM INTO copy next to it, named
pre-migrate-<schema>-<time>.db (legacy-<schema_version> for an install
the positional runner left, otherwise the last recorded migration), and
keeps the newest TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP (3). The copy is
written under a temporary name and renamed, so a crash never leaves a
truncated file that looks good. If it cannot be written the boot refuses
before anything is migrated; TREK_DB_PRE_MIGRATE_SNAPSHOT=false is the
documented way past that. The newer-database refusal names the copy
taken right before the newer release migrated past this one.

A fresh install, a restart with nothing pending, an in-memory database
and a restore's re-bootstrap (the archive is the copy) take none, and
NODE_ENV=test leaves it off unless a suite sets the switch.
2026-10-08 13:02:57 +02:00

208 lines
8.4 KiB
TypeScript

/**
* The copy of the database a boot takes before it migrates
* (`src/db/pre-migrate-snapshot.ts`), driven through `migrateToHead` against a
* temp FILE database, because an in-memory one has nothing to copy.
*
* The end-to-end case (a v4.3.3 install booting through `buildApp()`) is
* `tests/integration/legacy-upgrade-v244.test.ts`.
*/
import { readEnv, type AppEnv } from '../../../src/app-config';
import { knownMigrationNames } from '../../../src/db/known-migrations';
import { migrateToHead, migrationLabel } from '../../../src/db/legacy-baseline';
import { listSnapshots, preMigrateSnapshot, pruneSnapshots } from '../../../src/db/pre-migrate-snapshot';
import { Migrator } from '@mikro-orm/migrations';
import { MikroORM } from '@mikro-orm/sqlite';
import Database from 'better-sqlite3';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const MIGRATIONS = path.join(__dirname, '../../../src/db/migrations');
const MIDPOINT = 'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null';
const NOW = new Date('2026-10-08T09:15:00.123Z');
const STAMP = '20261008T091500Z';
let dir: string;
let dbFile: string;
let orm: MikroORM;
function ormFor(dbName: string): Promise<MikroORM> {
return MikroORM.init({
entities: [],
discovery: { warnWhenNoEntities: false },
extensions: [Migrator],
migrations: { path: MIGRATIONS, pathTs: MIGRATIONS, snapshot: false, silent: true },
dbName,
});
}
const migrator = () => orm.config.getExtension('@mikro-orm/migrator') as Migrator;
/** The live env with the snapshot switches set as given. */
function envWith(db: Partial<AppEnv['db']>, isTest = false): () => AppEnv {
return () => {
const env = readEnv();
return { ...env, app: { ...env.app, isTest }, db: { ...env.db, ...db } };
};
}
function migrate(env: () => AppEnv): Promise<void> {
const connection = orm.em.getConnection();
return migrateToHead(
connection,
migrator(),
undefined,
undefined,
preMigrateSnapshot(connection, env, () => NOW),
);
}
async function recorded(): Promise<string[]> {
const rows: Array<{ name: string }> = await orm.em
.getConnection()
.execute('SELECT name FROM mikro_orm_migrations ORDER BY id');
return rows.map((row) => row.name);
}
const snapshotsIn = () =>
fs
.readdirSync(dir)
.filter((entry) => entry.startsWith('pre-migrate-'))
.sort();
beforeEach(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-premigrate-'));
dbFile = path.join(dir, 'travel.db');
orm = await ormFor(dbFile);
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
await orm.close(true);
fs.rmSync(dir, { recursive: true, force: true });
});
describe('the pre-migration snapshot', () => {
it('PREMIG-001: copies a database that is behind before migrating it, named after the schema it holds', async () => {
await migrator().up({ to: MIDPOINT });
await orm.em
.getConnection()
.execute(`INSERT INTO users (username, email, password_hash) VALUES ('kept', 'kept@example.test', 'x')`);
const before = await recorded();
await migrate(envWith({ preMigrateSnapshot: true }));
const name = `pre-migrate-${migrationLabel(MIDPOINT)}-${STAMP}.db`;
expect(snapshotsIn()).toEqual([name]);
expect(await recorded()).toHaveLength(knownMigrationNames().size);
const copy = new Database(path.join(dir, name), { readonly: true });
try {
// The copy holds the state from before the run: its rows and its migrations.
expect(copy.prepare('SELECT email FROM users').all()).toEqual([{ email: 'kept@example.test' }]);
expect(
(copy.prepare('SELECT name FROM mikro_orm_migrations ORDER BY id').all() as Array<{ name: string }>).map(
(r) => r.name,
),
).toEqual(before);
} finally {
copy.close();
}
});
it('PREMIG-002: a legacy install is labelled by its schema_version', async () => {
await orm.em.getConnection().execute('CREATE TABLE schema_version (version INTEGER NOT NULL)');
await orm.em.getConnection().execute('INSERT INTO schema_version (version) VALUES (10)');
await migrate(envWith({ preMigrateSnapshot: true }));
expect(snapshotsIn()).toEqual([`pre-migrate-legacy-10-${STAMP}.db`]);
});
it('PREMIG-003: a fresh install, a restart with nothing pending and the test default take none', async () => {
await migrate(envWith({ preMigrateSnapshot: true }));
expect(snapshotsIn()).toEqual([]);
await migrate(envWith({ preMigrateSnapshot: true }));
expect(snapshotsIn()).toEqual([]);
await orm.close(true);
fs.rmSync(dbFile);
orm = await ormFor(dbFile);
await migrator().up({ to: MIDPOINT });
await migrate(envWith({ preMigrateSnapshot: undefined }, true));
expect(snapshotsIn()).toEqual([]);
expect(await recorded()).toHaveLength(knownMigrationNames().size);
});
it('PREMIG-004: a copy that cannot be written refuses the boot and migrates nothing', async () => {
await migrator().up({ to: MIDPOINT });
const before = await recorded();
// A directory where the copy has to land makes the final rename fail.
fs.mkdirSync(path.join(dir, `pre-migrate-${migrationLabel(MIDPOINT)}-${STAMP}.db`));
await expect(migrate(envWith({ preMigrateSnapshot: true }))).rejects.toThrow(
/\[DB\] Refusing to boot: could not write a copy of the database to .* Nothing was migrated\..*TREK_DB_PRE_MIGRATE_SNAPSHOT=false/,
);
expect(await recorded()).toEqual(before);
expect(fs.readdirSync(dir).filter((entry) => entry.endsWith('.partial'))).toEqual([]);
});
it('PREMIG-005: TREK_DB_PRE_MIGRATE_SNAPSHOT=false migrates without a copy, and says so', async () => {
await migrator().up({ to: MIDPOINT });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await migrate(envWith({ preMigrateSnapshot: false }));
expect(snapshotsIn()).toEqual([]);
expect(await recorded()).toHaveLength(knownMigrationNames().size);
expect(String(warn.mock.calls[0]?.[0])).toContain('TREK_DB_PRE_MIGRATE_SNAPSHOT is off');
});
it('PREMIG-006: keeps only the newest copies', async () => {
for (const [i, name] of ['a', 'b', 'c', 'd'].entries()) {
const file = path.join(dir, `pre-migrate-${name}.db`);
fs.writeFileSync(file, '');
fs.utimesSync(file, 1_000 + i, 1_000 + i);
}
fs.writeFileSync(path.join(dir, 'pre-restore-1.db'), '');
fs.writeFileSync(path.join(dir, 'pre-migrate-e.db.partial'), '');
pruneSnapshots(dir, 2);
expect(listSnapshots(dir).map((file) => path.basename(file))).toEqual(['pre-migrate-d.db', 'pre-migrate-c.db']);
// Nothing else in the data dir is touched.
expect(fs.existsSync(path.join(dir, 'pre-restore-1.db'))).toBe(true);
expect(fs.existsSync(path.join(dir, 'pre-migrate-e.db.partial'))).toBe(true);
});
it('PREMIG-007: the boot prunes to TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP after a new copy', async () => {
for (const name of ['old-1', 'old-2']) {
const file = path.join(dir, `pre-migrate-${name}.db`);
fs.writeFileSync(file, '');
fs.utimesSync(file, 1_000, 1_000);
}
await migrator().up({ to: MIDPOINT });
await migrate(envWith({ preMigrateSnapshot: true, preMigrateSnapshotKeep: 1 }));
expect(snapshotsIn()).toEqual([`pre-migrate-${migrationLabel(MIDPOINT)}-${STAMP}.db`]);
});
it('PREMIG-008: refusing a database a newer TREK migrated names the copy taken from this release', async () => {
await migrate(envWith({ preMigrateSnapshot: true }));
const head = migrationLabel([...knownMigrationNames()].sort().at(-1) ?? '');
const match = path.join(dir, `pre-migrate-${head}-20261001T000000Z.db`);
fs.writeFileSync(match, '');
await orm.em
.getConnection()
.execute(
"INSERT INTO mikro_orm_migrations (name, executed_at) VALUES ('Migration20990101000000_from_a_newer_release', CURRENT_TIMESTAMP)",
);
const refusal = migrate(envWith({ preMigrateSnapshot: true }));
await expect(refusal).rejects.toThrow(/migrated by a newer TREK/);
await expect(refusal).rejects.toThrow(`The copy taken right before the newer TREK migrated it is ${match}`);
fs.rmSync(match);
fs.writeFileSync(path.join(dir, 'pre-migrate-legacy-200-20250101T000000Z.db'), '');
await expect(migrate(envWith({ preMigrateSnapshot: true }))).rejects.toThrow(/none of them from this release/);
});
});