/* ═══════════════════════════════════════════════════════════════ DATABASE One SQLite file, opened once at boot and held for the life of the process. Node's own sqlite module is used when it's there (Node 24+), better-sqlite3 otherwise. Their APIs overlap enough that everything below works against either, as long as you: • use positional ? parameters, never named ones • pass 0/1 for booleans, never true/false • use tx() rather than db.transaction() Those three rules are the whole compatibility story. ═══════════════════════════════════════════════════════════════ */ import { readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); const MIGRATIONS_DIR = join(HERE, "migrations"); /* ── Driver selection ──────────────────────────────────────── */ async function loadDriver() { try { const { DatabaseSync } = await import("node:sqlite"); return { Driver: DatabaseSync, name: "node:sqlite" }; } catch { const { default: BetterSqlite3 } = await import("better-sqlite3"); return { Driver: BetterSqlite3, name: "better-sqlite3" }; } } /* ── Open ────────────────────────────────────────────────────── WAL readers never block the writer, which matters the moment a feedback POST lands mid page-load NORMAL fsync on checkpoint rather than every commit; safe under WAL, and much faster busy wait rather than throw if something else holds the write lock (a backup, usually) ───────────────────────────────────────────────────────────── */ export async function openDatabase(path) { const { Driver, name } = await loadDriver(); const db = new Driver(path); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); db.exec("PRAGMA synchronous = NORMAL"); db.exec("PRAGMA busy_timeout = 5000"); db.driverName = name; return db; } /* ── Transactions ────────────────────────────────────────────── node:sqlite has no db.transaction(), so do it by hand. Runs fn() and commits, or rolls back and rethrows. ───────────────────────────────────────────────────────────── */ export function tx(db, fn) { db.exec("BEGIN"); try { const result = fn(); db.exec("COMMIT"); return result; } catch (err) { try { db.exec("ROLLBACK"); } catch { /* already rolled back */ } throw err; } } /* ── Migrations ──────────────────────────────────────────────── Files are NNN_name.sql. The leading number is the version. PRAGMA user_version tracks how far we've got, so there's no bookkeeping table and no ordering ambiguity. Migrations only ever go forward. To undo something, write a new migration. ───────────────────────────────────────────────────────────── */ export function migrate(db, { log = console.log } = {}) { const current = db.prepare("PRAGMA user_version").get().user_version; const files = readdirSync(MIGRATIONS_DIR) .filter((f) => f.endsWith(".sql")) .sort(); let applied = 0; for (const file of files) { const version = Number.parseInt(file.slice(0, 3), 10); if (!Number.isInteger(version) || version < 1) { throw new Error(`Migration "${file}" must start with a number, e.g. 001_`); } if (version <= current) continue; const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8"); tx(db, () => { db.exec(sql); // Not parameterisable, but version is a validated integer. db.exec(`PRAGMA user_version = ${version}`); }); log(`migrated → ${file}`); applied += 1; } const final = db.prepare("PRAGMA user_version").get().user_version; if (applied === 0) log(`schema up to date (v${final})`); return final; }