Files
diegosouzapw--omniroute/tests/unit/migration-safety-abort-6260.test.ts
2026-07-13 13:39:12 +08:00

195 lines
6.4 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import Database from "better-sqlite3";
import { resetDbInstance } from "../../src/lib/db/core.ts";
// Regression guard for #6260:
// 1. The mass-migration safety-abort message must tell the operator how to
// bypass the check (OMNIROUTE_MAX_PENDING_MIGRATIONS=0) — e.g. after
// restoring a backup where the migration tracking table was wiped.
// 2. Repeated runMigrations() calls on the same over-threshold DB must throw
// the SAME memoized MigrationSafetyAbortError instance, so downstream
// subsystems re-opening the DB do not re-compute + re-log the full abort
// banner 11+ times (the cascade described in the issue).
const serial = { concurrency: false };
async function importFresh(modulePath: string) {
const url = pathToFileURL(path.resolve(modulePath)).href;
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
}
function withMockedMigrationFs<T>(files: Record<string, string>, fn: () => T): T {
const originalExistsSync = fs.existsSync;
const originalReaddirSync = fs.readdirSync;
const originalReadFileSync = fs.readFileSync;
const isMigrationDir = (target: unknown) =>
String(target).replaceAll("\\", "/").endsWith("/src/lib/db/migrations") ||
String(target).replaceAll("\\", "/").endsWith("/migrations");
fs.existsSync = ((target: fs.PathLike) => {
if (isMigrationDir(target)) return true;
const fileName = path.basename(String(target));
if (Object.hasOwn(files, fileName)) return true;
return originalExistsSync(target);
}) as typeof fs.existsSync;
fs.readdirSync = ((target: fs.PathLike, options?: unknown) => {
if (isMigrationDir(target)) return Object.keys(files);
return (originalReaddirSync as (t: fs.PathLike, o?: unknown) => unknown)(target, options);
}) as typeof fs.readdirSync;
fs.readFileSync = ((target: fs.PathOrFileDescriptor, options?: unknown) => {
const fileName = path.basename(String(target));
if (Object.hasOwn(files, fileName)) return files[fileName];
return (originalReadFileSync as (t: fs.PathOrFileDescriptor, o?: unknown) => unknown)(
target,
options
);
}) as typeof fs.readFileSync;
try {
return fn();
} finally {
fs.existsSync = originalExistsSync;
fs.readdirSync = originalReaddirSync;
fs.readFileSync = originalReadFileSync;
}
}
function withNonTestEnvironment<T>(fn: () => T): T {
const originalNodeEnv = process.env.NODE_ENV;
const originalVitest = process.env.VITEST;
const originalDisableAutoBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
const originalArgv = [...process.argv];
delete process.env.NODE_ENV;
delete process.env.VITEST;
delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
process.argv = process.argv.filter((arg) => !arg.includes("test"));
try {
return fn();
} finally {
process.argv = originalArgv;
if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = originalNodeEnv;
if (originalVitest === undefined) delete process.env.VITEST;
else process.env.VITEST = originalVitest;
if (originalDisableAutoBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
else process.env.DISABLE_SQLITE_AUTO_BACKUP = originalDisableAutoBackup;
}
}
// Existing DB with only the migrations table + one applied row and no physical
// schema sentinel tables, so inferPhysicalSchemaBaseline() returns null and the
// abort decision depends purely on the resolved threshold.
function seedExistingDbWithoutPhysicalBaseline(db: InstanceType<typeof Database>) {
db.exec(`
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(
"001",
"initial_schema"
);
}
function buildMockMigrationFiles(startVersion: number, endVersion: number, prefix: string) {
const files: Record<string, string> = {};
for (let version = startVersion; version <= endVersion; version++) {
const padded = String(version).padStart(3, "0");
const fileName = version === 1 ? "001_initial_schema.sql" : `${padded}_${prefix}_${padded}.sql`;
files[fileName] = `CREATE TABLE ${prefix}_${padded} (id INTEGER);`;
}
return files;
}
function createDb() {
return new Database(":memory:");
}
test.after(() => {
resetDbInstance();
});
test(
"abort message tells the operator to set OMNIROUTE_MAX_PENDING_MIGRATIONS=0 to bypass (#6260)",
serial,
async () => {
const runner = await importFresh("src/lib/db/migrationRunner.ts");
const db = createDb();
try {
seedExistingDbWithoutPhysicalBaseline(db);
let thrown: unknown;
assert.throws(() => {
try {
withNonTestEnvironment(() =>
withMockedMigrationFs(buildMockMigrationFiles(1, 60, "bypass_hint"), () =>
runner.runMigrations(db)
)
);
} catch (err) {
thrown = err;
throw err;
}
});
const message = thrown instanceof Error ? thrown.message : String(thrown);
assert.match(message, /OMNIROUTE_MAX_PENDING_MIGRATIONS=0/);
} finally {
db.close();
}
}
);
test(
"two consecutive aborts on the same over-threshold DB throw the SAME memoized instance (#6260)",
serial,
async () => {
const runner = await importFresh("src/lib/db/migrationRunner.ts");
const db = createDb();
try {
seedExistingDbWithoutPhysicalBaseline(db);
const runOnce = () =>
withNonTestEnvironment(() =>
withMockedMigrationFs(buildMockMigrationFiles(1, 60, "cascade"), () =>
runner.runMigrations(db)
)
);
let first: unknown;
let second: unknown;
assert.throws(() => {
try {
runOnce();
} catch (err) {
first = err;
throw err;
}
});
assert.throws(() => {
try {
runOnce();
} catch (err) {
second = err;
throw err;
}
});
assert.ok(first instanceof runner.MigrationSafetyAbortError);
assert.ok(second instanceof runner.MigrationSafetyAbortError);
assert.strictEqual(first, second, "cascade re-triggers must reuse the memoized instance");
} finally {
db.close();
}
}
);