import { openDB, type IDBPDatabase } from "idb"; import type { DurableSegment, SegmentDurability, } from "./segment-uploader.js"; export const SEGMENT_DB_PREFIX = "proctoring-segments"; const LAST_SEQUENCE_KEY = "lastSequence"; export function segmentDbName( sessionId: string, kind: "screen" | "webcam", ): string { return `${SEGMENT_DB_PREFIX}-${sessionId}-${kind}`; } interface StoredSegment { sequence: number; buffer: ArrayBuffer; mimeType: string; timingHeaders?: Record; } export interface DurableSegmentStoreOptions { sessionId: string; kind: "screen" | "webcam"; } /** * IndexedDB queue for independent recording objects. A record is deleted * only after the API acknowledges that its object exists in durable storage. */ export class DurableSegmentStore implements SegmentDurability { private readonly dbName: string; private db: IDBPDatabase | null = null; constructor(options: DurableSegmentStoreOptions) { this.dbName = segmentDbName(options.sessionId, options.kind); } name(): string { return this.dbName; } async open(): Promise { if (this.db) return; this.db = await openDB(this.dbName, 1, { upgrade(db) { db.createObjectStore("segments", { keyPath: "sequence" }); db.createObjectStore("meta", { keyPath: "key" }); }, }); } async list(): Promise { const db = this.requireDb(); const records = (await db.getAll("segments")) as StoredSegment[]; records.sort((left, right) => left.sequence - right.sequence); return records.map((record) => ({ sequence: record.sequence, blob: new Blob([record.buffer], { type: record.mimeType || "video/webm", }), ...(record.timingHeaders ? { timingHeaders: record.timingHeaders } : {}), })); } async listSequences(): Promise { const keys = await this.requireDb().getAllKeys("segments"); return keys .map((key) => Number(key)) .filter((key) => Number.isSafeInteger(key) && key > 0) .sort((left, right) => left - right); } async get(sequence: number): Promise { const record = (await this.requireDb().get( "segments", sequence, )) as StoredSegment | undefined; if (!record) return null; return { sequence: record.sequence, blob: new Blob([record.buffer], { type: record.mimeType || "video/webm", }), ...(record.timingHeaders ? { timingHeaders: record.timingHeaders } : {}), }; } async put(record: DurableSegment): Promise { const db = this.requireDb(); await db.put("segments", { sequence: record.sequence, buffer: await record.blob.arrayBuffer(), mimeType: record.blob.type, ...(record.timingHeaders ? { timingHeaders: record.timingHeaders } : {}), } satisfies StoredSegment); } async remove(sequence: number): Promise { await this.requireDb().delete("segments", sequence); } async nextSequence(preferred: number): Promise { const db = this.requireDb(); const tx = db.transaction("meta", "readwrite"); const store = tx.objectStore("meta"); const prior = (await store.get(LAST_SEQUENCE_KEY)) as | { key: string; value: number } | undefined; const sequence = Math.max( Number.isSafeInteger(preferred) && preferred > 0 ? preferred : 1, (prior?.value ?? 0) + 1, ); await store.put({ key: LAST_SEQUENCE_KEY, value: sequence }); await tx.done; return sequence; } async close(): Promise { this.db?.close(); this.db = null; } private requireDb(): IDBPDatabase { if (!this.db) { throw new Error(`Durable segment store ${this.dbName} is not open`); } return this.db; } }