NGU-Web/server/src/db.js

120 lines
4.8 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
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;
}