mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-29 03:12:10 +03:00
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:
committed by
GitHub
parent
b46bb6d6f1
commit
5f365bae7c
@@ -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 部署中提高;在内存受限场景下降低。 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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(
|
||||
|
||||
181
src/lib/db/migrationRunner/extraDirs.ts
Normal file
181
src/lib/db/migrationRunner/extraDirs.ts
Normal 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));
|
||||
}
|
||||
307
tests/unit/db-migration-runner-extra-dirs.test.ts
Normal file
307
tests/unit/db-migration-runner-extra-dirs.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user