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.
This commit is contained in:
parent
bf524dfccc
commit
b3eaa4b406
4 changed files with 239 additions and 24 deletions
|
|
@ -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<void>;
|
||||
/** 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<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") {
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue