mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 07:42:13 +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
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user