From e342100d5a87ac0c8d4ba234b1a80b223daaaf36 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 13 Apr 2026 15:51:58 -0400 Subject: [PATCH] fix: make server authoritative on initial sync to prevent stale IndexedDB from hiding R2 data - Server always overwrites local IndexedDB on initial page load (was only when server had more shapes) - Prune local-only shapes not on server (stale deletions stuck in IndexedDB) - Increase sync timeout from 5s to 15s (DO cold starts can exceed 5s) Co-Authored-By: Claude Opus 4.6 --- src/automerge/useAutomergeSyncRepo.ts | 49 ++++++++++++++------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/automerge/useAutomergeSyncRepo.ts b/src/automerge/useAutomergeSyncRepo.ts index 1dbf9d6..2569d5e 100644 --- a/src/automerge/useAutomergeSyncRepo.ts +++ b/src/automerge/useAutomergeSyncRepo.ts @@ -445,7 +445,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus // Wait for network adapter with a timeout const networkReadyPromise = adapter.whenReady() const timeoutPromise = new Promise<'timeout'>((resolve) => - setTimeout(() => resolve('timeout'), 5000) + setTimeout(() => resolve('timeout'), 15000) ) const result = await Promise.race([networkReadyPromise, timeoutPromise]) @@ -500,39 +500,42 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus const localShapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length const localIsEmpty = Object.keys(doc.store).length === 0 - // IMPROVED: Server is source of truth on initial load - // Prefer server if: - // - Local is empty (first load or cleared cache) - // - Server has more shapes (local is likely stale/incomplete) - // - Local has shapes but server has different/more content - const serverHasMoreContent = serverShapeCount > localShapeCount - const shouldPreferServer = localIsEmpty || localShapeCount === 0 || serverHasMoreContent + // Server is ALWAYS authoritative on initial page load. + // Previous logic only preferred server when it had more shapes, + // but that's flawed: local IndexedDB can accumulate stale/deleted + // shapes, keeping its count artificially high and preventing + // server data from ever overwriting the stale cache. + // After initial sync, ongoing CRDT WebSocket sync handles changes. let addedFromServer = 0 let updatedFromServer = 0 - let keptLocal = 0 + // Apply all server records (add new, overwrite existing) Object.entries(serverDoc.store).forEach(([id, record]) => { const existsLocally = !!doc.store[id] - - if (!existsLocally) { - // Record doesn't exist locally - add from server - doc.store[id] = record - addedFromServer++ - } else if (shouldPreferServer) { - // Record exists locally but server has more content - update with server version - // This handles stale IndexedDB cache scenarios - doc.store[id] = record + doc.store[id] = record + if (existsLocally) { updatedFromServer++ } else { - // Local has equal or more content - keep local version - // Local changes will sync to server via normal CRDT mechanism - keptLocal++ + addedFromServer++ } }) - totalMerged = addedFromServer + updatedFromServer - console.log(`🔄 Server sync: added=${addedFromServer}, updated=${updatedFromServer}, keptLocal=${keptLocal}, serverShapes=${serverShapeCount}, localShapes=${localShapeCount}, preferServer=${shouldPreferServer}`) + // Remove local-only shapes that don't exist on server + // (they were deleted on server but still in stale IndexedDB) + let removedStale = 0 + if (!localIsEmpty) { + const serverIds = new Set(Object.keys(serverDoc.store)) + for (const id of Object.keys(doc.store)) { + if (!serverIds.has(id) && doc.store[id]?.typeName === 'shape') { + delete doc.store[id] + removedStale++ + } + } + } + + totalMerged = addedFromServer + updatedFromServer + removedStale + console.log(`🔄 Server sync: added=${addedFromServer}, updated=${updatedFromServer}, removedStale=${removedStale}, serverShapes=${serverShapeCount}, localShapes=${localShapeCount}`) }) const finalDoc = handle.doc()