import { Database } from 'bun:sqlite'; import type { NonceStore } from './types'; export class MemoryNonceStore implements NonceStore { private seen = new Map(); consume(nonce: string, expiresAt: number): boolean { const now = Date.now(); // Prune inline instead of on a timer — tokens expire in minutes and this // map only grows one entry per successful send, so the scan stays tiny. for (const [key, exp] of this.seen) { if (exp < now) { this.seen.delete(key); } } if (this.seen.has(nonce)) { return false; } this.seen.set(nonce, expiresAt); return true; } } export class SqliteNonceStore implements NonceStore { private db: Database; constructor(path: string) { this.db = new Database(path, { create: true }); this.db.run('CREATE TABLE IF NOT EXISTS nonces (nonce TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)'); // Without this the prune below is a full table scan on every consume — the // primary key indexes `nonce`, not `expires_at`. this.db.run('CREATE INDEX IF NOT EXISTS nonces_expires_at ON nonces (expires_at)'); } consume(nonce: string, expiresAt: number): boolean { // Pruning rides on consume rather than a background sweeper: a row is only // ever added by a consume, so the table can't grow while nothing is calling // this. That makes it self-limiting — no timer to own or shut down. this.db.run('DELETE FROM nonces WHERE expires_at < ?', [Date.now()]); const result = this.db.run('INSERT OR IGNORE INTO nonces (nonce, expires_at) VALUES (?, ?)', [nonce, expiresAt]); return result.changes === 1; } // Release the underlying SQLite handle. Optional in production (the store lives // for the process), but Windows won't delete the DB file while a handle is open, // so tests must close before removing their temp dir. close(): void { this.db.close(); } }