120 lines
4.8 KiB
JavaScript
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;
|
|
}
|