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 [];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue