Something went wrong. Try again.
A self-hosted household ledger.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127import { DatabaseSync } from "node:sqlite";import { mkdirSync, readdirSync, readFileSync } from "node:fs";import { dirname, join } from "node:path";import process from "node:process";import { type EmbeddedMigration, MIGRATIONS } from "./migrations.generated.ts";
const MIGRATION_FILE = /^(\d+)_.*\.sql$/;
let instance: DatabaseSync | null = null;
/** * Process-wide database handle, initialized once at server startup. Omit * `migrationsDir` to use the migrations embedded in the bundle (production and * the desktop binary, which have no `migrations/` directory on disk). */export function initDb(dbPath: string, migrationsDir?: string): DatabaseSync { instance ??= openDatabase(dbPath, migrationsDir); return instance;}
export function getDb(): DatabaseSync { if (!instance) { throw new Error( "Database not initialized — initDb() runs at server startup", ); } return instance;}
export function openDatabase( dbPath: string, migrationsDir?: string,): DatabaseSync { let db: DatabaseSync; try { if (dbPath !== ":memory:") mkdirSync(dirname(dbPath), { recursive: true }); db = new DatabaseSync(dbPath); } catch (err) { const reason = err instanceof Error ? err.message : String(err); throw new Error( `Cannot open database at ${dbPath}: ${reason}\n` + `The server runs as uid ${ process.getuid?.() ?? "?" } — the directory must be ` + `writable by it. In a container, bind mounts keep host ownership: with rootless ` + `Podman use "-v /host/path:/data:U" (chowns to the container user) or ` + `"podman unshare chown -R 1000:1000 /host/path"; a named volume ` + `(-v quantum-data:/data) inherits correct ownership from the image automatically.`, ); } db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); runMigrations(db, migrationsDir); return db;}
/** * Apply pending migrations. With `migrationsDir`, read `.sql` files from disk * (tests stage subsets this way); without it, use the migrations embedded in * the bundle (production and the desktop binary). */export function runMigrations( db: DatabaseSync, migrationsDir?: string,): number { const migrations = migrationsDir ? loadMigrationsFromDir(migrationsDir) : MIGRATIONS;
db.exec(`CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL )`);
const applied = new Set( (db.prepare("SELECT version FROM schema_version").all() as { version: number; }[]).map( (r) => r.version, ), );
let count = 0; for (const { version, name, sql } of migrations) { if (applied.has(version)) continue; db.exec("BEGIN"); try { db.exec(sql); db.prepare( "INSERT INTO schema_version (version, name, applied_at) VALUES (?, ?, ?)", ).run( version, name, new Date().toISOString(), ); db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); throw new Error( `Migration ${name} failed: ${err instanceof Error ? err.message : err}`, ); } count++; } return count;}
/** Read `.sql` migrations from a directory, sorted by version. */export function loadMigrationsFromDir( migrationsDir: string,): EmbeddedMigration[] { return readdirSync(migrationsDir) .map((file) => { const match = file.match(MIGRATION_FILE); return match ? { version: Number(match[1]), name: file, sql: readFileSync(join(migrationsDir, file), "utf-8"), } : null; }) .filter((m) => m !== null) .sort((a, b) => a.version - b.version);}