151 lines
11 KiB
TypeScript
151 lines
11 KiB
TypeScript
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<void> };
|
|
export type StoragePort = {
|
|
claim(scope: StorageScope): Promise<StorageLeader>;
|
|
recover(leader: StorageLeader): Promise<RecoveryImage | null>;
|
|
commit(leader: StorageLeader, batch: DurableBatch): Promise<{ readonly durableRootRevision: number }>;
|
|
readFile(leader: StorageLeader, id: string): Promise<Blob | undefined>;
|
|
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 = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
|
|
const transactionDone = (transaction: IDBTransaction) => new Promise<void>((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<StorageLeader>();
|
|
const opening = new Promise<IDBDatabase>((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<T>(leader: StorageLeader, stores: string[], mode: IDBTransactionMode, run: (transaction: IDBTransaction, metadata: Metadata, key: string) => Promise<T>): Promise<T> {
|
|
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<Metadata | undefined>(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<void>(resolve => { unlock = resolve; });
|
|
const result = new Promise<StorageLeader>((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<Metadata | undefined>(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<StoredImage | undefined>(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<StoredImage[]>(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<StoredImage>(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<StoredImage>(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<Blob | undefined>(transaction.objectStore('files').get([key, id]))); },
|
|
close() { closed = true; for (const handle of handles) void handle.release(); void opening.then(database => database.close()).catch(() => {}); },
|
|
});
|
|
}
|