/** * `PdfByteStore` — store for uploaded PDF bytes, keyed by `DocumentId`. * * BROWSER SURFACE — not part of the headless ingest core. This mints and * revokes `blob:` URLs and is meant to back an interactive upload/viewer * session. Headless callers should use `src/pdf/` directly and never import * this module. * * When constructed with a `sessionId`, put/delete/clear also mirror into * IndexedDB so bytes survive page reloads (CE-WP-0010 follow-up / CE-WP-0005 * deferred work). Call `hydrate()` once after creation to load durable bytes * into memory and re-mint blob URLs. */ import type { DocumentId } from "@citation-evidence/engine/shared"; import { idbClearSession, idbDeleteBytes, idbListSession, idbPutBytes, } from "./byte-store-idb"; export interface PdfByteRecord { readonly bytes: Uint8Array; /** A `blob:` URL the viewer can consume directly. */ readonly blobUrl: string; } export interface PdfByteStore { put( documentId: DocumentId, bytes: Uint8Array, contentType?: string, ): PdfByteRecord; get(documentId: DocumentId): PdfByteRecord | null; has(documentId: DocumentId): boolean; delete(documentId: DocumentId): boolean; list(): readonly DocumentId[]; /** Revoke every blob URL and clear the store (and durable copy if any). */ clear(): void; /** Total bytes currently held — useful for UI dashboards. */ size(): number; /** * Load durable bytes for this store's session into memory. * No-op when persistence is disabled. Safe to call multiple times. */ hydrate(): Promise; /** True after the first hydrate() attempt finishes (success or fail). */ isHydrated(): boolean; } export interface CreatePdfByteStoreOptions { /** * Mint a URL for the given bytes. Defaults to `URL.createObjectURL` in * environments that have it; tests can inject a deterministic stub. */ readonly createObjectURL?: (blob: Blob) => string; /** Revoke a URL previously minted by `createObjectURL`. */ readonly revokeObjectURL?: (url: string) => void; /** * When set, mirror put/delete/clear into IndexedDB under this session id * and support hydrate() for reload recovery. */ readonly sessionId?: string; /** Disable IDB even when sessionId is set (tests). Default true when sessionId set. */ readonly persist?: boolean; } export function createPdfByteStore( options: CreatePdfByteStoreOptions = {}, ): PdfByteStore { const createUrl = options.createObjectURL ?? ((blob: Blob) => { if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") { throw new Error( "createPdfByteStore: URL.createObjectURL not available — inject a stub via options", ); } return URL.createObjectURL(blob); }); const revokeUrl = options.revokeObjectURL ?? ((url: string) => { if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { URL.revokeObjectURL(url); } }); const sessionId = options.sessionId; const persist = options.persist ?? (typeof sessionId === "string" && sessionId.length > 0); const records = new Map(); let hydrated = !persist; let hydratePromise: Promise | null = null; function putInMemory( documentId: DocumentId, bytes: Uint8Array, contentType: string, ): PdfByteRecord { const prior = records.get(documentId); if (prior) revokeUrl(prior.blobUrl); const blob = new Blob([bytes as unknown as ArrayBuffer], { type: contentType, }); const blobUrl = createUrl(blob); // Keep a copy so callers can free the original buffer safely. const owned = new Uint8Array(bytes); const record: PdfByteRecord = { bytes: owned, blobUrl }; records.set(documentId, record); return record; } return { put(documentId, bytes, contentType = "application/pdf") { const record = putInMemory(documentId, bytes, contentType); if (persist && sessionId) { void idbPutBytes(sessionId, documentId, record.bytes, contentType); } return record; }, get(documentId) { return records.get(documentId) ?? null; }, has(documentId) { return records.has(documentId); }, delete(documentId) { const record = records.get(documentId); if (!record) return false; revokeUrl(record.blobUrl); records.delete(documentId); if (persist && sessionId) { void idbDeleteBytes(sessionId, documentId); } return true; }, list() { return [...records.keys()]; }, clear() { for (const record of records.values()) revokeUrl(record.blobUrl); records.clear(); if (persist && sessionId) { void idbClearSession(sessionId); } }, size() { let total = 0; for (const r of records.values()) total += r.bytes.byteLength; return total; }, hydrate() { if (!persist || !sessionId) { hydrated = true; return Promise.resolve(); } if (hydratePromise) return hydratePromise; hydratePromise = (async () => { const rows = await idbListSession(sessionId); for (const row of rows) { const id = row.documentId as DocumentId; if (records.has(id)) continue; putInMemory(id, new Uint8Array(row.bytes), row.contentType); } hydrated = true; })().catch((err) => { console.warn("PdfByteStore.hydrate failed", err); hydrated = true; }); return hydratePromise; }, isHydrated() { return hydrated; }, }; }