/** * @mandujs/core/filling/session-sqlite * * SQLite-backed `SessionStorage` — drop-in replacement for * {@link createCookieSessionStorage} when session payloads outgrow the * 4 KB cookie budget or when you need server-side invalidation. * * ## Contract * * Implements the same `SessionStorage` interface as * `createCookieSessionStorage`. The existing `session()` middleware and * the `saveSession` / `destroySession` helpers work unchanged — only the * construction call changes. * * ## Cookie shape * * Only a signed **session id** travels in the cookie. The actual session * `data` lives in SQLite keyed by id. Cookie value is * `encodeURIComponent() + "." + `, identical * in shape to `CookieManager.setSigned` output. * * ## Phase 4a Appendix D compliance * * - **D.4 WAL mode**: issued at init via `PRAGMA journal_mode = WAL` so * concurrent writers don't serialise on the default rollback journal. * The Bun.SQL wrapper deliberately does not auto-enable WAL (some * embedded deployments need rollback journals) — we opt in here. * - **D.5 use `createDb`**: connection goes through `@mandujs/core/db`, * never `new Bun.SQL` directly. Keeps URL/options translation in * exactly one place. * * ## TTL GC * * Expired rows are swept by a cron job registered via * `@mandujs/core/scheduler`. If `Bun.cron` is unavailable (pre-1.3.12), * we warn once and continue — the caller can still invoke * {@link SqliteSessionStorage.gcNow} manually from their own boot hook. * * @example * ```ts * import { createSqliteSessionStorage } from "@mandujs/core/filling/session-sqlite"; * import { session } from "@mandujs/core/middleware"; * * const storage = createSqliteSessionStorage({ * cookie: { secrets: [process.env.SESSION_SECRET!] }, * dbPath: ".mandu/sessions.db", * ttlSeconds: 60 * 60 * 24 * 7, // 7 days * }); * * // on shutdown: * await storage.close(); * ``` * * @module filling/session-sqlite */ import { createDb, type Db } from "../db"; import { defineCron, type CronRegistration } from "../scheduler"; import type { CookieManager, CookieOptions } from "./context"; import { Session, type CookieSessionOptions, type SessionData, type SessionStorage, } from "./session"; // ─── Public API ───────────────────────────────────────────────────────────── /** Construction options for {@link createSqliteSessionStorage}. */ export interface SqliteSessionStorageOptions { /** * Cookie-layer settings — reused verbatim from * {@link CookieSessionOptions}. The cookie only carries a signed id, * but name / secrets / flags / max-age still apply. */ cookie: CookieSessionOptions["cookie"]; /** * SQLite database path. Accepts `":memory:"` for transient tests or a * filesystem path for persisted sessions. Default: `".mandu/sessions.db"`. * * The string is appended to `sqlite:` to form a URL that * `@mandujs/core/db` accepts. */ dbPath?: string; /** * Table name for the session rows. Default: `"mandu_sessions"`. * * Not parameterisable at query time (SQLite does not bind identifiers), * so we validate against `SAFE_IDENT_RE` at construction to keep the * name out of injection-prone string interpolation territory. */ table?: string; /** * Absolute session lifetime in seconds. Default: `604800` (7 days). A * row's `expires_at` is (re)set on every commit; old values are wiped * by {@link SqliteSessionStorage.gcNow}. */ ttlSeconds?: number; /** * Cron schedule for the TTL sweep. Default: `"0 * * * *"` (hourly). * Set to `false` to disable the cron entirely — callers can still * invoke `gcNow()` manually. */ gcSchedule?: string | false; } /** * `SessionStorage` + SQLite-specific affordances. Returned by * {@link createSqliteSessionStorage}. */ export interface SqliteSessionStorage extends SessionStorage { /** * Immediately sweep expired rows. Safe to invoke at any time — the * cron job calls the same underlying delete. * * @returns The number of rows deleted. */ gcNow(): Promise; /** * Stop the GC cron (if started) and close the DB pool. Call from your * shutdown hook to release file handles. */ close(): Promise; } // ─── Constants ────────────────────────────────────────────────────────────── const DEFAULT_DB_PATH = ".mandu/sessions.db"; const DEFAULT_TABLE = "mandu_sessions"; const DEFAULT_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days const DEFAULT_GC_SCHEDULE = "0 * * * *"; // hourly /** * Safe identifier pattern for the user-supplied `table` name. SQLite does * not bind identifiers, so the table name is interpolated directly into * DDL/DML — we constrain it to `[A-Za-z_][A-Za-z0-9_]*` to eliminate any * injection surface. */ const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; // ─── HMAC helpers ──────────────────────────────────────────────────────────── // // Structurally identical to the private `hmacSign` inside `context.ts` and // the signing logic in `createCookieSessionStorage`. Duplicated rather than // shared to avoid widening `filling/context.ts`'s public surface; TODO: // extract into a private `filling/hmac.ts` once a third call site appears. async function hmacSign(data: string, secret: string): Promise { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); return btoa(String.fromCharCode(...new Uint8Array(sig))).replace(/=+$/, ""); } /** * Verify a signed cookie value against each secret in rotation order. * Returns the raw session id on success; `null` when none of the secrets * validate (invalid signature, tampered value, or no cookie). */ async function verifySignedId( rawCookieValue: string | undefined, secrets: readonly string[], ): Promise { if (!rawCookieValue) return null; const dot = rawCookieValue.lastIndexOf("."); if (dot <= 0) return null; const payload = rawCookieValue.slice(0, dot); const signature = rawCookieValue.slice(dot + 1); if (!payload || !signature) return null; for (const secret of secrets) { const expected = await hmacSign(payload, secret); if (expected === signature) { try { return decodeURIComponent(payload); } catch { return null; } } } return null; } // ─── Set-Cookie serialisation ─────────────────────────────────────────────── function serializeSetCookie( name: string, value: string, opts: CookieOptions, ): string { const parts = [`${name}=${encodeURIComponent(value)}`]; if (opts.path) parts.push(`Path=${opts.path}`); if (opts.domain) parts.push(`Domain=${opts.domain}`); if (typeof opts.maxAge === "number") parts.push(`Max-Age=${opts.maxAge}`); if (opts.httpOnly) parts.push("HttpOnly"); if (opts.secure) parts.push("Secure"); if (opts.sameSite) parts.push(`SameSite=${opts.sameSite}`); return parts.join("; "); } // ─── Row shape ────────────────────────────────────────────────────────────── interface SessionRow { id: string; data: string; expires_at: number; // Index signature so it satisfies the `Record` constraint // that `queryOne`'s generic expects. Property-level types above still win // for known keys. [key: string]: unknown; } // ─── Factory ──────────────────────────────────────────────────────────────── /** * Construct a SQLite-backed session storage. * * Initialisation is performed lazily on the first call to * {@link SessionStorage.getSession} / {@link SessionStorage.commitSession} / * {@link SessionStorage.destroySession} / {@link SqliteSessionStorage.gcNow} * — the constructor never opens a connection itself. This matches * `@mandujs/core/db`'s own laziness and keeps environment-driven * construction cheap at boot. * * @throws {Error} Synchronously when `cookie.secrets` is empty or when * `table` fails the safe-identifier check. */ export function createSqliteSessionStorage( options: SqliteSessionStorageOptions, ): SqliteSessionStorage { const { cookie, dbPath = DEFAULT_DB_PATH, table = DEFAULT_TABLE, ttlSeconds = DEFAULT_TTL_SECONDS, gcSchedule = DEFAULT_GC_SCHEDULE, } = options; const { name: cookieName = "__session", secrets, httpOnly = true, secure = process.env.NODE_ENV === "production", sameSite = "lax", maxAge = ttlSeconds, path = "/", domain, } = cookie; if (!secrets || secrets.length === 0) { throw new Error( "[Mandu Session SQLite] At least one cookie.secret is required.", ); } if (!SAFE_IDENT_RE.test(table)) { throw new Error( `[Mandu Session SQLite] Invalid table name ${JSON.stringify(table)}. ` + `Must match ${SAFE_IDENT_RE}.`, ); } const cookieOpts: CookieOptions = { httpOnly, secure, sameSite, maxAge, path, domain, }; // ─── DB init (idempotent) ───────────────────────────────────────────────── const url = `sqlite:${dbPath}`; const db: Db = createDb({ url }); // Init is run at most once. Each public method awaits this promise to // guarantee the schema + PRAGMAs are in place before any other query. let initPromise: Promise | null = null; let closed = false; function ensureInit(): Promise { if (initPromise) return initPromise; initPromise = (async () => { // Appendix D.4: enable WAL explicitly. Concurrent session writes on // the default rollback journal serialise hard; WAL gives us // readers-don't-block-writers semantics. Idempotent — if the file // is already in WAL, this is a no-op reflected in the return row. // // `PRAGMA journal_mode = WAL` must be run on the handle, not in a // transaction. `:memory:` databases accept the pragma but silently // remain in "memory" mode — we don't assert the return value here // to keep `:memory:` valid for tests; the WAL-mode test asserts on // a file-backed DB instead. // // Interpolation is safe here — the SQL text is a literal, no user // input. await db`PRAGMA journal_mode = WAL`; // Identifier was validated above, so direct interpolation into DDL // is safe and unavoidable (SQLite does not bind identifiers). const createTableSql = `CREATE TABLE IF NOT EXISTS ${table} ( id TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL )`; const createIndexSql = `CREATE INDEX IF NOT EXISTS ${table}_expires ON ${table}(expires_at)`; await execRaw(db, createTableSql); await execRaw(db, createIndexSql); })(); return initPromise; } // ─── Cron (optional) ────────────────────────────────────────────────────── let cronReg: CronRegistration | null = null; function startCronIfEnabled(): void { if (gcSchedule === false) return; if (cronReg) return; try { const reg = defineCron({ [`${table}:gc`]: { schedule: gcSchedule, run: async () => { await gcNow(); }, }, }); reg.start(); cronReg = reg; } catch (err) { // Older Bun (< 1.3.12) lacks `Bun.cron`. Warn once — the caller // can still call `gcNow()` manually — then keep serving traffic. const msg = err instanceof Error ? err.message : String(err); console.warn( `[Mandu Session SQLite] GC cron disabled: ${msg}. ` + `Sessions will still persist; call storage.gcNow() manually.`, ); } } // Register cron after the first init completes so the scheduler's first // tick never races with table creation. We fire-and-forget the init — // any error surfaces on the next real query. void ensureInit().then(startCronIfEnabled); // ─── SessionStorage methods ─────────────────────────────────────────────── async function getSession(cookies: CookieManager): Promise { if (closed) { throw new Error("[Mandu Session SQLite] storage is closed."); } await ensureInit(); const raw = cookies.get(cookieName); const decoded = typeof raw === "string" ? safeDecode(raw) : null; const id = await verifySignedId(decoded ?? undefined, secrets); if (!id) return new Session(); const now = Date.now(); // Direct identifier interpolation is safe (table validated at // construction). The user-controlled `id` and `now` go through Bun.SQL // placeholder binding. const sql = `SELECT id, data, expires_at FROM ${table} WHERE id = $1 AND expires_at > $2 LIMIT 1`; const row = await queryOne(db, sql, [id, now]); if (!row) return new Session(); let parsed: SessionData; try { parsed = JSON.parse(row.data) as SessionData; } catch { // Corrupted row — treat as a missed session rather than throwing at // the end user. A later commit will overwrite it. return new Session(); } return rehydrateSession(parsed, id); } async function commitSession(session: Session): Promise { if (closed) { throw new Error("[Mandu Session SQLite] storage is closed."); } await ensureInit(); // Clean + already-persisted sessions emit no Set-Cookie — matches the // "no-op when nothing changed" contract. if (!session.isDirty()) return ""; // Always ensure the row has a stable id. Sessions loaded by // `getSession` keep their DB id; freshly-constructed sessions use // the UUID v7 generated in the `Session` constructor. const id = session.id; if (!id || typeof id !== "string") { throw new Error("[Mandu Session SQLite] session.id missing."); } const expiresAt = Date.now() + ttlSeconds * 1000; const dataJson = JSON.stringify(session.toJSON()); // INSERT OR REPLACE — last write wins, concurrent writes resolve // under WAL without corruption. const sql = `INSERT OR REPLACE INTO ${table} (id, data, expires_at) VALUES ($1, $2, $3)`; await execWithParams(db, sql, [id, dataJson, expiresAt]); // Cookie carries ONLY the signed id. Never the data. const signedValue = await signPayload(id, secrets[0]); return serializeSetCookie(cookieName, signedValue, cookieOpts); } async function destroySession(session: Session): Promise { if (closed) { throw new Error("[Mandu Session SQLite] storage is closed."); } await ensureInit(); const id = session.id; if (typeof id === "string" && id.length > 0) { const sql = `DELETE FROM ${table} WHERE id = $1`; await execWithParams(db, sql, [id]); } // Emit an expiring cookie so the browser drops its copy. Shape // matches `createCookieSessionStorage`. const parts = [`${cookieName}=`, `Path=${path}`, "Max-Age=0"]; if (domain) parts.push(`Domain=${domain}`); if (httpOnly) parts.push("HttpOnly"); if (secure) parts.push("Secure"); if (sameSite) parts.push(`SameSite=${sameSite}`); return parts.join("; "); } async function gcNow(): Promise { if (closed) { throw new Error("[Mandu Session SQLite] storage is closed."); } await ensureInit(); const now = Date.now(); // Count-then-delete inside a transaction so we return an accurate // number even when another writer races us. Under WAL this is cheap. // Identifier is interpolated (validated at construction); the time // parameter is bound. let deleted = 0; await db.transaction(async (tx) => { const countSql = `SELECT COUNT(*) AS n FROM ${table} WHERE expires_at <= $1`; const cnt = await queryOne<{ n: number | bigint }>(tx, countSql, [now]); deleted = cnt ? Number(cnt.n) : 0; const delSql = `DELETE FROM ${table} WHERE expires_at <= $1`; await execWithParams(tx, delSql, [now]); }); return deleted; } async function close(): Promise { if (closed) return; closed = true; if (cronReg) { try { await cronReg.stop(); } catch { // Best-effort shutdown — don't mask the caller's shutdown flow. } cronReg = null; } await db.close(); } return { getSession, commitSession, destroySession, gcNow, close, }; } // ─── Signing ──────────────────────────────────────────────────────────────── /** * Sign a session id with the primary secret. The cookie value carries * `encodeURIComponent(id) + "." + `, matching the shape produced by * `CookieManager.setSigned` so `CookieManager.getSigned` could consume it * symmetrically in a pinch. */ async function signPayload(id: string, secret: string): Promise { const encoded = encodeURIComponent(id); const sig = await hmacSign(encoded, secret); return `${encoded}.${sig}`; } /** * URL-decode a cookie value, returning `null` on malformed input. We * never throw on untrusted cookie data. */ function safeDecode(value: string): string | null { try { return decodeURIComponent(value); } catch { return null; } } /** * Rebuild a {@link Session} from its persisted JSON blob while preserving * the supplied `id`. * * `Session.fromJSON` mints a fresh UUID for the rebuilt instance — * correct for cookie storage (the id lives on the cookie itself) but * wrong for SQLite where the row's primary key must stay stable across * requests. We go through the public constructor with `(data, id)` and * then replay flash keys so `session.get(k)` returns the flash on first * read, empty on subsequent reads — same contract as `fromJSON`. * * The replay needs write access to the private `flash` map and to the * `_dirty` flag; we reuse the same narrow-interface cast the cookie * codepath uses internally. */ function rehydrateSession(data: SessionData, id: string): Session { const session = new Session({}, id); const flashKeys: string[] = []; // Narrow structural view of the private fields we need to populate. // Session's public API does not expose a "load raw JSON with id" entry, // so we reach in carefully — the shape matches session.ts:42-53. const internal = session as unknown as { data: SessionData; flash: Map; _dirty: boolean; }; for (const [key, value] of Object.entries(data)) { if (key.startsWith("__flash_")) { const realKey = key.slice(8); internal.flash.set(realKey, value); flashKeys.push(key); } else { internal.data[key] = value; } } for (const key of flashKeys) { delete internal.data[key]; } // Just-loaded state is clean by definition. internal._dirty = false; return session; } // ─── DB helpers ───────────────────────────────────────────────────────────── // // `@mandujs/core/db` exposes a tagged-template API. Our DDL/DML strings // are dynamic (they interpolate the table name, which SQLite doesn't // bind), so we construct the TemplateStringsArray ourselves via the // pattern used by Bun.SQL: split the string at `$1`, `$2`, … markers and // forward values in positional order. /** Run a SQL string with positional `$N` placeholders. No result rows consumed. */ async function execWithParams( dbOrTx: Db, sql: string, params: unknown[], ): Promise { const parts = splitPlaceholders(sql, params.length); const strings = Object.assign(parts.slice(), { raw: parts.slice() }) as unknown as TemplateStringsArray; await dbOrTx(strings, ...params); } /** Run a SQL string and return at most one row, or `null`. */ async function queryOne>( dbOrTx: Db, sql: string, params: unknown[], ): Promise { const parts = splitPlaceholders(sql, params.length); const strings = Object.assign(parts.slice(), { raw: parts.slice() }) as unknown as TemplateStringsArray; const rows = await dbOrTx(strings, ...params); if (!rows || rows.length === 0) return null; return rows[0] as T; } /** Run a parameter-less DDL/DML statement. */ async function execRaw(dbOrTx: Db, sql: string): Promise { const strings = Object.assign([sql], { raw: [sql] }) as unknown as TemplateStringsArray; await dbOrTx(strings); } /** * Split a SQL string with `$1`, `$2`, … markers into the string segments * that bracket each placeholder. The resulting array has * `placeholderCount + 1` entries — matches the shape of a * `TemplateStringsArray` produced by literal interpolation. * * Throws when the detected placeholder count does not match the expected * count — a diagnostic for mismatched SQL + params pairs. */ function splitPlaceholders(sql: string, expected: number): string[] { const parts: string[] = []; let rest = sql; for (let i = 1; i <= expected; i++) { const marker = `$${i}`; const idx = rest.indexOf(marker); if (idx === -1) { throw new Error( `[Mandu Session SQLite] placeholder ${marker} missing in SQL: ${sql}`, ); } parts.push(rest.slice(0, idx)); rest = rest.slice(idx + marker.length); } parts.push(rest); return parts; }