import Database from "better-sqlite3"; import fs from "node:fs"; import path from "node:path"; /** * Single shared SQLite connection. * * WAL journaling is enabled (the SQLite-native equivalent of "wal-g"): it improves * concurrency and is the prerequisite for streaming backups via Litestream. The DB * path is configurable (DATABASE_PATH) so Litestream can be pointed at it without * any code change. */ declare global { // eslint-disable-next-line no-var var __drawitDb: Database.Database | undefined; } function resolveDbPath(): string { const p = process.env.DATABASE_PATH || "./data/drawit.db"; return path.isAbsolute(p) ? p : path.join(process.cwd(), p); } function createConnection(): Database.Database { const dbPath = resolveDbPath(); fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const db = new Database(dbPath); // WAL mode: better concurrency + required for Litestream streaming backups. db.pragma("journal_mode = WAL"); db.pragma("synchronous = NORMAL"); db.pragma("foreign_keys = ON"); db.pragma("busy_timeout = 5000"); migrate(db); return db; } function migrate(db: Database.Database) { db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, password_salt TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'learner', -- 'admin' | 'learner' | ... level TEXT NOT NULL DEFAULT 'early-beginner', status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'active' | 'suspended' email_verified INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, -- 'confirm' | 'reset' expires_at TEXT NOT NULL, used_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, level TEXT NOT NULL, sublevel INTEGER NOT NULL, step INTEGER NOT NULL, completed INTEGER NOT NULL DEFAULT 0, completed_at TEXT, UNIQUE(user_id, level, sublevel, step) ); CREATE TABLE IF NOT EXISTS badges ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, badge_key TEXT NOT NULL, awarded_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, badge_key) ); CREATE TABLE IF NOT EXISTS reports ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, level TEXT, sublevel TEXT, step TEXT, message TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', -- 'open' | 'resolved' created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_tokens_token ON tokens(token); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_progress_user ON progress(user_id); CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status); `); } export function getDb(): Database.Database { if (!global.__drawitDb) { global.__drawitDb = createConnection(); } return global.__drawitDb; }