Files
OmniRoute/tests/unit/db-migration-runner-extra-dirs.test.ts
Diego Rodrigues de Sa e Souza 9977492542 fix(db): repair the extra-migration-dirs test and env contract on CI (#8773)
Two defects shipped with #8770, both red on release/v3.8.49.

1. `OMNIROUTE_EXTRA_MIGRATIONS_DIRS` was documented in ENVIRONMENT.md but never
   added to .env.example, so the env/docs contract gate and its two tests
   (check-env-doc-sync, issue-7793-env-doc-sync-repro) failed. Added, with the
   same explanation the docs carry.

2. tests/unit/db-migration-runner-extra-dirs.test.ts passed locally and failed on
   CI, for two reasons that only appear under the CI invocation:

   - It re-imported migrationRunner.ts under a cache-busting query string to pick
     up a fresh `MIGRATIONS_DIR` per test. That is not reliable under the loader
     chain CI uses (`--import tsx/esm --import setupPolyfill --import
     isolateDataDir`): the second test got a runner still pointing at the REAL
     migrations directory and tried to apply migration 127 to an empty in-memory
     DB. The core directory is now fixed once, before the first import; only the
     extra directories vary per test, and those are resolved at call time by
     design.
   - Tests were registered with top-level `await`. With synchronous bodies that
     drains the event loop between tests and `--test-force-exit` — used by every
     CI test script — cancels the remainder of the file.

   Both constraints are now written down in the file header so the next edit does
   not reintroduce them.

Validated with the exact CI invocation, not the bare runner: 11/11, 0 cancelled.
Neighbouring suites green under the same flags: db-migration-runner (26),
check-migration-numbering (15), db-migrationrunner-constants-split (7),
db-migration-version-uniqueness (2), check-env-doc-sync (13),
issue-7793-env-doc-sync-repro (1).
2026-07-27 13:48:49 -03:00

283 lines
11 KiB
TypeScript

/**
* tests/unit/db-migration-runner-extra-dirs.test.ts
*
* Extra migration directories with a namespaced version space.
*
* The runner reads exactly one directory (`MIGRATIONS_DIR`) and requires every
* file to be `NNN_name.sql`, recording the bare number as the version. That makes
* the numeric slots a single global namespace: any distribution that ships its own
* migrations alongside the upstream set has to pick numbers out of the same range,
* and upstream keeps appending to it. When both sides claim a number, the runner
* records one name for it and silently treats the other as already applied — the
* migration never runs, on every already-provisioned database.
*
* This suite pins the extension point: `OMNIROUTE_EXTRA_MIGRATIONS_DIRS` maps
* `namespace=directory` entries (separated by `path.delimiter`), and files found
* there are recorded as `<namespace>-<number>` so they can never collide with the
* upstream numeric slots. Unset (the default, and always the case for a plain
* install) the runner behaves exactly as before.
*
* Misconfiguration fails LOUDLY rather than silently skipping schema — a typo'd
* namespace or a moved directory is the same class of defect this mechanism
* exists to prevent.
*
* TEST SHAPE — read before editing. Two constraints, both learned from CI:
*
* 1. `MIGRATIONS_DIR` is resolved ONCE, at module evaluation of
* migrationRunner.ts, so the core directory is fixed here BEFORE the first
* import and never changed again. Re-importing under a cache-busting query
* string to pick up a new value is not reliable under the loader chain CI uses
* (`--import tsx/esm --import setupPolyfill --import isolateDataDir`); an
* earlier revision did that, passed locally and failed in CI. The extra
* directories, by contrast, are resolved at CALL time, so each test varies only
* `OMNIROUTE_EXTRA_MIGRATIONS_DIRS`.
*
* 2. Tests are registered WITHOUT top-level `await`. The bodies are synchronous,
* so awaiting each one drains the event loop between tests and `--test-force-exit`
* (used by every CI test script) cancels the rest of the file.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
const tempDirs: string[] = [];
function mkTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function writeMigrations(dir: string, files: Record<string, string>): void {
for (const [name, sql] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), sql, "utf-8");
}
}
// ── The core directory must be fixed BEFORE migrationRunner.ts is imported ──────
const CORE_DIR = mkTempDir("mig-core-");
writeMigrations(CORE_DIR, {
"001_initial_schema.sql": "CREATE TABLE core_one (id INTEGER);",
"002_core_two.sql": "CREATE TABLE core_two (id INTEGER);",
});
process.env.OMNIROUTE_MIGRATIONS_DIR = CORE_DIR;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const { runMigrations } = await import("../../src/lib/db/migrationRunner.ts");
// Cleanup on process exit, NOT via test.after(): the root after-hook fires as soon
// as the first top-level test settles, while a later test has already registered its
// directories and is sitting on an `await` — it would delete a directory still in use.
process.on("exit", () => {
for (const dir of tempDirs) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
}
});
interface RunResult {
count: number;
rows: Array<{ version: string; name: string }>;
tables: string[];
}
/** Run the migrations against a fresh in-memory DB with the given extra-dir spec. */
function runWithExtras(extraSpec: string | null): RunResult {
const prev = process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS;
if (extraSpec === null) delete process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS;
else process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS = extraSpec;
const db = new Database(":memory:");
try {
const count = runMigrations(db as never, { isNewDb: true });
const rows = db
.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY rowid")
.all() as Array<{ version: string; name: string }>;
const tables = (
db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as Array<{
name: string;
}>
).map((r) => r.name);
return { count, rows, tables };
} finally {
db.close();
if (prev === undefined) delete process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS;
else process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS = prev;
}
}
test("sem a env, o runner só enxerga o diretório core (comportamento atual)", async () => {
const r = runWithExtras(null);
assert.deepEqual(
r.rows.map((x) => x.version),
["001", "002"]
);
assert.ok(r.tables.includes("core_one") && r.tables.includes("core_two"));
});
test("migration de diretório extra é aplicada e gravada com versão namespaced", async () => {
const eeDir = mkTempDir("mig-ee-");
writeMigrations(eeDir, { "001_ee_lending.sql": "CREATE TABLE ee_lending (id INTEGER);" });
const r = runWithExtras(`ee=${eeDir}`);
assert.ok(
r.tables.includes("ee_lending"),
`a migration do diretório extra deve ter rodado; count=${r.count} ` +
`rows=${JSON.stringify(r.rows)} tabelas=${r.tables.join(", ")}`
);
assert.deepEqual(
r.rows.map((x) => x.version),
["001", "002", "ee-001"],
"o número do diretório extra é gravado prefixado pelo namespace e depois das core"
);
assert.equal(r.rows.at(-1)?.name, "ee_lending");
});
test("o mesmo número em core e em diretório extra NÃO colide — ambas rodam", async () => {
const eeDir = mkTempDir("mig-collide-");
// Mesmo prefixo numérico de uma migration core: é exatamente o caso que hoje
// faz uma das duas ser silenciosamente considerada já aplicada.
writeMigrations(eeDir, { "002_ee_same_slot.sql": "CREATE TABLE ee_same_slot (id INTEGER);" });
const r = runWithExtras(`ee=${eeDir}`);
assert.ok(r.tables.includes("core_two"), "a core 002 deve ter rodado");
assert.ok(r.tables.includes("ee_same_slot"), "a extra 002 deve ter rodado também");
assert.deepEqual(
r.rows.map((x) => x.version),
["001", "002", "ee-002"]
);
});
test("dois namespaces extras coexistem, cada um no seu espaço de versão", async () => {
const a = mkTempDir("mig-nsa-");
const b = mkTempDir("mig-nsb-");
writeMigrations(a, { "001_from_a.sql": "CREATE TABLE from_a (id INTEGER);" });
writeMigrations(b, { "001_from_b.sql": "CREATE TABLE from_b (id INTEGER);" });
const r = runWithExtras(`ee=${a}${path.delimiter}lab=${b}`);
assert.ok(r.tables.includes("from_a") && r.tables.includes("from_b"));
assert.deepEqual(
r.rows.map((x) => x.version),
["001", "002", "ee-001", "lab-001"]
);
});
test("número duplicado DENTRO de um namespace extra é erro (não pode ser pulado em silêncio)", async () => {
const eeDir = mkTempDir("mig-dup-");
writeMigrations(eeDir, {
"003_first.sql": "CREATE TABLE ee_first (id INTEGER);",
"003_second.sql": "CREATE TABLE ee_second (id INTEGER);",
});
assert.throws(
() => runWithExtras(`ee=${eeDir}`),
/collision/i,
"duas migrations com o mesmo número no mesmo namespace têm que estourar"
);
});
test("spec malformada estoura em vez de ignorar o diretório", async () => {
const eeDir = mkTempDir("mig-malformed-");
writeMigrations(eeDir, { "001_x.sql": "CREATE TABLE x (id INTEGER);" });
assert.throws(
() => runWithExtras(eeDir), // sem "namespace="
/OMNIROUTE_EXTRA_MIGRATIONS_DIRS/,
"entrada sem namespace= é configuração inválida, não um diretório a ignorar"
);
});
test("namespace inválido estoura (só minúsculas/dígitos, começando por letra)", async () => {
const eeDir = mkTempDir("mig-badns-");
writeMigrations(eeDir, { "001_x.sql": "CREATE TABLE x (id INTEGER);" });
assert.throws(
() => runWithExtras(`EE Corp=${eeDir}`),
/namespace/i,
"namespace fora de [a-z][a-z0-9]* tem que estourar"
);
});
test("diretório configurado que não existe estoura (schema faltando em silêncio é o bug)", async () => {
const missing = path.join(os.tmpdir(), `mig-nao-existe-${process.pid}`);
assert.throws(
() => runWithExtras(`ee=${missing}`),
/does not exist/i,
"um diretório explicitamente configurado e ausente é erro de configuração"
);
});
test("arquivos que não casam NNN_nome.sql são ignorados, como no diretório core", async () => {
const eeDir = mkTempDir("mig-junk-");
writeMigrations(eeDir, {
"001_ok.sql": "CREATE TABLE ee_ok (id INTEGER);",
"README.md": "# não é migration",
"rascunho.sql": "CREATE TABLE nope (id INTEGER);",
});
const r = runWithExtras(`ee=${eeDir}`);
assert.ok(r.tables.includes("ee_ok"));
assert.ok(!r.tables.includes("nope"), "arquivo .sql sem prefixo numérico não deve rodar");
assert.deepEqual(
r.rows.map((x) => x.version),
["001", "002", "ee-001"]
);
});
test("diretório core ausente não impede as migrations dos extras", async () => {
// O runner devolve [] assim que MIGRATIONS_DIR não existe. Os extras são um
// conjunto independente: um core ausente não pode fazê-los desaparecer em
// silêncio — é a mesma falha de "schema some sem avisar" que isto previne.
// MIGRATIONS_DIR é lido a cada chamada por fs.existsSync, então basta remover
// o diretório durante o teste e recriá-lo depois.
const eeDir = mkTempDir("mig-extra-only-");
writeMigrations(eeDir, { "001_ee_solo.sql": "CREATE TABLE ee_solo (id INTEGER);" });
const backup = fs.readdirSync(CORE_DIR).map((f) => ({
name: f,
body: fs.readFileSync(path.join(CORE_DIR, f), "utf-8"),
}));
fs.rmSync(CORE_DIR, { recursive: true, force: true });
try {
const r = runWithExtras(`ee=${eeDir}`);
assert.ok(r.tables.includes("ee_solo"), `tabelas: ${r.tables.join(", ")}`);
assert.deepEqual(
r.rows.map((x) => x.version),
["ee-001"]
);
} finally {
fs.mkdirSync(CORE_DIR, { recursive: true });
for (const f of backup) fs.writeFileSync(path.join(CORE_DIR, f.name), f.body, "utf-8");
}
});
test("rodar duas vezes não reaplica as migrations do diretório extra", async () => {
const eeDir = mkTempDir("mig-idem-");
writeMigrations(eeDir, { "001_ee_idem.sql": "CREATE TABLE ee_idem (id INTEGER);" });
const prev = process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS;
process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS = `ee=${eeDir}`;
const db = new Database(":memory:");
try {
const first = runMigrations(db as never, { isNewDb: true });
const second = runMigrations(db as never);
assert.equal(first, 3, "primeira execução aplica core 001/002 + ee-001");
assert.equal(second, 0, "segunda execução não tem nada pendente");
} finally {
db.close();
if (prev === undefined) delete process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS;
else process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS = prev;
}
});