/** * Durable, IndexedDB-backed append log for un-uploaded recording bytes. * * Why this exists — the data-loss bug it closes: * * Recording streams upload to S3 as a multipart object. S3 requires every * part except the last to be >= 5 MB, so {@link MultipartUploader} BUFFERS * MediaRecorder blobs in memory until they reach a part, then PUTs. That * buffer is volatile: a page refresh / tab crash discards it. On a low * bitrate or short pre-refresh window the un-flushed tail is the ENTIRE * pre-refresh recording — silently lost, with the resumed session starting * a fresh recording that omits it. * * This store gives that buffer the same durability the event queue already * has. Incoming blobs are persisted here as they arrive; a refresh keeps * them; on resume the uploader {@link restore}s the un-committed tail and * PUTs it into the SAME multipart (the server resumes the open upload and * hands back the next part number, so numbering — and therefore byte * offsets — stay contiguous). * * Model — an absolute-offset append log with front-only truncation: * * - Each appended blob records its ABSOLUTE byte offset in the whole * recording (offsets never reset across page loads; the running total * is persisted). So "how much is already committed" is a single byte * watermark, and slicing a partially-committed blob is exact. * - `commit(committedBytes)` deletes records that lie fully below the * watermark. The watermark is derived by the uploader from the server's * authoritative resume point — `(nextPartNumber - 1) * partSize` — since * every non-final part is exactly `partSize`. No fragile client-only ack * counter drives correctness. * - Eviction (disk cap) and commit only ever remove from the FRONT, so the * retained records are always a contiguous tail. Evicting the oldest * un-committed record is the durable mirror of the uploader's in-memory * `maxBufferBytes` overflow: it creates a gap the server completes up to, * never a corrupt middle. * * Bounds (the three guards that keep this from trading a data-loss bug for a * leak): * 1. Backpressure — appends are serialised through one writer; blobs queued * but not yet written are capped (`maxPendingBytes`). Over the cap the * OLDEST pending write is dropped. Bounds MEMORY. * 2. Disk budget — persisted-but-un-committed bytes are capped (`maxBytes`); * over it the oldest record is evicted (front truncation → server gap). * Bounds DISK. * 3. Degrade, never throw — any IndexedDB failure (quota, private mode, * worker denial) flips the store to a no-op. The uploader's in-memory * buffer still works, so recording degrades to today's behaviour rather * than breaking. Surfaced once via `onDegrade`. */ import { deleteDB, openDB, type IDBPDatabase } from "idb"; /** DB-name prefix for the per-(session,kind) recording buffers. */ export const RECORDING_DB_PREFIX = "proctoring-rec"; /** The IndexedDB database name for a recording buffer. */ export function recordingDbName(sessionId: string, kind: string): string { return `${RECORDING_DB_PREFIX}-${sessionId}-${kind}`; } export interface DurableBlobStoreOptions { sessionId: string; kind: "screen" | "webcam"; /** * Max persisted-but-un-committed bytes. Over this the oldest record is * evicted (front truncation → the server completes up to the gap). * Default 128 MB — comfortably above the uploader's in-memory buffer cap, * so in normal operation commit() keeps disk near the un-acked working set. */ maxBytes?: number; /** * Max bytes queued for write but not yet persisted. Over this the oldest * pending write is dropped. The memory backstop for a stalled IndexedDB. * Default 32 MB. */ maxPendingBytes?: number; /** Fires once when the store degrades to a no-op (IndexedDB unusable). */ onDegrade?: (reason: string) => void; /** Fires when a record/pending-write is dropped by a cap (byte count). */ onEvict?: (bytes: number, reason: "disk-cap" | "pending-cap") => void; } interface StoredRecord { /** Absolute byte offset of this blob in the whole recording. */ offset: number; size: number; /** * Bytes stored as an ArrayBuffer, not a Blob. Blobs don't round-trip * reliably through structured clone in every engine (and not at all under * fake-indexeddb); ArrayBuffers do. We reconstruct a Blob on restore. */ buffer: ArrayBuffer; } const DEFAULT_MAX_BYTES = 128 * 1024 * 1024; const DEFAULT_MAX_PENDING_BYTES = 32 * 1024 * 1024; const META_TOTAL = "totalBytes"; const META_FLOOR = "floorBytes"; /** * Open a durable blob store for one recording stream. Never rejects: on any * IndexedDB failure it returns a degraded no-op store (recording falls back * to the in-memory buffer). Call {@link DurableBlobStore.close} when done. */ export async function openDurableBlobStore( opts: DurableBlobStoreOptions, ): Promise { const store = new DurableBlobStore(opts); await store.open(); return store; } export class DurableBlobStore { private readonly opts: Required< Omit > & Pick; private db: IDBPDatabase | null = null; private degraded = false; /** Running absolute offset for the NEXT appended byte. Persisted. */ private totalBytes = 0; /** Front truncation watermark from eviction. Persisted. */ private floorBytes = 0; /** * Snapshot of `totalBytes` at open. `restore()` only returns records BELOW * this — i.e. the tail persisted by PRIOR instances (a refresh). Blobs this * instance appends live sit at or above it and must never be restored, or a * resume would re-prepend (and thus duplicate) its own live bytes. */ private openTotalBytes = 0; /** Actual bytes currently stored (drives the disk cap; not persisted). */ private diskBytes = 0; // Serialised writer: appends chain off this so records land in order. private writeChain: Promise = Promise.resolve(); private pendingBytes = 0; constructor(opts: DurableBlobStoreOptions) { this.opts = { sessionId: opts.sessionId, kind: opts.kind, maxBytes: opts.maxBytes ?? DEFAULT_MAX_BYTES, maxPendingBytes: opts.maxPendingBytes ?? DEFAULT_MAX_PENDING_BYTES, onDegrade: opts.onDegrade, onEvict: opts.onEvict, }; } async open(): Promise { try { this.db = await openDB(recordingDbName(this.opts.sessionId, this.opts.kind), 1, { upgrade(db) { db.createObjectStore("blobs", { keyPath: "key", autoIncrement: true }); db.createObjectStore("meta", { keyPath: "key" }); }, }); // Rehydrate the offset watermarks so a resumed instance continues the // same absolute byte space (offsets must not reset across page loads). const meta = this.db.transaction("meta").objectStore("meta"); this.totalBytes = ((await meta.get(META_TOTAL))?.value as number) ?? 0; this.floorBytes = ((await meta.get(META_FLOOR))?.value as number) ?? 0; this.openTotalBytes = this.totalBytes; // Recompute actual on-disk bytes from the surviving records so the disk // cap accounts for prior-session leftovers. const recs = (await this.db.getAll("blobs")) as StoredRecord[]; this.diskBytes = recs.reduce((sum, r) => sum + r.size, 0); } catch (err) { this.degrade(`open: ${(err as Error)?.message ?? "unknown"}`); } } /** * Persist one recorder blob. Non-blocking: enqueues onto the serialised * writer. Assigns the blob its absolute offset synchronously so ordering is * deterministic even if writes settle out of order. */ append(blob: Blob): void { if (this.degraded || !this.db || blob.size === 0) return; // Pending-write memory backstop: if IndexedDB is stalling and the queue // is over budget, drop this blob rather than grow memory unbounded. if (this.pendingBytes + blob.size > this.opts.maxPendingBytes) { this.opts.onEvict?.(blob.size, "pending-cap"); return; } const offset = this.totalBytes; const size = blob.size; this.totalBytes += size; this.pendingBytes += size; this.writeChain = this.writeChain .then(() => this.writeRecord(offset, size, blob)) .catch((err) => this.degrade(`write: ${(err as Error)?.message ?? "unknown"}`)) .finally(() => { this.pendingBytes -= size; }); } private async writeRecord(offset: number, size: number, blob: Blob): Promise { if (!this.db) return; const buffer = await blob.arrayBuffer(); const tx = this.db.transaction(["blobs", "meta"], "readwrite"); await tx.objectStore("blobs").add({ offset, size, buffer } satisfies StoredRecord); await tx.objectStore("meta").put({ key: META_TOTAL, value: offset + size }); await tx.done; this.diskBytes += size; await this.enforceDiskCap(); } /** * The un-committed tail as blobs, in order, sliced exactly at * `committedBytes` (bytes already durably in S3, from the server's resume * point). Records fully below the watermark are ignored; a straddling * record is sliced. Returns [] when degraded or nothing remains. */ async restore(committedBytes: number): Promise { await this.flush(); if (this.degraded || !this.db) return []; const cut = Math.max(committedBytes, this.floorBytes); const all = (await this.db.getAll("blobs")) as StoredRecord[]; all.sort((a, b) => a.offset - b.offset); const out: Blob[] = []; for (const rec of all) { // Only the tail persisted by PRIOR instances — never this instance's // own live appends (which sit at/above the open-time ceiling). if (rec.offset >= this.openTotalBytes) continue; const end = rec.offset + rec.size; if (end <= cut) continue; // fully committed if (rec.offset >= cut) { out.push(new Blob([rec.buffer])); } else { out.push(new Blob([rec.buffer.slice(cut - rec.offset)])); // straddles the watermark } } return out; } /** Delete records lying fully below `committedBytes`. Front truncation. */ async commit(committedBytes: number): Promise { await this.flush(); if (this.degraded || !this.db) return; const cut = Math.max(committedBytes, this.floorBytes); try { const tx = this.db.transaction("blobs", "readwrite"); let cursor = await tx.objectStore("blobs").openCursor(); while (cursor) { const rec = cursor.value as StoredRecord; if (rec.offset + rec.size <= cut) { this.diskBytes -= rec.size; await cursor.delete(); } cursor = await cursor.continue(); } await tx.done; } catch (err) { this.degrade(`commit: ${(err as Error)?.message ?? "unknown"}`); } } /** Drop everything (call after a graceful multipart complete). */ async clear(): Promise { await this.flush().catch(() => undefined); if (!this.db) return; try { const name = this.db.name; this.db.close(); this.db = null; await deleteDB(name); } catch { // Best effort — a leftover DB is swept next session by sweepOrphans. } } /** Await all queued writes. */ async flush(): Promise { await this.writeChain.catch(() => undefined); } async close(): Promise { await this.flush().catch(() => undefined); this.db?.close(); this.db = null; } /** Actual bytes currently persisted (post-commit / post-eviction). */ bytesOnDisk(): number { return Math.max(0, this.diskBytes); } isDegraded(): boolean { return this.degraded; } // ── internals ────────────────────────────────────────────────────────── private async enforceDiskCap(): Promise { if (!this.db) return; while (this.diskBytes > this.opts.maxBytes) { const tx = this.db.transaction(["blobs", "meta"], "readwrite"); const cursor = await tx.objectStore("blobs").openCursor(); if (!cursor) { await tx.done; break; } const rec = cursor.value as StoredRecord; await cursor.delete(); this.floorBytes = rec.offset + rec.size; this.diskBytes -= rec.size; await tx.objectStore("meta").put({ key: META_FLOOR, value: this.floorBytes }); await tx.done; this.opts.onEvict?.(rec.size, "disk-cap"); } } private degrade(reason: string): void { if (this.degraded) return; this.degraded = true; try { this.db?.close(); } catch { /* ignore */ } this.db = null; this.opts.onDegrade?.(reason); } } /** * Delete recording buffers left over from previous sessions. Keeps the DBs * named in `keep` (the current session's). Mirrors the event queue's stale * sweep; no-ops where `indexedDB.databases()` is unavailable. Returns the * count deleted. */ export async function sweepOrphanRecordingStores( keep: ReadonlySet = new Set(), ): Promise { try { if (typeof indexedDB === "undefined" || typeof indexedDB.databases !== "function") { return 0; } const entries = await indexedDB.databases(); let deleted = 0; for (const { name } of entries) { if (!name || keep.has(name) || !name.startsWith(RECORDING_DB_PREFIX)) continue; try { await deleteDB(name); deleted += 1; } catch { // Skip a locked DB; a later sweep gets it. } } return deleted; } catch { return 0; } }