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

@@ -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));
}