evidence-source/src/browser/byte-store.ts
tegwick b3eaa4b406
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
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.
2026-07-30 20:05:24 +02:00

180 lines
5.5 KiB
TypeScript

/**
* `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<void>;
/** 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<DocumentId, PdfByteRecord>();
let hydrated = !persist;
let hydratePromise: Promise<void> | 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;
},
};
}