import Database from 'better-sqlite3' import { MIGRATIONS } from './migrations' export type Db = Database.Database export interface OpenOptions { /** Skip WAL. In-memory databases do not support it. */ memory?: boolean } /** * Open the database, apply pragmas, and run any pending migrations. * * Called from the main process only. better-sqlite3 is synchronous, which is * exactly what we want here: the main process is the sole writer, the queries * are small, and synchronous access removes an entire class of interleaving * bugs that async drivers would introduce. */ export function openDatabase(path: string, options: OpenOptions = {}): Db { const db = new Database(path) if (!options.memory) { // WAL lets reads proceed during writes. With a single writer the practical // benefit is durability behaviour rather than concurrency, but it also // makes crashes much less likely to corrupt the file. db.pragma('journal_mode = WAL') } // Off by default in SQLite. Without this, ON DELETE CASCADE is silently // ignored and deleting a folder would orphan its hosts. db.pragma('foreign_keys = ON') db.pragma('synchronous = NORMAL') db.pragma('busy_timeout = 5000') migrate(db) return db } /** * Apply every migration newer than the database's recorded `user_version`. * * Each migration runs inside a transaction. SQLite makes DDL transactional, so * a migration that fails partway leaves the database exactly as it was rather * than half-migrated — which is the difference between a recoverable bug and a * corrupted install. */ export function migrate(db: Db): number { const current = db.pragma('user_version', { simple: true }) as number const pending = MIGRATIONS.filter((m) => m.version > current).sort( (a, b) => a.version - b.version, ) for (const migration of pending) { const rebuild = migration.rebuildsTables === true // Outside the transaction on purpose: SQLite silently ignores both of // these inside one. if (rebuild) { db.pragma('foreign_keys = OFF') db.pragma('legacy_alter_table = ON') } try { db.transaction(() => { db.exec(migration.sql) if (rebuild) { // A rebuild that leaves dangling references must not commit. Failing // here rolls the whole migration back, so a bad rebuild leaves the // previous schema intact rather than a half-converted database. const violations = db.pragma('foreign_key_check') as unknown[] if (violations.length > 0) { throw new Error( `migration ${migration.version} (${migration.name}) left ` + `${violations.length} foreign key violation(s)`, ) } } // Interpolated, not bound: PRAGMA does not accept bound parameters. The // value comes from our own migration table, never from user input. db.pragma(`user_version = ${migration.version}`) })() } finally { if (rebuild) { db.pragma('legacy_alter_table = OFF') db.pragma('foreign_keys = ON') } } } return pending.length } export function closeDatabase(db: Db): void { db.close() }