/** * SQLite schema versioning (see ../schemaMigrations.js). * * Step context: `{ db, exec, getVersion, assertVersion, setVersion }` where * `db` is the raw better-sqlite3 Database and `exec(sql)` runs DDL/DML. * Each step runs in `BEGIN IMMEDIATE … COMMIT` with foreign key enforcement * switched off (SQLite's recommended procedure for table rebuilds); * `PRAGMA foreign_key_check` must pass before commit. */ import { BASE_SCHEMA_VERSION, assertStepResult, latestSchemaVersion, makeVersionContext, migrateSchema, parseSchemaVersion, } from '../schemaMigrations.js'; export const UPGRADE_DIR = new URL('./upgrade/', import.meta.url); export const DOWNGRADE_DIR = new URL('./downgrade/', import.meta.url); /** Schema version a fresh database is created at. */ export const SCHEMA_VERSION = latestSchemaVersion(UPGRADE_DIR); function hasTable(db, name) { return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name); } function hasAnyTable(db) { return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get(); } /** * Reads `meta.version`, or `null` when the meta table/row is missing. * @param {import('better-sqlite3').Database} db * @returns {number|null} */ export function readSchemaVersion(db) { if (!hasTable(db, 'meta')) return null; const row = db.prepare(`SELECT value FROM meta WHERE "key" = 'version'`).get(); return row ? parseSchemaVersion(row.value) : null; } function writeSchemaVersion(db, version) { db.prepare(`INSERT INTO meta ("key", value) VALUES ('version', ?) ON CONFLICT("key") DO UPDATE SET value = excluded.value`).run(String(version)); } function makeRunStep(db) { return async (step, fn) => { const fkWasOn = db.pragma('foreign_keys', { simple: true }) === 1; if (fkWasOn) db.pragma('foreign_keys = OFF'); db.exec('BEGIN IMMEDIATE'); try { const ctx = { db, exec: (sql) => db.exec(sql), ...makeVersionContext(step.file, () => readSchemaVersion(db), (v) => writeSchemaVersion(db, v)), }; await fn(ctx); assertStepResult(step, readSchemaVersion(db)); const violations = db.pragma('foreign_key_check'); if (violations.length > 0) { throw new Error(`Schema step ${step.file} left ${violations.length} foreign key violation(s)`); } db.exec('COMMIT'); } catch (err) { if (db.inTransaction) db.exec('ROLLBACK'); throw err; } finally { if (fkWasOn) db.pragma('foreign_keys = ON'); } }; } /** * Upgrades an existing database to `SCHEMA_VERSION`. Must run BEFORE the * `schema.js` scripts so they never meet an outdated table shape. * Fresh (empty) databases are skipped — `INIT_META` stamps them. * A non-empty database without a version row is treated as version 1. * @param {import('better-sqlite3').Database} db * @param {(message: string) => void} [log] */ export async function upgradeSchema(db, log) { if (!hasAnyTable(db)) return 0; let current = readSchemaVersion(db); if (current === null) { db.exec(`CREATE TABLE IF NOT EXISTS meta ("key" TEXT PRIMARY KEY, value TEXT NOT NULL)`); writeSchemaVersion(db, BASE_SCHEMA_VERSION); current = BASE_SCHEMA_VERSION; } return migrateSchema({ direction: 'upgrade', dirUrl: UPGRADE_DIR, current, target: SCHEMA_VERSION, runStep: makeRunStep(db), log, }); } /** * Downgrades the database to `target` (CLI only, never at server start). * @param {import('better-sqlite3').Database} db * @param {number} target * @param {(message: string) => void} [log] */ export async function downgradeSchema(db, target, log) { const current = readSchemaVersion(db); if (current === null) throw new Error('Database has no schema version (meta table missing)'); return migrateSchema({ direction: 'downgrade', dirUrl: DOWNGRADE_DIR, current, target, runStep: makeRunStep(db), log, }); }