import { callAction } from "@agent-native/core/client/hooks"; import { sourceContentHash } from "@shared/source-workspace"; export type DesignSaveActionName = | "update-file" | "update-design" | "apply-tweaks"; export interface DesignSaveOutboxEntry { key: string; designId: string; /** Prevents a later signed-in user on the same browser from replaying it. */ actorScope: string; actionName: DesignSaveActionName; resourceId: string; operationSource: string; operationRevision: number; payload: Record; updatedAt: number; } export interface DesignSaveOutboxStorage { putLatest(entry: DesignSaveOutboxEntry): Promise; deleteIfRevision(entry: DesignSaveOutboxEntry): Promise; list(designId: string, actorScope: string): Promise; pruneOlderThan(updatedAt: number): Promise; } export interface DrainDesignSaveOutboxResult { saved: DesignSaveOutboxEntry[]; failed: Array<{ entry: DesignSaveOutboxEntry; error: unknown }>; /** * Entries dropped because retrying can never succeed — the target file no * longer exists (deleted or never created). Retained-and-retried forever they * turn one orphaned screen into a 500 storm that jams every save; dropping * them self-heals stale outbox residue. Separate from `failed`, which is for * transient/conflict errors that SHOULD be retried. */ dropped: Array<{ entry: DesignSaveOutboxEntry; error: unknown }>; /** * Entries dropped because the server moved past their base version (a 409 * conflict). Distinct from `dropped`: the file still exists and nothing was * lost to deletion, so the editor should rebase from the server rather than * warn "changes discarded". Kept separate so a normal concurrent edit is * never presented as a deleted file. */ rebased: Array<{ entry: DesignSaveOutboxEntry; error: unknown }>; } /** * A save failure that can never succeed on retry: the server reports the target * file is gone (HTTP 404, or a "File not found" message from update-file's * missing-row guard). Distinct from 409 conflicts and network errors, which are * transient and must stay queued. */ export function isTerminalSaveError(error: unknown): boolean { if (!error || typeof error !== "object") return false; const candidate = error as { status?: unknown; message?: unknown }; // Terminal only when an explicit 404 ALSO names a missing file. A bare 404 can // be a transient route-not-found (e.g. a cold-start action route), so both // signals are required — dropping an edit is unrecoverable, a retry is not. return ( candidate.status === 404 && typeof candidate.message === "string" && /file not found/i.test(candidate.message) ); } /** * The server's update-file version conflict ("File changed since it was read…"). * Its frozen expectedVersionHash can never match on retry, so drop-and-rebase * rather than loop forever. Matched by MESSAGE, not bare status 409, on purpose: * the client-side "no known base version" / "changed elsewhere" 409 synthetics * are intentionally retained by drainEntries, and the client-build-mismatch 409 * is a reload-then-retry. */ export function isConflictSaveError(error: unknown): boolean { if (!error || typeof error !== "object") return false; const candidate = error as { code?: unknown; message?: unknown }; if (candidate.code === "client_build_mismatch") return false; return ( typeof candidate.message === "string" && /changed since it was read|re-read the file/i.test(candidate.message) ); } const DATABASE_NAME = "agent-native-design-save-outbox"; const DATABASE_VERSION = 2; const ENTRY_STORE = "entries"; const DESIGN_ID_INDEX = "by-design-id"; const UPDATED_AT_INDEX = "by-updated-at"; /** * Failed/conflicted writes remain available for a month, but abandoned * browser-tab sessions must not retain full HTML documents forever. */ export const DESIGN_SAVE_OUTBOX_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000; let databasePromise: Promise | null = null; function openDatabase(): Promise { if (typeof indexedDB === "undefined") { return Promise.reject( new Error("IndexedDB is unavailable; design changes cannot be journaled"), ); } if (databasePromise) return databasePromise; databasePromise = new Promise((resolve, reject) => { const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); request.onupgradeneeded = () => { const database = request.result; const store = database.objectStoreNames.contains(ENTRY_STORE) ? request.transaction?.objectStore(ENTRY_STORE) : database.createObjectStore(ENTRY_STORE, { keyPath: "key" }); if (store && !store.indexNames.contains(DESIGN_ID_INDEX)) { store.createIndex(DESIGN_ID_INDEX, "designId", { unique: false }); } if (store && !store.indexNames.contains(UPDATED_AT_INDEX)) { store.createIndex(UPDATED_AT_INDEX, "updatedAt", { unique: false }); } }; request.onsuccess = () => { const database = request.result; database.onversionchange = () => { database.close(); databasePromise = null; }; resolve(database); }; request.onerror = () => { databasePromise = null; reject( request.error ?? new Error("Failed to open the design save outbox"), ); }; request.onblocked = () => { databasePromise = null; reject(new Error("The design save outbox database upgrade was blocked")); }; }); return databasePromise; } function transactionError( transaction: IDBTransaction, fallback: string, ): Error { return transaction.error ?? new Error(fallback); } const indexedDbStorage: DesignSaveOutboxStorage = { async putLatest(entry) { const database = await openDatabase(); await new Promise((resolve, reject) => { const transaction = database.transaction(ENTRY_STORE, "readwrite"); const store = transaction.objectStore(ENTRY_STORE); const request = store.get(entry.key); request.onsuccess = () => { const current = request.result as DesignSaveOutboxEntry | undefined; const shouldReplace = !current || entry.operationRevision > current.operationRevision || (entry.operationRevision === current.operationRevision && entry.updatedAt >= current.updatedAt); if (shouldReplace) store.put(entry); }; request.onerror = () => transaction.abort(); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject( transactionError(transaction, "Failed to journal design changes"), ); transaction.onabort = () => reject( transactionError(transaction, "Failed to journal design changes"), ); }); }, async deleteIfRevision(entry) { const database = await openDatabase(); return await new Promise((resolve, reject) => { const transaction = database.transaction(ENTRY_STORE, "readwrite"); const store = transaction.objectStore(ENTRY_STORE); const request = store.get(entry.key); let deleted = false; request.onsuccess = () => { const current = request.result as DesignSaveOutboxEntry | undefined; if ( current?.operationSource === entry.operationSource && current.operationRevision === entry.operationRevision ) { store.delete(entry.key); deleted = true; } }; request.onerror = () => transaction.abort(); transaction.oncomplete = () => resolve(deleted); transaction.onerror = () => reject( transactionError(transaction, "Failed to acknowledge design changes"), ); transaction.onabort = () => reject( transactionError(transaction, "Failed to acknowledge design changes"), ); }); }, async list(designId, actorScope) { const database = await openDatabase(); return await new Promise((resolve, reject) => { const transaction = database.transaction(ENTRY_STORE, "readonly"); const request = transaction .objectStore(ENTRY_STORE) .index(DESIGN_ID_INDEX) .getAll(designId); let entries: DesignSaveOutboxEntry[] = []; request.onsuccess = () => { entries = (request.result as DesignSaveOutboxEntry[]) .filter((entry) => entry.actorScope === actorScope) .sort((left, right) => left.updatedAt - right.updatedAt); }; request.onerror = () => transaction.abort(); transaction.oncomplete = () => resolve(entries); transaction.onerror = () => reject(transactionError(transaction, "Failed to read design changes")); transaction.onabort = () => reject(transactionError(transaction, "Failed to read design changes")); }); }, async pruneOlderThan(updatedAt) { const database = await openDatabase(); return await new Promise((resolve, reject) => { const transaction = database.transaction(ENTRY_STORE, "readwrite"); const request = transaction .objectStore(ENTRY_STORE) .index(UPDATED_AT_INDEX) .openCursor(IDBKeyRange.upperBound(updatedAt, true)); let pruned = 0; request.onsuccess = () => { const cursor = request.result; if (!cursor) return; cursor.delete(); pruned += 1; cursor.continue(); }; request.onerror = () => transaction.abort(); transaction.oncomplete = () => resolve(pruned); transaction.onerror = () => reject( transactionError(transaction, "Failed to prune old design changes"), ); transaction.onabort = () => reject( transactionError(transaction, "Failed to prune old design changes"), ); }); }, }; export function designSaveOutboxKey(input: { designId: string; actorScope: string; actionName: DesignSaveActionName; resourceId: string; operationSource: string; }): string { return [ input.designId, input.actorScope, input.actionName, input.resourceId, input.operationSource, ] .map(encodeURIComponent) .join(":"); } export function createDesignSaveOutboxEntry(input: { designId: string; actorScope: string; actionName: DesignSaveActionName; resourceId: string; operationSource: string; operationRevision: number; payload: Record; updatedAt?: number; }): DesignSaveOutboxEntry { return { ...input, key: designSaveOutboxKey(input), updatedAt: input.updatedAt ?? Date.now(), }; } export async function journalDesignSaveOutboxEntry( entry: DesignSaveOutboxEntry, storage: DesignSaveOutboxStorage = indexedDbStorage, ): Promise { await storage.putLatest(entry); } export async function acknowledgeDesignSaveOutboxEntry( entry: DesignSaveOutboxEntry, storage: DesignSaveOutboxStorage = indexedDbStorage, ): Promise { return await storage.deleteIfRevision(entry); } export async function discardDesignSaveOutboxEntry( entry: DesignSaveOutboxEntry, storage: DesignSaveOutboxStorage = indexedDbStorage, ): Promise { return await storage.deleteIfRevision(entry); } /** A versioned update-file no-op is safe to acknowledge only when the server * proves the exact requested content is already persisted. A higher revision * from the same source also reports skippedStaleOperation, but its version hash * belongs to different content and must leave this entry conflict-retained. */ export function updateFileResultPersistedContent( actionResult: unknown, expectedContent: string, ): boolean { if (!actionResult || typeof actionResult !== "object") return true; const result = actionResult as { skippedStaleMirror?: unknown; skippedStaleOperation?: unknown; versionHash?: unknown; }; if (result.skippedStaleMirror) return false; if (!result.skippedStaleOperation) return true; return result.versionHash === sourceContentHash(expectedContent); } async function drainEntries( designId: string, actorScope: string, invokeAction: ( actionName: DesignSaveActionName, payload: Record, ) => Promise, storage: DesignSaveOutboxStorage, ): Promise { const result: DrainDesignSaveOutboxResult = { saved: [], failed: [], dropped: [], rebased: [], }; for (const entry of await storage.list(designId, actorScope)) { try { if ( entry.actionName === "update-file" && entry.payload.syncCollab === false && typeof entry.payload.expectedVersionHash !== "string" ) { const conflict = new Error( "A live-collaboration mirror cannot be replayed without a known base version", ); (conflict as Error & { status?: number }).status = 409; throw conflict; } if ( entry.actionName === "apply-tweaks" && typeof entry.payload.expectedSelectionsHash !== "string" ) { const conflict = new Error( "A full tweak snapshot cannot be replayed without a known base version", ); (conflict as Error & { status?: number }).status = 409; throw conflict; } const actionResult = await invokeAction(entry.actionName, entry.payload); if ( entry.actionName === "update-file" && typeof entry.payload.content === "string" && !updateFileResultPersistedContent(actionResult, entry.payload.content) ) { const conflict = new Error( "The saved file changed elsewhere before this edit could be replayed", ); (conflict as Error & { status?: number }).status = 409; throw conflict; } await storage.deleteIfRevision(entry); result.saved.push(entry); } catch (error) { if (isTerminalSaveError(error)) { // The target file is gone (deleted/never created) — retrying can never // succeed. Drop the entry so one orphaned screen can't loop update-file // 500s forever and jam every save. Logged, never silently swallowed. await storage.deleteIfRevision(entry); result.dropped.push({ entry, error }); if (typeof console !== "undefined") { console.warn( `[design-save-outbox] dropped unrecoverable save for ${entry.actionName} ${entry.resourceId} (file no longer exists)`, ); } } else if (isConflictSaveError(error)) { // Base version superseded — retry is futile and replaying the stale // snapshot would clobber newer content. Drop into `rebased` (NOT // `dropped`) so the editor rebases from the server instead of warning // that the file was discarded/deleted. await storage.deleteIfRevision(entry); result.rebased.push({ entry, error }); if (typeof console !== "undefined") { console.warn( `[design-save-outbox] rebased superseded save for ${entry.actionName} ${entry.resourceId} (server content moved on)`, ); } } else { result.failed.push({ entry, error }); } } } return result; } export async function drainDesignSaveOutbox(options: { designId: string; actorScope: string; invokeAction?: ( actionName: DesignSaveActionName, payload: Record, ) => Promise; storage?: DesignSaveOutboxStorage; }): Promise { const invokeAction = options.invokeAction ?? ((actionName: DesignSaveActionName, payload: Record) => ( callAction as ( name: string, params: Record, ) => Promise )(actionName, payload)); const storage = options.storage ?? indexedDbStorage; await storage.pruneOlderThan(Date.now() - DESIGN_SAVE_OUTBOX_RETENTION_MS); const run = () => drainEntries(options.designId, options.actorScope, invokeAction, storage); const lockManager = typeof navigator === "undefined" ? undefined : ( navigator as Navigator & { locks?: { request(name: string, callback: () => Promise): Promise; }; } ).locks; if (!lockManager) return await run(); return await lockManager.request( `agent-native-design-save-outbox:${options.designId}`, run, ); }