Files
dashlibstorage/src/providers/schemaMigrations.js
2026-10-29 03:17:47 +01:00

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