import fs from "node:fs"; import path from "node:path"; import Database from "better-sqlite3"; import { ulid } from "ulid"; import { config } from "./config.js"; export type Db = Database.Database; /** * Open (or create) the SQLite database and bring it up to the latest * migration. Migrations are plain `.sql` files in `src/migrations/`, applied * in filename order and recorded in `schema_migrations` so each runs once. */ export function openDb(dbPath: string = config.dbPath): Db { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const db = new Database(dbPath); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); migrate(db); return db; } function migrate(db: Db): void { db.exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( name TEXT PRIMARY KEY, applied_at TEXT NOT NULL ) `); const applied = new Set( (db.prepare("SELECT name FROM schema_migrations").all() as Array<{ name: string }>).map((r) => r.name), ); const dir = path.resolve(import.meta.dirname, "migrations"); const files = fs .readdirSync(dir) .filter((f) => f.endsWith(".sql")) .sort(); for (const file of files) { if (applied.has(file)) continue; const sql = fs.readFileSync(path.join(dir, file), "utf8"); const apply = db.transaction(() => { db.exec(sql); db.prepare("INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)").run( file, new Date().toISOString(), ); }); apply(); } } /** Bootstrap the first admin from env on first boot (idempotent). */ export function ensureAdmin(db: Db, hashPassword: (pw: string) => Promise): Promise { const email = process.env.PUBSCRIPT_ADMIN_EMAIL; const password = process.env.PUBSCRIPT_ADMIN_PASSWORD; if (!email || !password) return Promise.resolve(); const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email) as { id: string } | undefined; if (existing) return Promise.resolve(); return hashPassword(password).then((passHash) => { const id = ulid(); const now = new Date().toISOString(); db.prepare( "INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)", ).run(id, email, passHash, email.split("@")[0] ?? "admin", now); db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.bootstrap', ?)").run( ulid(), now, id, JSON.stringify({ email }), ); }); }