import { parseWorkspace, type Workspace } from "./workspace"; export type SavedWorkspace = { revision: string | null; workspace: Workspace | null; }; type RecordValue = { revision: string; payload: string }; export class WorkspaceConflict extends Error { constructor() { super( "Newer work was saved in another tab. This tab has not overwritten it." ); } } export function workspaceContent(workspace: Workspace) { const { savedAt: _savedAt, ...content } = workspace; return JSON.stringify(content, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries( Object.entries(value).sort(([a], [b]) => a.localeCompare(b)) ) : value ); } export function nextRecord( current: RecordValue | undefined, workspace: Workspace, expected: string | null ): RecordValue { if ((current?.revision ?? null) !== expected) throw new WorkspaceConflict(); if ( current && workspaceContent(parseWorkspace(current.payload)) === workspaceContent(workspace) ) return current; // Validate before committing; never replace a good saved copy with unreadable data. const clean = parseWorkspace(JSON.stringify(workspace)); return { revision: crypto.randomUUID(), payload: JSON.stringify(clean) }; } // IndexedDB read/write transactions serialize competing tabs. A stale revision // cannot replace newer work, even if both tabs save at the same instant. export class WorkspaceStore { private database: Promise | null = null; constructor(private name = "marmar-context-workspaces") {} private open() { if (!this.database) { this.database = new Promise((resolve, reject) => { const request = indexedDB.open(this.name, 1); request.onupgradeneeded = () => request.result.createObjectStore("workspace"); request.onerror = () => reject(request.error); request.onblocked = () => reject( new Error( "Browser storage is busy in another tab. Close the older tab and retry saving." ) ); request.onsuccess = () => { const db = request.result; db.onversionchange = () => { db.close(); this.database = null; }; resolve(db); }; }).catch((error) => { this.database = null; throw error; }); } return this.database; } async read(): Promise { const db = await this.open(); return new Promise((resolve, reject) => { const tx = db.transaction("workspace", "readonly"); const request = tx.objectStore("workspace").get("current"); tx.oncomplete = () => { try { const value = request.result as RecordValue | undefined; resolve( value ? { revision: value.revision, workspace: parseWorkspace(value.payload), } : { revision: null, workspace: null } ); } catch (e) { reject(e); } }; tx.onabort = () => reject(tx.error ?? new Error("Browser storage could not be read.")); tx.onerror = () => reject(tx.error); }); } async save(workspace: Workspace, expected: string | null): Promise { const db = await this.open(); return new Promise((resolve, reject) => { const tx = db.transaction("workspace", "readwrite", { durability: "strict", }); const store = tx.objectStore("workspace"); const request = store.get("current"); let result: RecordValue; let failure: unknown; request.onsuccess = () => { try { result = nextRecord(request.result, workspace, expected); if (result !== request.result) store.put(result, "current"); } catch (e) { failure = e; tx.abort(); } }; tx.oncomplete = () => resolve(result.revision); tx.onabort = () => reject( failure ?? tx.error ?? new Error("Browser save was interrupted.") ); tx.onerror = () => { failure ??= tx.error; }; }); } }