import type { IDBPDatabase } from "idb"; import { deleteDB, openDB } from "idb"; import type { SessionEvent } from "@a4anthony/proctorkit-types"; interface ProctoringSchema { events: { key: string; value: StoredEvent; indexes: { "by-seq": number }; }; meta: { key: string; value: { key: string; value: number }; }; } interface StoredEvent extends SessionEvent { seq: number; bytes: number; } interface MemoryQueueState { events: StoredEvent[]; seq: number; bytes: number; reason: string; } export interface EventQueueOptions { dbName?: string; maxBytes?: number; /** * IndexedDB is the normal durable mode. Memory mode is used as a * degraded fallback when the browser denies IndexedDB in a worker * context. */ storage?: "indexeddb" | "memory"; /** * When true, an IndexedDB open failure falls back to a non-durable * in-memory queue instead of failing SDK startup. Defaults to true. */ fallbackToMemory?: boolean; } const DEFAULT_DB_NAME = "proctoring-sdk"; const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; const SEQ_KEY = "seq"; const BYTES_KEY = "bytes"; /** * Prefix for the per-session IndexedDB databases the worker creates * (one per `sessionId`, named `proctoring-`). The sweep * below uses this to recognise the SDK's own leaked databases without * touching anything else on the origin. Keep in sync with the * queueFactory in worker-core.ts. */ export const SESSION_DB_PREFIX = "proctoring-sess"; /** * The IndexedDB database name the SDK uses for a given session. Single * source of truth for the `proctoring-` scheme so the * worker's queue factory and its stale-database sweep can't drift on * the naming. Session ids are already `sess_…`, so this yields names * under {@link SESSION_DB_PREFIX}. */ export function sessionDbName(sessionId: string): string { return `proctoring-${sessionId}`; } /** * IndexedDB-backed FIFO queue of session events. Survives page * reloads and tab crashes — events are only removed on explicit * {@link EventQueue.ack}, so an event that left the queue has been * confirmed durable somewhere downstream. * * Ordering is by an internal monotonic `seq` (1, 2, 3 ...), not by * the event's timestamp. Two events with identical timestamps still * have a stable order. * * Concurrent calls from JS are safe: IndexedDB serialises overlapping * `readwrite` transactions on the same object stores, so the byte and * size accounting cannot drift under contention. */ export class EventQueue { private db: IDBPDatabase | null = null; private memory: MemoryQueueState | null = null; private readonly dbName: string; private readonly maxBytes: number; private readonly storage: "indexeddb" | "memory"; private readonly fallbackToMemory: boolean; constructor(options: EventQueueOptions = {}) { this.dbName = options.dbName ?? DEFAULT_DB_NAME; this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; this.storage = options.storage ?? "indexeddb"; this.fallbackToMemory = options.fallbackToMemory ?? true; } /** Opens (or upgrades) the underlying IndexedDB database. Must be called once before any other method. Idempotent. */ async open(): Promise { if (this.db || this.memory) return; if (this.storage === "memory") { this.openMemory("configured-memory-storage"); return; } try { this.db = await openDB(this.dbName, 1, { upgrade(db) { const events = db.createObjectStore("events", { keyPath: "id" }); events.createIndex("by-seq", "seq"); db.createObjectStore("meta", { keyPath: "key" }); }, }); } catch (err) { if (!this.fallbackToMemory) throw err; this.openMemory(indexedDbOpenFailureReason(err)); } } storageMode(): "indexeddb" | "memory" { return this.memory ? "memory" : "indexeddb"; } storageFallbackReason(): string | null { return this.memory?.reason ?? null; } /** * Appends an event to the tail. If the queue would exceed * `maxBytes`, drops the oldest events until it fits — the new * event is never the one dropped. Returns the count of dropped * events so callers can surface backpressure to the host. */ async enqueue(event: SessionEvent): Promise<{ dropped: number }> { if (this.memory) return this.enqueueMemory(event); const db = this.requireDb(); const bytes = byteLength(event); const tx = db.transaction(["events", "meta"], "readwrite"); const events = tx.objectStore("events"); const meta = tx.objectStore("meta"); const seq = ((await meta.get(SEQ_KEY))?.value ?? 0) + 1; let totalBytes = ((await meta.get(BYTES_KEY))?.value ?? 0) + bytes; await events.put({ ...event, seq, bytes }); await meta.put({ key: SEQ_KEY, value: seq }); let dropped = 0; if (totalBytes > this.maxBytes) { const cursor = await events.index("by-seq").openCursor(); let c = cursor; while (c && totalBytes > this.maxBytes) { const stored = c.value; if (stored.id === event.id) { c = await c.continue(); continue; } totalBytes -= stored.bytes; dropped += 1; await c.delete(); c = await c.continue(); } } await meta.put({ key: BYTES_KEY, value: totalBytes }); await tx.done; return { dropped }; } /** Returns up to `limit` oldest events without removing them. */ async peek(limit: number): Promise { if (this.memory) { return this.memory.events .slice(0, limit) .map(({ seq: _seq, bytes: _bytes, ...event }) => event); } const db = this.requireDb(); const tx = db.transaction("events", "readonly"); const out: SessionEvent[] = []; let cursor = await tx.store.index("by-seq").openCursor(); while (cursor && out.length < limit) { const { seq: _seq, bytes: _bytes, ...event } = cursor.value; out.push(event); cursor = await cursor.continue(); } return out; } /** * Removes events by id. Unknown ids are silently skipped so callers * can safely ack the same id twice (eg. when an idempotency-keyed * server retry returns success after we already acked locally). */ async ack(ids: string[]): Promise { if (ids.length === 0) return; if (this.memory) { const idSet = new Set(ids); let bytes = this.memory.bytes; this.memory.events = this.memory.events.filter((event) => { if (!idSet.has(event.id)) return true; bytes -= event.bytes; return false; }); this.memory.bytes = Math.max(0, bytes); return; } const db = this.requireDb(); const tx = db.transaction(["events", "meta"], "readwrite"); const events = tx.objectStore("events"); const meta = tx.objectStore("meta"); let totalBytes = (await meta.get(BYTES_KEY))?.value ?? 0; for (const id of ids) { const stored = await events.get(id); if (!stored) continue; totalBytes -= stored.bytes; await events.delete(id); } await meta.put({ key: BYTES_KEY, value: Math.max(0, totalBytes) }); await tx.done; } /** Number of events currently queued. */ async size(): Promise { if (this.memory) return this.memory.events.length; const db = this.requireDb(); return db.count("events"); } /** Cumulative byte size of all queued events (JSON-encoded). */ async bytes(): Promise { if (this.memory) return this.memory.bytes; const db = this.requireDb(); return (await db.get("meta", BYTES_KEY))?.value ?? 0; } /** Closes the database handle. Safe to call multiple times. */ async close(): Promise { this.db?.close(); this.db = null; this.memory = null; } /** * Closes the handle AND deletes the underlying database from disk. * * Call this only once the queue is known to be fully drained — a * deleted database takes any not-yet-uploaded events with it. The * worker gates this on `size() === 0` after draining at teardown, so * an offline teardown (events still queued) keeps the database for a * later page load to flush. Reclaiming the database is what stops the * per-session databases accumulating until the origin hits its * storage quota and `open()` starts throwing. */ async destroy(): Promise { if (this.memory) { await this.close(); return; } await this.close(); await deleteDB(this.dbName); } /** * Best-effort reclamation of leaked per-session databases left by * earlier sessions on this origin. Enumerates all IndexedDB * databases, and for each `proctoring-sess-*` one NOT in `keep`, * opens it, checks it holds zero events, and deletes it if so. A * database with pending events is left alone — it may still flush on * a future load. * * No-ops where `indexedDB.databases()` is unavailable (Firefox, older * Safari): there is no enumeration API there, so leaked databases * can't be discovered. New sessions still self-clean via destroy(), * so the leak is bounded going forward even on those browsers. * * Never throws — cleanup must not be able to fail a session. */ static async sweepStale(keep: ReadonlySet = new Set()): Promise { let removed = 0; try { if ( typeof indexedDB === "undefined" || typeof indexedDB.databases !== "function" ) { return 0; } const entries = await indexedDB.databases(); for (const { name } of entries) { if (!name || keep.has(name) || !name.startsWith(SESSION_DB_PREFIX)) { continue; } try { const queue = new EventQueue({ dbName: name }); await queue.open(); const pending = await queue.size(); await queue.close(); if (pending === 0) { await deleteDB(name); removed += 1; } } catch { // A single un-openable / racing database must not abort the // sweep of the rest. } } } catch { // databases() itself can reject in some engines; swallow. } return removed; } private requireDb(): IDBPDatabase { if (!this.db) { throw new Error("EventQueue: call open() before any other operation"); } return this.db; } private openMemory(reason: string): void { this.db = null; this.memory = { events: [], seq: 0, bytes: 0, reason, }; } private enqueueMemory(event: SessionEvent): { dropped: number } { if (!this.memory) { throw new Error("EventQueue: call open() before any other operation"); } const bytes = byteLength(event); const stored: StoredEvent = { ...event, seq: this.memory.seq + 1, bytes, }; this.memory.seq = stored.seq; this.memory.events.push(stored); this.memory.bytes += bytes; let dropped = 0; while (this.memory.events.length > 1 && this.memory.bytes > this.maxBytes) { const oldest = this.memory.events[0]; if (!oldest || oldest.id === event.id) break; this.memory.events.shift(); this.memory.bytes -= oldest.bytes; dropped += 1; } return { dropped }; } } function byteLength(event: SessionEvent): number { return new TextEncoder().encode(JSON.stringify(event)).byteLength; } function indexedDbOpenFailureReason(err: unknown): string { if (err instanceof Error) { return `${err.name}: ${err.message}`; } return String(err); }