/** * Users + sessions in bun:sqlite (Decisions.md D-009). * * Single-file database; path from env `DB_PATH`, default ./data/app.db. All * access goes through the typed helpers on {@link AppDb} — no raw SQL leaks * out of this module, so the (deliberately tiny) schema can evolve in one * place. * * Testability: `createDb(path)` is a pure factory — tests pass ":memory:" and * never touch the on-disk default. The shared instance is created lazily by * `getDb()`, so merely importing this module has no filesystem side effects. */ import { Database } from "bun:sqlite"; import { randomBytes } from "node:crypto"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; // --------------------------------------------------------------------------- // Domain types (camelCase; the snake_case row shapes stay private below) // --------------------------------------------------------------------------- export interface User { id: number; username: string; /** Argon2id hash produced by `Bun.password.hash` — never the plaintext. */ passwordHash: string; /** Milliseconds since epoch. */ createdAt: number; } export interface Session { /** 256-bit random id (base64url); doubles as the `sid` cookie value. */ id: string; userId: number; /** Milliseconds since epoch; sessions past this instant are invalid. */ expiresAt: number; } export interface AppDb { /** Returns the new user, or null when the username is already taken. */ createUser(username: string, passwordHash: string): User | null; findUserByUsername(username: string): User | null; findUserById(id: number): User | null; /** Creates a session for `userId`; also sweeps expired rows opportunistically. */ createSession(userId: number, expiresAt: number): Session; /** * Returns the session only when it exists AND has not expired — expiry is * enforced here, server-side, regardless of what the cookie claims. */ findValidSession(id: string, now?: number): Session | null; deleteSession(id: string): void; /** Removes all sessions with expires_at <= now; returns the count removed. */ deleteExpiredSessions(now?: number): number; close(): void; } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- interface UserRow { id: number; username: string; password_hash: string; created_at: number; } interface SessionRow { id: string; user_id: number; expires_at: number; } const toUser = (row: UserRow): User => ({ id: row.id, username: row.username, passwordHash: row.password_hash, createdAt: row.created_at, }); const toSession = (row: SessionRow): Session => ({ id: row.id, userId: row.user_id, expiresAt: row.expires_at, }); /** * Session ids come from the CSPRNG: 32 random bytes = 256 bits, well past the * ~128-bit unguessability floor, encoded base64url so the value is safe in a * cookie without escaping. */ export function newSessionId(): string { return randomBytes(32).toString("base64url"); } /** Opens (creating if needed) a database at `path` and applies the schema. */ export function createDb(path: string): AppDb { if (path !== ":memory:") { // bun:sqlite creates the file but not its parent directory. mkdirSync(dirname(path), { recursive: true }); } const db = new Database(path, { create: true, strict: true }); // WAL lets reads proceed during writes; harmless no-op for ":memory:". db.exec("PRAGMA journal_mode = WAL;"); // Enforce sessions.user_id → users.id (off by default in SQLite). db.exec("PRAGMA foreign_keys = ON;"); db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); `); const insertUser = db.query( "INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?) RETURNING *", ); const userByUsername = db.query( "SELECT * FROM users WHERE username = ?", ); const userById = db.query("SELECT * FROM users WHERE id = ?"); const insertSession = db.query( "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?) RETURNING *", ); const sessionById = db.query( "SELECT * FROM sessions WHERE id = ? AND expires_at > ?", ); const removeSession = db.query("DELETE FROM sessions WHERE id = ?"); const removeExpired = db.query("DELETE FROM sessions WHERE expires_at <= ?"); return { createUser(username, passwordHash) { try { return toUser(insertUser.get(username, passwordHash, Date.now())!); } catch (error) { // The UNIQUE constraint is the source of truth for "username taken" — // checking first would race concurrent registrations. if ( error instanceof Error && (error as { code?: string }).code?.startsWith("SQLITE_CONSTRAINT") ) { return null; } throw error; } }, findUserByUsername(username) { const row = userByUsername.get(username); return row ? toUser(row) : null; }, findUserById(id) { const row = userById.get(id); return row ? toUser(row) : null; }, createSession(userId, expiresAt) { // Piggyback garbage collection on the write path so stale rows never // accumulate unboundedly without needing a background timer. removeExpired.run(Date.now()); return toSession(insertSession.get(newSessionId(), userId, expiresAt)!); }, findValidSession(id, now = Date.now()) { const row = sessionById.get(id, now); return row ? toSession(row) : null; }, deleteSession(id) { removeSession.run(id); }, deleteExpiredSessions(now = Date.now()) { return removeExpired.run(now).changes; }, close() { db.close(); }, }; } // --------------------------------------------------------------------------- // Lazily-created default instance (the real app database) // --------------------------------------------------------------------------- export const DEFAULT_DB_PATH = "./data/app.db"; let defaultDb: AppDb | undefined; /** The application database (env `DB_PATH`, default ./data/app.db — gitignored). */ export function getDb(): AppDb { defaultDb ??= createDb(process.env.DB_PATH ?? DEFAULT_DB_PATH); return defaultDb; }