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
147
src/browser/byte-store-idb.ts
Normal file
147
src/browser/byte-store-idb.ts
Normal file
|
|
@ -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<IDBDatabase> {
|
||||||
|
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<T>(request: IDBRequest<T>): Promise<T> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<readonly PersistedByteEntry[]> {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,8 @@ describe("PdfByteStore", () => {
|
||||||
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF
|
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF
|
||||||
const record = store.put("doc_a" as DocumentId, bytes);
|
const record = store.put("doc_a" as DocumentId, bytes);
|
||||||
expect(record.blobUrl).toBe("blob:stub-1");
|
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.has("doc_a" as DocumentId)).toBe(true);
|
||||||
expect(store.list()).toEqual(["doc_a"]);
|
expect(store.list()).toEqual(["doc_a"]);
|
||||||
expect(store.size()).toBe(4);
|
expect(store.size()).toBe(4);
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,26 @@
|
||||||
/**
|
/**
|
||||||
* `PdfByteStore` — in-memory store for uploaded PDF bytes, keyed by
|
* `PdfByteStore` — store for uploaded PDF bytes, keyed by `DocumentId`.
|
||||||
* `DocumentId`.
|
|
||||||
*
|
*
|
||||||
* BROWSER SURFACE — not part of the headless ingest core. This mints and
|
* BROWSER SURFACE — not part of the headless ingest core. This mints and
|
||||||
* revokes `blob:` URLs and is meant to back an interactive upload/viewer
|
* revokes `blob:` URLs and is meant to back an interactive upload/viewer
|
||||||
* session. Headless callers should use `src/pdf/` directly and never import
|
* session. Headless callers should use `src/pdf/` directly and never import
|
||||||
* this module.
|
* this module.
|
||||||
*
|
*
|
||||||
* Uploaded PDFs are stored in memory only. Bytes survive within a tab
|
* When constructed with a `sessionId`, put/delete/clear also mirror into
|
||||||
* session; reloading the page loses them unless the session was exported.
|
* 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
|
||||||
* One store instance per active session. The session-management layer is
|
* into memory and re-mint blob URLs.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { DocumentId } from "@citation-evidence/engine/shared";
|
import type { DocumentId } from "@citation-evidence/engine/shared";
|
||||||
|
|
||||||
|
import {
|
||||||
|
idbClearSession,
|
||||||
|
idbDeleteBytes,
|
||||||
|
idbListSession,
|
||||||
|
idbPutBytes,
|
||||||
|
} from "./byte-store-idb";
|
||||||
|
|
||||||
export interface PdfByteRecord {
|
export interface PdfByteRecord {
|
||||||
readonly bytes: Uint8Array;
|
readonly bytes: Uint8Array;
|
||||||
/** A `blob:` URL the viewer can consume directly. */
|
/** A `blob:` URL the viewer can consume directly. */
|
||||||
|
|
@ -34,10 +37,17 @@ export interface PdfByteStore {
|
||||||
has(documentId: DocumentId): boolean;
|
has(documentId: DocumentId): boolean;
|
||||||
delete(documentId: DocumentId): boolean;
|
delete(documentId: DocumentId): boolean;
|
||||||
list(): readonly DocumentId[];
|
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;
|
clear(): void;
|
||||||
/** Total bytes currently held — useful for UI dashboards. */
|
/** Total bytes currently held — useful for UI dashboards. */
|
||||||
size(): number;
|
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 {
|
export interface CreatePdfByteStoreOptions {
|
||||||
|
|
@ -48,6 +58,13 @@ export interface CreatePdfByteStoreOptions {
|
||||||
readonly createObjectURL?: (blob: Blob) => string;
|
readonly createObjectURL?: (blob: Blob) => string;
|
||||||
/** Revoke a URL previously minted by `createObjectURL`. */
|
/** Revoke a URL previously minted by `createObjectURL`. */
|
||||||
readonly revokeObjectURL?: (url: string) => void;
|
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(
|
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>();
|
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 {
|
return {
|
||||||
put(documentId, bytes, contentType = "application/pdf") {
|
put(documentId, bytes, contentType = "application/pdf") {
|
||||||
// Replace previous record (revoking the prior URL) if any.
|
const record = putInMemory(documentId, bytes, contentType);
|
||||||
const prior = records.get(documentId);
|
if (persist && sessionId) {
|
||||||
if (prior) revokeUrl(prior.blobUrl);
|
void idbPutBytes(sessionId, documentId, record.bytes, contentType);
|
||||||
// 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);
|
|
||||||
return record;
|
return record;
|
||||||
},
|
},
|
||||||
get(documentId) {
|
get(documentId) {
|
||||||
|
|
@ -101,6 +133,9 @@ export function createPdfByteStore(
|
||||||
if (!record) return false;
|
if (!record) return false;
|
||||||
revokeUrl(record.blobUrl);
|
revokeUrl(record.blobUrl);
|
||||||
records.delete(documentId);
|
records.delete(documentId);
|
||||||
|
if (persist && sessionId) {
|
||||||
|
void idbDeleteBytes(sessionId, documentId);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
list() {
|
list() {
|
||||||
|
|
@ -109,11 +144,37 @@ export function createPdfByteStore(
|
||||||
clear() {
|
clear() {
|
||||||
for (const record of records.values()) revokeUrl(record.blobUrl);
|
for (const record of records.values()) revokeUrl(record.blobUrl);
|
||||||
records.clear();
|
records.clear();
|
||||||
|
if (persist && sessionId) {
|
||||||
|
void idbClearSession(sessionId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
size() {
|
size() {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const r of records.values()) total += r.bytes.byteLength;
|
for (const r of records.values()) total += r.bytes.byteLength;
|
||||||
return total;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ export {
|
||||||
type PdfByteRecord,
|
type PdfByteRecord,
|
||||||
type PdfByteStore,
|
type PdfByteStore,
|
||||||
} from "./byte-store";
|
} from "./byte-store";
|
||||||
|
export {
|
||||||
|
idbClearSession,
|
||||||
|
idbDeleteBytes,
|
||||||
|
idbListSession,
|
||||||
|
idbPutBytes,
|
||||||
|
} from "./byte-store-idb";
|
||||||
export {
|
export {
|
||||||
ingestHtmlFromFile,
|
ingestHtmlFromFile,
|
||||||
ingestMarkdownFromFile,
|
ingestMarkdownFromFile,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue