65 lines
2.5 KiB
TypeScript
65 lines
2.5 KiB
TypeScript
/**
|
|
* PM-02 — CI guard against migration version-number collisions.
|
|
*
|
|
* The migration runner tracks applied migrations by version (PRIMARY KEY in
|
|
* _omniroute_migrations). When two files share the same numeric prefix
|
|
* (e.g. 068_a.sql + 068_b.sql), only the first is applied; the rest are
|
|
* silently skipped. This caused a production regression in v3.8.4 (three
|
|
* PRs each shipped a 068_*.sql; see _tasks/features-v3.8.4/9route/POST-MERGE-AUDIT.md).
|
|
*
|
|
* SUPERSEDED_DUPLICATE_MIGRATIONS in migrationRunner.ts intentionally allows
|
|
* known historical pairs (e.g. 041_session_account_affinity superseded by 050).
|
|
* This test honors that allow-list — only "real" (unmanaged) collisions fail.
|
|
*/
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const MIGRATIONS_DIR = path.join(process.cwd(), "src/lib/db/migrations");
|
|
|
|
// Mirror of SUPERSEDED_DUPLICATE_MIGRATIONS in migrationRunner.ts (line ~156).
|
|
// Keep in sync if the runner adds new managed pairs.
|
|
const SUPERSEDED = new Set<string>([
|
|
"041:session_account_affinity",
|
|
// Add new "version:name" entries here if you intentionally renumber a migration.
|
|
]);
|
|
|
|
test("no two migration files share the same numeric prefix", () => {
|
|
const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql"));
|
|
|
|
const byVersion = new Map<string, string[]>();
|
|
for (const file of files) {
|
|
const m = file.match(/^(\d+)_(.+)\.sql$/);
|
|
if (!m) continue;
|
|
const [, version, name] = m;
|
|
if (!byVersion.has(version)) byVersion.set(version, []);
|
|
byVersion.get(version)!.push(name);
|
|
}
|
|
|
|
const realCollisions = Array.from(byVersion.entries())
|
|
.filter(([, names]) => names.length > 1)
|
|
.map(([version, names]) => ({
|
|
version,
|
|
liveNames: names.filter((n) => !SUPERSEDED.has(`${version}:${n}`)),
|
|
}))
|
|
.filter((c) => c.liveNames.length > 1);
|
|
|
|
assert.deepEqual(
|
|
realCollisions,
|
|
[],
|
|
`Migration version collisions detected:\n${realCollisions
|
|
.map((c) => ` ${c.version}: [${c.liveNames.join(", ")}]`)
|
|
.join(
|
|
"\n"
|
|
)}\n\nFix by renaming one of the files to a unique number AND adding a retroactive guard in migrationRunner.ts isSchemaAlreadyApplied().`
|
|
);
|
|
});
|
|
|
|
test("every migration file matches the NNN_name.sql convention", () => {
|
|
const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql"));
|
|
const bad = files.filter((f) => !/^\d+_[^.]+\.sql$/.test(f));
|
|
assert.deepEqual(bad, [], `Files violating NNN_name.sql convention: ${bad.join(", ")}`);
|
|
});
|