import type { Json } from '../canvas-core/types.ts'; import { clone, equal, fault, validJson } from '../canvas-core/values.ts'; export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string }; export type RecoveryImage = { readonly formatVersion: 1 | 2; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json }; export type DurableBatch = { readonly image: RecoveryImage; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] }; export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise }; export type StoragePort = { claim(scope: StorageScope): Promise; recover(leader: StorageLeader): Promise; commit(leader: StorageLeader, batch: DurableBatch): Promise<{ readonly durableRootRevision: number }>; readFile(leader: StorageLeader, id: string): Promise; close(): void; }; type Metadata = { fence: number; throughRevision?: number; checkpointRevision?: number }; type StoredImage = { readonly image: RecoveryImage; readonly checksum: string }; async function checksum(image: RecoveryImage) { const bytes = new TextEncoder().encode(JSON.stringify(image)); return [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(value => value.toString(16).padStart(2, '0')).join(''); } const keyFor = (scope: StorageScope) => { if (![scope.accountId, scope.workspaceId, scope.projectId].every(value => typeof value === 'string' && value.length > 0)) throw fault('invalid_storage_scope'); return JSON.stringify([scope.accountId, scope.workspaceId, scope.projectId]); }; function validateImage(image: RecoveryImage) { if (![1,2].includes(image.formatVersion)) throw fault('unsupported_recovery_version'); if (!Number.isSafeInteger(image.rootRevision) || image.rootRevision < 0 || !Number.isSafeInteger(image.localRevision) || image.localRevision < 0 || image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image'); } const requestValue = (request: IDBRequest) => new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const transactionDone = (transaction: IDBTransaction) => new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onabort = () => reject(transaction.error ?? fault('storage_aborted')); transaction.onerror = () => { /* onabort is the terminal barrier. */ }; }); /** Single-origin browser adapter. Web Locks serialize tabs; a DB fence rejects stale handles. */ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: LockManager; name?: string; checkpointEvery?: number } = {}): StoragePort { const factory = options.indexedDB ?? globalThis.indexedDB, locks = options.locks ?? globalThis.navigator?.locks; if (!factory || !locks) throw fault('persistent_storage_unavailable'); const checkpointEvery = options.checkpointEvery ?? 32; if (!Number.isSafeInteger(checkpointEvery) || checkpointEvery < 1) throw fault('invalid_checkpoint_interval'); let closed = false; const handles = new Set(); const opening = new Promise((resolve, reject) => { const request = factory.open(options.name ?? 'endoleap-headless', 1); request.onupgradeneeded = () => { for (const name of ['metadata', 'checkpoints', 'journal', 'files']) if (!request.result.objectStoreNames.contains(name)) request.result.createObjectStore(name); }; request.onerror = () => reject(request.error); request.onblocked = () => reject(fault('storage_upgrade_blocked')); request.onsuccess = () => { if (closed) { request.result.close(); reject(fault('storage_closed')); return; } request.result.onversionchange = () => { closed = true; request.result.close(); for (const handle of handles) void handle.release(); }; resolve(request.result); }; }); // Opening failures are observable through operations without an unhandled background rejection. void opening.catch(() => {}); function check(leader: StorageLeader) { if (closed) throw fault('storage_closed'); if (!handles.has(leader) || !leader.active) throw fault('storage_leader_lost'); return keyFor(leader.scope); } async function execute(leader: StorageLeader, stores: string[], mode: IDBTransactionMode, run: (transaction: IDBTransaction, metadata: Metadata, key: string) => Promise): Promise { const key = check(leader), database = await opening; check(leader); const transaction = database.transaction([...new Set(['metadata', ...stores])], mode), done = transactionDone(transaction); try { const metadata = await requestValue(transaction.objectStore('metadata').get(key)); if (!metadata || metadata.fence !== leader.fence) throw fault('storage_fence_lost'); check(leader); const result = await run(transaction, metadata, key); await done; // Never resolve durability on an individual put callback. return result; } catch (error) { try { transaction.abort(); } catch { /* Already terminal. */ } await done.catch(() => {}); throw error; } } return Object.freeze({ async claim(scope: StorageScope) { const key = keyFor(scope); if (closed) throw fault('storage_closed'); let unlock!: () => void, acquired!: (leader: StorageLeader) => void, failed!: (error: unknown) => void; const released = new Promise(resolve => { unlock = resolve; }); const result = new Promise((resolve, reject) => { acquired = resolve; failed = reject; }); const held = locks.request(`endoleap-headless:${options.name ?? 'endoleap-headless'}:${key}`, { mode: 'exclusive', ifAvailable: true }, async lock => { if (!lock) throw fault('storage_scope_in_use'); const database = await opening; if (closed) throw fault('storage_closed'); const transaction = database.transaction('metadata', 'readwrite'), done = transactionDone(transaction); try { const store = transaction.objectStore('metadata'), previous = await requestValue(store.get(key)); const fence = (previous?.fence ?? 0) + 1; if (!Number.isSafeInteger(fence)) throw fault('storage_fence_exhausted'); store.put({ ...previous, fence }, key); await done; let active = true; const handle: StorageLeader = Object.freeze({ scope: Object.freeze({ ...scope }), fence, get active() { return active; }, async release() { if (!active) return; active = false; handles.delete(handle); unlock(); await held; } }); handles.add(handle); acquired(handle); await released; } catch (error) { try { transaction.abort(); } catch { /* Already terminal. */ } await done.catch(() => {}); throw error; } }); void held.catch(failed); return result; }, async recover(leader: StorageLeader) { const images = await execute(leader, ['checkpoints', 'journal'], 'readonly', async (transaction, metadata, key) => { if (metadata.throughRevision === undefined) return []; if (metadata.checkpointRevision === undefined) throw fault('recovery_checkpoint_missing'); const checkpoint = await requestValue(transaction.objectStore('checkpoints').get([key, metadata.checkpointRevision])); if (!checkpoint?.image) throw fault('recovery_checkpoint_missing'); let image = checkpoint.image; validateImage(image); if (image.rootRevision !== metadata.checkpointRevision) throw fault('corrupt_recovery_image'); const journal = metadata.checkpointRevision === metadata.throughRevision ? [] : await requestValue(transaction.objectStore('journal').getAll(IDBKeyRange.bound([key, metadata.checkpointRevision + 1], [key, metadata.throughRevision]))); for (const next of journal) { validateImage(next.image); if (next.image.rootRevision !== image.rootRevision + 1 || next.image.localRevision < image.localRevision) throw fault('recovery_journal_gap'); image = next.image; } if (image.rootRevision !== metadata.throughRevision) throw fault('recovery_journal_gap'); return [checkpoint, ...journal]; }); // Hashing must run after the IDB transaction, which would otherwise auto-close while awaiting it. for (const stored of images) if (stored.checksum !== await checksum(stored.image)) throw fault('recovery_checksum_mismatch'); check(leader); return images.at(-1)?.image ?? null; }, async commit(leader: StorageLeader, batch: DurableBatch) { // Copy before awaiting: callers cannot change the durable cut while a transaction queues. const image = clone(batch.image), files = [...(batch.files ?? [])]; validateImage(image); const stored: StoredImage = { image, checksum: await checksum(image) }; return execute(leader, ['checkpoints', 'journal', 'files'], 'readwrite', async (transaction, metadata, key) => { const previous = metadata.throughRevision; if (previous !== undefined && image.rootRevision !== previous + 1) { if (image.rootRevision !== previous) throw fault('storage_revision_gap'); const saved = await requestValue(transaction.objectStore(metadata.checkpointRevision === previous ? 'checkpoints' : 'journal').get([key, previous])); if (!equal(saved, stored) || files.length) throw fault('storage_revision_conflict'); return { durableRootRevision: previous }; } if (previous !== undefined && image.localRevision < (await requestValue(transaction.objectStore(metadata.checkpointRevision === previous ? 'checkpoints' : 'journal').get([key, previous]))).image.localRevision) throw fault('storage_revision_conflict'); for (const file of files) { if (!file.id || !(file.bytes instanceof Blob)) throw fault('invalid_staged_file'); // Immutable resource identity: replacing bytes requires a new resource ID. if (await requestValue(transaction.objectStore('files').getKey([key, file.id]))) throw fault('resource_identity_conflict', [file.id]); transaction.objectStore('files').put(file.bytes, [key, file.id]); } if (previous === undefined || image.rootRevision - (metadata.checkpointRevision ?? 0) >= checkpointEvery) { transaction.objectStore('checkpoints').put(stored, [key, image.rootRevision]); if (metadata.checkpointRevision !== undefined) transaction.objectStore('checkpoints').delete([key, metadata.checkpointRevision]); transaction.objectStore('journal').delete(IDBKeyRange.bound([key, 0], [key, image.rootRevision])); metadata.checkpointRevision = image.rootRevision; } else transaction.objectStore('journal').put(stored, [key, image.rootRevision]); transaction.objectStore('metadata').put({ ...metadata, throughRevision: image.rootRevision }, key); return { durableRootRevision: image.rootRevision }; }); }, readFile(leader: StorageLeader, id: string) { return execute(leader, ['files'], 'readonly', (transaction, _metadata, key) => requestValue(transaction.objectStore('files').get([key, id]))); }, close() { closed = true; for (const handle of handles) void handle.release(); void opening.then(database => database.close()).catch(() => {}); }, }); }