/** * Versioned schema migrations shared by the MSSQL and SQLite providers. * * Layout (per provider): * src/providers//upgrade/_to_.js (to = from + 1) * src/providers//downgrade/_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 _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} getVersion * @param {(version: number) => Promise|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) => Promise} options.runStep * Provider hook: runs `fn(ctx)` in a transaction and verifies the end version. * @param {(message: string) => void} [options.log] * @returns {Promise} 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}`); } }