185 lines
6.7 KiB
JavaScript
185 lines
6.7 KiB
JavaScript
/**
|
|
* Versioned schema migrations shared by the MSSQL and SQLite providers.
|
|
*
|
|
* Layout (per provider):
|
|
* src/providers/<provider>/upgrade/<from>_to_<to>.js (to = from + 1)
|
|
* src/providers/<provider>/downgrade/<from>_to_<to>.js (to = from - 1)
|
|
*
|
|
* Each step file default-exports `async function (ctx)`. `ctx` always has:
|
|
* - `getVersion()` → current `meta.version` (number)
|
|
* - `assertVersion(expected)` → throws unless `meta.version === expected`
|
|
* - `setVersion(version)` → writes `meta.version`
|
|
* plus provider-specific handles (`db`/`exec` for SQLite, `query` for MSSQL).
|
|
*
|
|
* A step must assert its start version and finish by setting its end version;
|
|
* the runner verifies the end version and rolls back on mismatch. Each step
|
|
* runs in its own transaction.
|
|
*
|
|
* Fresh databases are created from `schema.js` (always the latest shape) and
|
|
* stamped with `SCHEMA_VERSION` (= highest upgrade target) by `INIT_META`.
|
|
* Existing databases are upgraded step by step at provider `initialize()`.
|
|
*
|
|
* Adding a version N → N+1:
|
|
* 1. Add `upgrade/N_to_N+1.js` and `downgrade/N+1_to_N.js` for BOTH providers.
|
|
* 2. Update both `schema.js` files so a fresh DB has the new shape.
|
|
* `SCHEMA_VERSION` is derived from the upgrade folder — nothing else to bump.
|
|
*/
|
|
|
|
import { readdirSync } from 'node:fs';
|
|
|
|
/** Version of a database that has never run a versioned upgrade. */
|
|
export const BASE_SCHEMA_VERSION = 1;
|
|
|
|
const STEP_FILE = /^(\d+)_to_(\d+)\.js$/;
|
|
|
|
/**
|
|
* Lists and validates the step files of one direction.
|
|
* @param {URL} dirUrl - Directory URL (trailing slash).
|
|
* @param {'upgrade'|'downgrade'} direction
|
|
* @returns {Array<{ from: number, to: number, file: string, url: URL }>} Sorted by `from` (asc for upgrade, desc for downgrade).
|
|
*/
|
|
export function listSteps(dirUrl, direction) {
|
|
let names;
|
|
try {
|
|
names = readdirSync(dirUrl);
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') return [];
|
|
throw err;
|
|
}
|
|
|
|
const delta = direction === 'upgrade' ? 1 : -1;
|
|
const steps = [];
|
|
const seen = new Set();
|
|
for (const file of names) {
|
|
if (!file.endsWith('.js')) continue;
|
|
const match = STEP_FILE.exec(file);
|
|
if (!match) {
|
|
throw new Error(`Invalid schema ${direction} file name: ${file} (expected <from>_to_<to>.js)`);
|
|
}
|
|
const from = Number(match[1]);
|
|
const to = Number(match[2]);
|
|
if (to !== from + delta) {
|
|
throw new Error(`Invalid schema ${direction} step ${file}: must go from N to N${delta > 0 ? '+' : '-'}1`);
|
|
}
|
|
if (seen.has(from)) {
|
|
throw new Error(`Duplicate schema ${direction} step starting at version ${from}`);
|
|
}
|
|
seen.add(from);
|
|
steps.push({ from, to, file, url: new URL(file, dirUrl) });
|
|
}
|
|
return steps.sort((a, b) => (a.from - b.from) * delta);
|
|
}
|
|
|
|
/**
|
|
* Highest schema version reachable through a gap-free upgrade chain starting
|
|
* at `BASE_SCHEMA_VERSION`.
|
|
* @param {URL} upgradeDirUrl
|
|
* @returns {number}
|
|
*/
|
|
export function latestSchemaVersion(upgradeDirUrl) {
|
|
let version = BASE_SCHEMA_VERSION;
|
|
for (const step of listSteps(upgradeDirUrl, 'upgrade')) {
|
|
if (step.from !== version) {
|
|
throw new Error(`Schema upgrade chain broken: expected ${version}_to_${version + 1}.js, found ${step.file}`);
|
|
}
|
|
version = step.to;
|
|
}
|
|
return version;
|
|
}
|
|
|
|
/**
|
|
* Parses a `meta.version` value.
|
|
* @param {unknown} value
|
|
* @returns {number}
|
|
*/
|
|
export function parseSchemaVersion(value) {
|
|
const version = Number(value);
|
|
if (!Number.isInteger(version) || version < BASE_SCHEMA_VERSION) {
|
|
throw new Error(`Invalid schema version in meta table: ${value}`);
|
|
}
|
|
return version;
|
|
}
|
|
|
|
/**
|
|
* Builds the version helpers every step receives.
|
|
* @param {string} file - Step file name (for error messages).
|
|
* @param {() => Promise<number>|number} getVersion
|
|
* @param {(version: number) => Promise<void>|void} setVersion
|
|
*/
|
|
export function makeVersionContext(file, getVersion, setVersion) {
|
|
return {
|
|
getVersion: async () => getVersion(),
|
|
assertVersion: async (expected) => {
|
|
const actual = await getVersion();
|
|
if (actual !== expected) {
|
|
throw new Error(`Schema step ${file}: expected database version ${expected}, found ${actual}`);
|
|
}
|
|
},
|
|
setVersion: async (version) => {
|
|
if (!Number.isInteger(version) || version < BASE_SCHEMA_VERSION) {
|
|
throw new Error(`Schema step ${file}: invalid version ${version}`);
|
|
}
|
|
await setVersion(version);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Runs the steps that move the database from `current` to `target`.
|
|
*
|
|
* @param {object} options
|
|
* @param {'upgrade'|'downgrade'} options.direction
|
|
* @param {URL} options.dirUrl - Step directory for this direction.
|
|
* @param {number} options.current - Current database version.
|
|
* @param {number} options.target - Target version.
|
|
* @param {(step: { from: number, to: number, file: string }, fn: (ctx: object) => Promise<void>) => Promise<void>} options.runStep
|
|
* Provider hook: runs `fn(ctx)` in a transaction and verifies the end version.
|
|
* @param {(message: string) => void} [options.log]
|
|
* @returns {Promise<number>} Number of steps applied.
|
|
*/
|
|
export async function migrateSchema({ direction, dirUrl, current, target, runStep, log = console.log }) {
|
|
if (direction === 'upgrade' && target < current) {
|
|
throw new Error(`Database schema version ${current} is newer than this application supports (${target})`);
|
|
}
|
|
if (direction === 'downgrade' && target > current) {
|
|
throw new Error(`Cannot downgrade schema from version ${current} to higher version ${target}`);
|
|
}
|
|
if (target < BASE_SCHEMA_VERSION) {
|
|
throw new Error(`Cannot ${direction} schema below version ${BASE_SCHEMA_VERSION}`);
|
|
}
|
|
|
|
const byFrom = new Map(listSteps(dirUrl, direction).map((step) => [step.from, step]));
|
|
const plan = [];
|
|
for (let version = current; version !== target; ) {
|
|
const step = byFrom.get(version);
|
|
if (!step) {
|
|
const next = direction === 'upgrade' ? version + 1 : version - 1;
|
|
throw new Error(`Missing schema ${direction} step ${version}_to_${next}.js`);
|
|
}
|
|
plan.push(step);
|
|
version = step.to;
|
|
}
|
|
|
|
for (const step of plan) {
|
|
const mod = await import(step.url.href);
|
|
if (typeof mod.default !== 'function') {
|
|
throw new Error(`Schema ${direction} step ${step.file} must default-export a function`);
|
|
}
|
|
await runStep(step, mod.default);
|
|
log(`schema ${direction} ${step.from} -> ${step.to} (${step.file})`);
|
|
}
|
|
return plan.length;
|
|
}
|
|
|
|
/**
|
|
* Throws unless the version a step left behind matches its target.
|
|
* @param {{ file: string, to: number }} step
|
|
* @param {number} actual
|
|
*/
|
|
export function assertStepResult(step, actual) {
|
|
if (actual !== step.to) {
|
|
throw new Error(`Schema step ${step.file} must end with version ${step.to}, found ${actual}`);
|
|
}
|
|
}
|
|
|