feat(db): let the migration runner scan extra namespaced directories (#8770)

The runner reads exactly one directory and records the bare numeric prefix as the
version, so the numeric slots are a single global namespace. Any distribution that
ships its own migrations next to the upstream set has to draw from that same range
while upstream keeps appending to it — and when both sides claim a number, the
runner records one name for it and treats the other as already applied. That
migration then never runs, silently, on every already-provisioned database.

OMNIROUTE_EXTRA_MIGRATIONS_DIRS registers additional directories as
`namespace=dir` entries separated by the platform path delimiter. Files found
there are recorded as `<namespace>-<number>` (e.g. `ee-134`), a version space that
cannot collide with the upstream numeric one, and they are applied after the core
set. Unset — the default, and the only case for a plain install — nothing changes:
no filesystem access, identical behaviour.

Misconfiguration throws instead of being skipped. A malformed entry, a namespace
outside [a-z][a-z0-9]*, a duplicate namespace, or a directory that does not exist
aborts startup, because silently missing schema is the exact failure this exists to
prevent. Two files sharing a number inside the SAME namespace still collide and
throw, mirroring the runner's own guard.

Also fixes an inconsistency the tests surfaced: a missing core directory returned
early and took the extra directories down with it. They are an independent set.

The version-namespaced strings need no further plumbing — the applied set, the gap
reconciliation and the name-mismatch check all key on the version string, and the
numeric-only paths (`Number.parseInt`, the "032"/"041"/"042" special cases,
`isSchemaAlreadyApplied`) ignore them by construction.

11 new tests; the 7 neighbouring migration suites stay green (62 tests).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-27 12:56:06 -03:00
committed by GitHub
parent b46bb6d6f1
commit 5f365bae7c
5 changed files with 501 additions and 2 deletions

View File

@@ -96,6 +96,7 @@ OmniRoute 使用 **SQLite**(通过 `better-sqlite3`)进行所有持久化存
| `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | 设为 `1` 可强制开启数据库健康检查循环,即使正常会被跳过(如短生命周期任务)。 |
| `OMNIROUTE_SKIP_POSTINSTALL` | `0` | `scripts/postinstall.mjs` | 设为 `1` 可在 `npm install` 期间跳过原生运行时预热。适用于 CI/无头安装,此时 sqlite 已构建好。 |
| `OMNIROUTE_MIGRATIONS_DIR` | _(自动检测)_ | `src/lib/db/migrationRunner.ts` | 覆盖迁移运行器扫描的目录。在自定义构建中打包迁移文件时很有用。 |
| `OMNIROUTE_EXTRA_MIGRATIONS_DIRS` | _(未设置)_ | `src/lib/db/migrationRunner/extraDirs.ts` | 以 `namespace=dir` 形式追加的迁移目录,条目之间用平台路径分隔符分隔(例如 `ee=/opt/app/enterprise/db/migrations`)。其中的文件会以 `<namespace>-<number>` 记录版本,因此自带迁移的发行版永远不会与上游的数字槽位冲突。条目格式错误、命名空间非法或目录缺失会在启动时抛错,而不是静默跳过该 schema。 |
| `OMNIROUTE_MAX_PENDING_MIGRATIONS` | `50` | `src/lib/db/migrationRunner.ts` | 大量待处理迁移的安全阈值(#3416)。如果现有数据库上有超过此数量的待处理迁移,启动将中止(防止跟踪表被清空)。恢复旧备份时提高此值;设为 `0` 可禁用检查。 |
| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 批量消费/成本写入器的刷新间隔(毫秒)。值越小写合并越少;值越大数据库争用越少。 |
| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 强制刷新前的最大缓存消费条目数。在高 QPS 部署中提高;在内存受限场景下降低。 |

View File

@@ -93,6 +93,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | Set to `1` to force the DB healthcheck loop on, even when it would normally be skipped (e.g., short-lived tasks). |
| `OMNIROUTE_SKIP_POSTINSTALL` | `0` | `scripts/postinstall.mjs` | Set to `1` to skip the native-runtime warm-up during `npm install`. Useful in CI/headless installs where sqlite is already built. |
| `OMNIROUTE_MIGRATIONS_DIR` | _(auto-detect)_ | `src/lib/db/migrationRunner.ts` | Override the directory that the migration runner scans. Useful when shipping bundled migrations in custom builds. |
| `OMNIROUTE_EXTRA_MIGRATIONS_DIRS` | _(unset)_ | `src/lib/db/migrationRunner/extraDirs.ts` | Additional migration directories as `namespace=dir` entries separated by the platform path delimiter (e.g. `ee=/opt/app/enterprise/db/migrations`). Files found there are recorded as `<namespace>-<number>`, so a distribution shipping its own migrations never collides with the upstream numeric slots. A malformed entry, an invalid namespace or a missing directory throws at startup instead of silently skipping the schema. |
| `OMNIROUTE_MAX_PENDING_MIGRATIONS` | `50` | `src/lib/db/migrationRunner.ts` | Mass-pending-migrations safety threshold (#3416). Startup aborts if more than this many migrations are pending on an existing DB (guards against a wiped tracking table). Raise it to restore an older backup; set to `0` to disable the check. |
| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Flush interval (ms) for the batched spend/cost writer. Lower values reduce write coalescing; higher values reduce DB contention. |
| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Max buffered spend entries before a forced flush. Raise on high-QPS deployments; lower when bounded memory matters more. |

View File

@@ -28,6 +28,7 @@ import {
INITIAL_SCHEMA_SENTINELS,
OPTIONAL_FTS5_MIGRATION_VERSIONS,
} from "./migrationRunner/constants";
import { getExtraMigrationFiles } from "./migrationRunner/extraDirs";
const isNodeTestRunnerChild = typeof process.env.NODE_TEST_CONTEXT === "string";
@@ -216,7 +217,9 @@ function isDeferredUnsupportedMigration(
* Get all migration files sorted by version number.
*/
function getMigrationFiles(): Array<{ version: string; name: string; path: string }> {
if (!fs.existsSync(MIGRATIONS_DIR)) return [];
// The extra directories are an independent set: a missing core directory must not
// make them vanish silently.
if (!fs.existsSync(MIGRATIONS_DIR)) return getExtraMigrationFiles();
const files = fs
.readdirSync(MIGRATIONS_DIR)
@@ -265,7 +268,13 @@ function getMigrationFiles(): Array<{ version: string; name: string; path: strin
);
}
return files;
// Extra directories registered via OMNIROUTE_EXTRA_MIGRATIONS_DIRS, appended
// AFTER the numeric set so a distribution's own schema always lands on top of
// the upstream one. Their versions are namespaced (`ee-134`), so they cannot
// collide with a numeric slot, and every downstream consumer here — the applied
// set, the gap reconciliation, the name-mismatch check — keys on the version
// string and needs no further change. Empty and filesystem-free when unset.
return [...files, ...getExtraMigrationFiles()];
}
function filterSupersededDuplicateMigrations(

View File

@@ -0,0 +1,181 @@
/**
* extraDirs.ts — additional migration directories with a namespaced version space.
*
* The runner's own directory (`MIGRATIONS_DIR`) owns the bare numeric version
* space: `NNN_name.sql` is recorded in `_omniroute_migrations` as `NNN`. That
* single namespace is fine while one party appends to it, and breaks as soon as a
* distribution ships its own migrations next to the upstream set — both sides draw
* from the same numbers, and when they pick the same one the runner records a
* single name for it and treats the other as already applied. The migration then
* never runs, silently, on every already-provisioned database.
*
* This module lets an operator register extra directories, each under its own
* namespace:
*
* OMNIROUTE_EXTRA_MIGRATIONS_DIRS="ee=/opt/app/enterprise/db/migrations"
*
* Entries are separated by `path.delimiter` (`:` on POSIX, `;` on Windows) and a
* file `NNN_name.sql` found there is recorded as `<namespace>-NNN`, so it can
* never collide with an upstream slot. Unset — the default, and always the case
* for a plain install — nothing changes.
*
* Misconfiguration throws instead of being skipped: a typo'd namespace or a moved
* directory would otherwise reproduce the exact silent-missing-schema failure this
* mechanism exists to prevent. A directory listed here is a declaration that its
* schema is required.
*/
import fs from "fs";
import path from "path";
/** Env var holding `namespace=dir` entries separated by `path.delimiter`. */
export const EXTRA_MIGRATIONS_DIRS_ENV = "OMNIROUTE_EXTRA_MIGRATIONS_DIRS";
/**
* Namespaces become a version prefix (`ee-134`), so they stay lowercase,
* alphanumeric and hyphen-free — the hyphen is the separator, and a numeric first
* character would make `1-001` ambiguous against a bare numeric version.
*/
const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*$/;
const MAX_NAMESPACE_LENGTH = 16;
/** Same shape the runner's own directory listing produces. */
const MIGRATION_FILE_PATTERN = /^(\d+)_(.+)\.sql$/;
export interface ExtraMigrationDir {
namespace: string;
dir: string;
}
export interface NamespacedMigrationFile {
/** `<namespace>-<number>`, e.g. `ee-134`. */
version: string;
name: string;
path: string;
}
/**
* Parse the env value into validated `{namespace, dir}` entries. Throws on any
* malformed entry, invalid namespace, duplicate namespace, or missing directory.
*/
export function parseExtraMigrationDirs(raw?: string | null): ExtraMigrationDir[] {
if (typeof raw !== "string" || raw.trim().length === 0) return [];
const seen = new Set<string>();
const out: ExtraMigrationDir[] = [];
for (const entry of raw.split(path.delimiter)) {
const spec = entry.trim();
if (spec.length === 0) continue;
const separator = spec.indexOf("=");
if (separator <= 0) {
throw new Error(
`[Migration] Invalid ${EXTRA_MIGRATIONS_DIRS_ENV} entry "${spec}": ` +
`expected "namespace=directory" (entries separated by "${path.delimiter}").`
);
}
const namespace = spec.slice(0, separator).trim();
const rawDir = spec.slice(separator + 1).trim();
if (!NAMESPACE_PATTERN.test(namespace) || namespace.length > MAX_NAMESPACE_LENGTH) {
throw new Error(
`[Migration] Invalid namespace "${namespace}" in ${EXTRA_MIGRATIONS_DIRS_ENV}: ` +
`must match ${NAMESPACE_PATTERN} and be at most ${MAX_NAMESPACE_LENGTH} characters ` +
`(it becomes the recorded version prefix, e.g. "${namespace || "ee"}-134").`
);
}
if (seen.has(namespace)) {
throw new Error(
`[Migration] Duplicate namespace "${namespace}" in ${EXTRA_MIGRATIONS_DIRS_ENV}: ` +
`each namespace maps to exactly one directory.`
);
}
if (rawDir.length === 0) {
throw new Error(
`[Migration] Empty directory for namespace "${namespace}" in ${EXTRA_MIGRATIONS_DIRS_ENV}.`
);
}
const dir = path.resolve(rawDir);
if (!fs.existsSync(dir)) {
throw new Error(
`[Migration] Directory for namespace "${namespace}" does not exist: ${dir}. ` +
`A directory listed in ${EXTRA_MIGRATIONS_DIRS_ENV} is required schema — ` +
`remove the entry if it is no longer shipped.`
);
}
if (!fs.statSync(dir).isDirectory()) {
throw new Error(
`[Migration] Path for namespace "${namespace}" is not a directory: ${dir}.`
);
}
seen.add(namespace);
out.push({ namespace, dir });
}
return out;
}
/**
* List the migrations of one namespaced directory, ordered by their numeric
* prefix. Files that do not match `NNN_name.sql` are ignored, exactly as in the
* runner's own directory.
*
* Two files sharing a numeric prefix inside the SAME namespace still collide —
* the namespace only separates this directory from upstream, not a directory from
* itself — so that throws, mirroring the runner's own collision guard.
*/
export function readNamespacedMigrationFiles(
entry: ExtraMigrationDir
): NamespacedMigrationFile[] {
const parsed = fs
.readdirSync(entry.dir)
.filter((f) => f.endsWith(".sql"))
.map((filename) => {
const match = filename.match(MIGRATION_FILE_PATTERN);
if (!match) return null;
return { number: match[1], name: match[2], filename };
})
.filter(Boolean) as Array<{ number: string; name: string; filename: string }>;
const byNumber = new Map<string, string[]>();
for (const f of parsed) {
if (!byNumber.has(f.number)) byNumber.set(f.number, []);
byNumber.get(f.number)!.push(f.name);
}
const collisions = [...byNumber.entries()].filter(([, names]) => names.length > 1);
if (collisions.length > 0) {
const summary = collisions
.map(([number, names]) => `${entry.namespace}-${number} → [${names.join(", ")}]`)
.join("; ");
throw new Error(
`[Migration] Migration version collision detected in ${entry.dir}: ${summary}. ` +
`Each migration file must have a unique numeric prefix within its namespace.`
);
}
return parsed
.sort((a, b) => Number.parseInt(a.number, 10) - Number.parseInt(b.number, 10))
.map((f) => ({
version: `${entry.namespace}-${f.number}`,
name: f.name,
path: path.join(entry.dir, f.filename),
}));
}
/**
* All migrations from every configured extra directory, namespace by namespace in
* the order they were declared. Empty (and free of any filesystem access) when the
* env var is unset.
*
* Read at call time, not at module load, so a process that configures the env
* before opening the database — and the tests — see the current value.
*/
export function getExtraMigrationFiles(): NamespacedMigrationFile[] {
const entries = parseExtraMigrationDirs(process.env[EXTRA_MIGRATIONS_DIRS_ENV]);
if (entries.length === 0) return [];
return entries.flatMap((entry) => readNamespacedMigrationFiles(entry));
}

View File

@@ -0,0 +1,307 @@
/**
* 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 a generic 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.
*/
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 { pathToFileURL } from "node:url";
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");
}
}
async function importFreshRunner() {
const modulePath = path.resolve("src/lib/db/migrationRunner.ts");
const url = pathToFileURL(modulePath).href;
return import(`${url}?extradirs=${Date.now()}-${Math.random().toString(16).slice(2)}`);
}
interface RunResult {
count: number;
rows: Array<{ version: string; name: string }>;
tables: string[];
}
/**
* Point the runner at a temp core dir plus the given extra dirs, run it against a
* fresh in-memory DB and report what got applied.
*/
async function runWithDirs(
coreFiles: Record<string, string>,
extraSpec: string | null
): Promise<RunResult> {
const coreDir = mkTempDir("omniroute-core-mig-");
writeMigrations(coreDir, coreFiles);
const prevCore = process.env.OMNIROUTE_MIGRATIONS_DIR;
const prevExtra = process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS;
const prevBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
process.env.OMNIROUTE_MIGRATIONS_DIR = coreDir;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
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 { runMigrations } = await importFreshRunner();
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 (prevCore === undefined) delete process.env.OMNIROUTE_MIGRATIONS_DIR;
else process.env.OMNIROUTE_MIGRATIONS_DIR = prevCore;
if (prevExtra === undefined) delete process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS;
else process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS = prevExtra;
if (prevBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
else process.env.DISABLE_SQLITE_AUTO_BACKUP = prevBackup;
}
}
// 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 */
}
}
});
const CORE = {
"001_initial_schema.sql": "CREATE TABLE core_one (id INTEGER);",
"002_core_two.sql": "CREATE TABLE core_two (id INTEGER);",
};
await test("sem a env, o runner só enxerga o diretório core (comportamento atual)", async () => {
const r = await runWithDirs(CORE, null);
assert.deepEqual(
r.rows.map((x) => x.version),
["001", "002"]
);
assert.ok(r.tables.includes("core_one") && r.tables.includes("core_two"));
});
await 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 = await runWithDirs(CORE, `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");
});
await test("o mesmo número em core e em diretório extra NÃO colide — ambas rodam", async () => {
const eeDir = mkTempDir("omniroute-ee-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 = await runWithDirs(CORE, `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"]
);
});
await test("dois namespaces extras coexistem, cada um no seu espaço de versão", async () => {
const a = mkTempDir("omniroute-nsa-");
const b = mkTempDir("omniroute-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 = await runWithDirs(CORE, `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"]
);
});
await test("número duplicado DENTRO de um namespace extra é erro (não pode ser pulado em silêncio)", async () => {
const eeDir = mkTempDir("omniroute-ee-dup-");
writeMigrations(eeDir, {
"003_first.sql": "CREATE TABLE ee_first (id INTEGER);",
"003_second.sql": "CREATE TABLE ee_second (id INTEGER);",
});
await assert.rejects(
() => runWithDirs(CORE, `ee=${eeDir}`),
/collision/i,
"duas migrations com o mesmo número no mesmo namespace têm que estourar"
);
});
await test("spec malformada estoura em vez de ignorar o diretório", async () => {
const eeDir = mkTempDir("omniroute-ee-malformed-");
writeMigrations(eeDir, { "001_x.sql": "CREATE TABLE x (id INTEGER);" });
await assert.rejects(
() => runWithDirs(CORE, eeDir), // sem "namespace="
/OMNIROUTE_EXTRA_MIGRATIONS_DIRS/,
"entrada sem namespace= é configuração inválida, não um diretório a ignorar"
);
});
await test("namespace inválido estoura (só minúsculas/dígitos, começando por letra)", async () => {
const eeDir = mkTempDir("omniroute-ee-badns-");
writeMigrations(eeDir, { "001_x.sql": "CREATE TABLE x (id INTEGER);" });
await assert.rejects(
() => runWithDirs(CORE, `EE Corp=${eeDir}`),
/namespace/i,
"namespace fora de [a-z][a-z0-9]* tem que estourar"
);
});
await test("diretório configurado que não existe estoura (schema faltando em silêncio é o bug)", async () => {
const missing = path.join(os.tmpdir(), `omniroute-nao-existe-${Date.now()}`);
await assert.rejects(
() => runWithDirs(CORE, `ee=${missing}`),
/does not exist|não existe|not exist/i,
"um diretório explicitamente configurado e ausente é erro de configuração"
);
});
await test("arquivos que não casam NNN_nome.sql são ignorados, como no diretório core", async () => {
const eeDir = mkTempDir("omniroute-ee-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 = await runWithDirs(CORE, `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"]
);
});
await 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.
const eeDir = mkTempDir("mig-extra-only-");
writeMigrations(eeDir, { "001_ee_solo.sql": "CREATE TABLE ee_solo (id INTEGER);" });
const missingCore = path.join(os.tmpdir(), `mig-core-ausente-${process.pid}-${Date.now()}`);
const prevCore = process.env.OMNIROUTE_MIGRATIONS_DIR;
const prevExtra = process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS;
const prevBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
process.env.OMNIROUTE_MIGRATIONS_DIR = missingCore;
process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS = `ee=${eeDir}`;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const db = new Database(":memory:");
try {
const { runMigrations } = await importFreshRunner();
runMigrations(db as never, { isNewDb: true });
const tables = (
db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{
name: string;
}>
).map((r) => r.name);
assert.ok(tables.includes("ee_solo"), `tabelas: ${tables.join(", ")}`);
} finally {
db.close();
if (prevCore === undefined) delete process.env.OMNIROUTE_MIGRATIONS_DIR;
else process.env.OMNIROUTE_MIGRATIONS_DIR = prevCore;
if (prevExtra === undefined) delete process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS;
else process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS = prevExtra;
if (prevBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
else process.env.DISABLE_SQLITE_AUTO_BACKUP = prevBackup;
}
});
await test("rodar duas vezes não reaplica as migrations do diretório extra", async () => {
const eeDir = mkTempDir("omniroute-ee-idem-");
writeMigrations(eeDir, { "001_ee_idem.sql": "CREATE TABLE ee_idem (id INTEGER);" });
const coreDir = mkTempDir("omniroute-core-idem-");
writeMigrations(coreDir, CORE);
const prevCore = process.env.OMNIROUTE_MIGRATIONS_DIR;
const prevExtra = process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS;
const prevBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
process.env.OMNIROUTE_MIGRATIONS_DIR = coreDir;
process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS = `ee=${eeDir}`;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const db = new Database(":memory:");
try {
const { runMigrations } = await importFreshRunner();
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 (prevCore === undefined) delete process.env.OMNIROUTE_MIGRATIONS_DIR;
else process.env.OMNIROUTE_MIGRATIONS_DIR = prevCore;
if (prevExtra === undefined) delete process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS;
else process.env.OMNIROUTE_EXTRA_MIGRATIONS_DIRS = prevExtra;
if (prevBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
else process.env.DISABLE_SQLITE_AUTO_BACKUP = prevBackup;
}
});