From b3eaa4b406257c7823290bca4fb54b14ec08f60a Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 30 Jul 2026 20:05:24 +0200 Subject: [PATCH] Persist PDF bytes in IndexedDB for session reload. Mirror PdfByteStore put/delete/clear into a session-scoped IndexedDB store and expose hydrate() so the umbrella can restore bytes after page reload. --- src/browser/byte-store-idb.ts | 147 +++++++++++++++++++++++++++++++++ src/browser/byte-store.test.ts | 3 +- src/browser/byte-store.ts | 107 ++++++++++++++++++------ src/browser/index.ts | 6 ++ 4 files changed, 239 insertions(+), 24 deletions(-) create mode 100644 src/browser/byte-store-idb.ts diff --git a/src/browser/byte-store-idb.ts b/src/browser/byte-store-idb.ts new file mode 100644 index 0000000..28a7782 --- /dev/null +++ b/src/browser/byte-store-idb.ts @@ -0,0 +1,147 @@ +/** + * IndexedDB backing for PdfByteStore — survives page reloads. + * + * Keys are scoped by session so multi-session demos don't share bytes. + * Failures are logged and ignored so a broken IDB never blocks upload. + */ + +import type { DocumentId } from "@citation-evidence/engine/shared"; + +const DB_NAME = "citation-evidence-pdf-bytes"; +const DB_VERSION = 1; +const STORE_NAME = "bytes"; + +export interface PersistedByteEntry { + readonly key: string; + readonly sessionId: string; + readonly documentId: string; + readonly contentType: string; + readonly bytes: ArrayBuffer; +} + +function entryKey(sessionId: string, documentId: string): string { + return `${sessionId}::${documentId}`; +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + if (typeof indexedDB === "undefined") { + reject(new Error("indexedDB not available")); + return; + } + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onerror = () => reject(req.error ?? new Error("indexedDB open failed")); + req.onsuccess = () => resolve(req.result); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: "key" }); + store.createIndex("sessionId", "sessionId", { unique: false }); + } + }; + }); +} + +function idbReq(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("idb request failed")); + }); +} + +function txDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("idb tx failed")); + tx.onabort = () => reject(tx.error ?? new Error("idb tx aborted")); + }); +} + +export async function idbPutBytes( + sessionId: string, + documentId: DocumentId, + bytes: Uint8Array, + contentType: string, +): Promise { + try { + const db = await openDb(); + try { + const copy = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + const entry: PersistedByteEntry = { + key: entryKey(sessionId, documentId), + sessionId, + documentId, + contentType, + bytes: copy, + }; + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).put(entry); + await txDone(tx); + } finally { + db.close(); + } + } catch (err) { + console.warn("idbPutBytes failed", err); + } +} + +export async function idbDeleteBytes( + sessionId: string, + documentId: DocumentId, +): Promise { + try { + const db = await openDb(); + try { + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).delete(entryKey(sessionId, documentId)); + await txDone(tx); + } finally { + db.close(); + } + } catch (err) { + console.warn("idbDeleteBytes failed", err); + } +} + +export async function idbClearSession(sessionId: string): Promise { + try { + const db = await openDb(); + try { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const index = store.index("sessionId"); + const keys = await idbReq(index.getAllKeys(IDBKeyRange.only(sessionId))); + for (const key of keys) { + store.delete(key); + } + await txDone(tx); + } finally { + db.close(); + } + } catch (err) { + console.warn("idbClearSession failed", err); + } +} + +export async function idbListSession( + sessionId: string, +): Promise { + try { + const db = await openDb(); + try { + const tx = db.transaction(STORE_NAME, "readonly"); + const index = tx.objectStore(STORE_NAME).index("sessionId"); + const rows = await idbReq(index.getAll(IDBKeyRange.only(sessionId))); + await txDone(tx); + return rows as PersistedByteEntry[]; + } finally { + db.close(); + } + } catch (err) { + console.warn("idbListSession failed", err); + return []; + } +} diff --git a/src/browser/byte-store.test.ts b/src/browser/byte-store.test.ts index 08d3c72..a87d2d3 100644 --- a/src/browser/byte-store.test.ts +++ b/src/browser/byte-store.test.ts @@ -26,7 +26,8 @@ describe("PdfByteStore", () => { const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF const record = store.put("doc_a" as DocumentId, bytes); expect(record.blobUrl).toBe("blob:stub-1"); - expect(store.get("doc_a" as DocumentId)?.bytes).toBe(bytes); + // Store keeps an owned copy so callers can free the source buffer. + expect(store.get("doc_a" as DocumentId)?.bytes).toEqual(bytes); expect(store.has("doc_a" as DocumentId)).toBe(true); expect(store.list()).toEqual(["doc_a"]); expect(store.size()).toBe(4); diff --git a/src/browser/byte-store.ts b/src/browser/byte-store.ts index 72d516a..4f1817c 100644 --- a/src/browser/byte-store.ts +++ b/src/browser/byte-store.ts @@ -1,23 +1,26 @@ /** - * `PdfByteStore` — in-memory store for uploaded PDF bytes, keyed by - * `DocumentId`. + * `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. * - * Uploaded PDFs are stored in memory only. Bytes survive within a tab - * session; reloading the page loses them unless the session was exported. - * - * One store instance per active session. The session-management layer is - * responsible for swapping the active store when the user switches sessions. - * The store also owns a small registry of issued `blob:` URLs so it can - * revoke them on delete/clear — no cross-cutting cleanup at the app layer. + * 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. */ @@ -34,10 +37,17 @@ export interface PdfByteStore { has(documentId: DocumentId): boolean; delete(documentId: DocumentId): boolean; list(): readonly DocumentId[]; - /** Revoke every blob URL and clear the store. */ + /** 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 { @@ -48,6 +58,13 @@ export interface CreatePdfByteStoreOptions { 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( @@ -71,23 +88,38 @@ export function createPdfByteStore( } }); + 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") { - // Replace previous record (revoking the prior URL) if any. - const prior = records.get(documentId); - if (prior) revokeUrl(prior.blobUrl); - // Cast: Blob() does accept Uint8Array at runtime, but TS narrows the - // buffer type to ArrayBufferLike (could be SharedArrayBuffer) and - // refuses without help. The bytes here always come from a fresh - // arrayBuffer() call, so a regular ArrayBuffer is guaranteed. - const blob = new Blob([bytes as unknown as ArrayBuffer], { - type: contentType, - }); - const blobUrl = createUrl(blob); - const record: PdfByteRecord = { bytes, blobUrl }; - records.set(documentId, record); + const record = putInMemory(documentId, bytes, contentType); + if (persist && sessionId) { + void idbPutBytes(sessionId, documentId, record.bytes, contentType); + } return record; }, get(documentId) { @@ -101,6 +133,9 @@ export function createPdfByteStore( if (!record) return false; revokeUrl(record.blobUrl); records.delete(documentId); + if (persist && sessionId) { + void idbDeleteBytes(sessionId, documentId); + } return true; }, list() { @@ -109,11 +144,37 @@ export function createPdfByteStore( 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; + }, }; } diff --git a/src/browser/index.ts b/src/browser/index.ts index 7a0d816..52d9752 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -14,6 +14,12 @@ export { type PdfByteRecord, type PdfByteStore, } from "./byte-store"; +export { + idbClearSession, + idbDeleteBytes, + idbListSession, + idbPutBytes, +} from "./byte-store-idb"; export { ingestHtmlFromFile, ingestMarkdownFromFile,