From 5f365bae7c04a8524986fdd32d677e5d503e9dfd Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 27 Jul 2026 12:56:06 -0300 Subject: [PATCH] feat(db): let the migration runner scan extra namespaced directories (#8770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `-` (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). --- docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md | 1 + docs/reference/ENVIRONMENT.md | 1 + src/lib/db/migrationRunner.ts | 13 +- src/lib/db/migrationRunner/extraDirs.ts | 181 +++++++++++ .../db-migration-runner-extra-dirs.test.ts | 307 ++++++++++++++++++ 5 files changed, 501 insertions(+), 2 deletions(-) create mode 100644 src/lib/db/migrationRunner/extraDirs.ts create mode 100644 tests/unit/db-migration-runner-extra-dirs.test.ts diff --git a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md index 75923197cd..de80b7718d 100644 --- a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md @@ -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`)。其中的文件会以 `-` 记录版本,因此自带迁移的发行版永远不会与上游的数字槽位冲突。条目格式错误、命名空间非法或目录缺失会在启动时抛错,而不是静默跳过该 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 部署中提高;在内存受限场景下降低。 | diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index e07d15a212..625a16209f 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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 `-`, 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. | diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 4d0efa8dd9..ac6f0ab79d 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -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( diff --git a/src/lib/db/migrationRunner/extraDirs.ts b/src/lib/db/migrationRunner/extraDirs.ts new file mode 100644 index 0000000000..1e8d23675f --- /dev/null +++ b/src/lib/db/migrationRunner/extraDirs.ts @@ -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 `-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 { + /** `-`, 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(); + 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(); + 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)); +} diff --git a/tests/unit/db-migration-runner-extra-dirs.test.ts b/tests/unit/db-migration-runner-extra-dirs.test.ts new file mode 100644 index 0000000000..904662300a --- /dev/null +++ b/tests/unit/db-migration-runner-extra-dirs.test.ts @@ -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 `-` 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): 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, + extraSpec: string | null +): Promise { + 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; + } +});