diff --git a/src/app/document/recovery/controller.ts b/src/app/document/recovery/controller.ts index 3b9004901..3c36cb8b3 100644 --- a/src/app/document/recovery/controller.ts +++ b/src/app/document/recovery/controller.ts @@ -19,7 +19,7 @@ interface DocumentRecoveryOptions { export interface DocumentRecoveryController { getRecoveryId(): string - adoptRecoverySnapshot(id: string, sceneVersion: number): void + adoptRecoverySnapshot(id: string, sceneVersion: number): Promise persistNow(): Promise markProtectedVersion(version: number): Promise discardRecovery(): Promise @@ -89,13 +89,15 @@ export function createDocumentRecovery({ return { getRecoveryId: () => id, - adoptRecoverySnapshot(nextId, sceneVersion) { - lifecycleGeneration++ + async adoptRecoverySnapshot(nextId, sceneVersion) { + const previousId = id + await invalidateActiveWrite() id = nextId protectedVersion = sceneVersion persistedVersion = sceneVersion requestedVersion = sceneVersion disposed = false + if (previousId !== nextId) await store.remove(previousId) }, persistNow, async markProtectedVersion(version) { diff --git a/src/app/document/recovery/store.ts b/src/app/document/recovery/store.ts index 956252325..3ce7e318b 100644 --- a/src/app/document/recovery/store.ts +++ b/src/app/document/recovery/store.ts @@ -18,16 +18,57 @@ function warnMemoryFallback(error?: unknown): void { function createResilientRecoveryStore(primary: RecoveryStore): RecoveryStore { let current = primary + let queue = Promise.resolve() - async function run(operation: (store: RecoveryStore) => Promise): Promise { + function serialized(operation: () => Promise): Promise { + const result = queue.then(operation, operation) + queue = result.then( + () => undefined, + () => undefined + ) + return result + } + + async function switchToMemory(error: unknown): Promise { + if (current !== primary) return current + warnMemoryFallback(error) + const memory = createMemoryRecoveryStore() try { - return await operation(current) - } catch (error) { - if (current !== primary) throw error - warnMemoryFallback(error) - current = createMemoryRecoveryStore() - return operation(current) + const snapshots = await primary.list() + for (const metadata of snapshots) { + const snapshot = await primary.read(metadata.id) + if (snapshot) await memory.write(snapshot) + } + } catch (migrationError) { + console.warn('[Recovery] Failed to migrate IndexedDB snapshots to memory:', migrationError) } + current = memory + return memory + } + + function run(operation: (store: RecoveryStore) => Promise): Promise { + return serialized(async () => { + try { + return await operation(current) + } catch (error) { + if (current !== primary) throw error + return operation(await switchToMemory(error)) + } + }) + } + + function removeFromAll(id: string): Promise { + return serialized(async () => { + await primary.remove(id) + if (current !== primary) await current.remove(id) + }) + } + + function clearAll(): Promise { + return serialized(async () => { + await primary.clear() + if (current !== primary) await current.clear() + }) } return { @@ -35,8 +76,8 @@ function createResilientRecoveryStore(primary: RecoveryStore): RecoveryStore { read: (id: string): Promise => run((store) => store.read(id)), write: (input: RecoverySnapshotInput): Promise => run((store) => store.write(input)), - remove: (id: string): Promise => run((store) => store.remove(id)), - clear: (): Promise => run((store) => store.clear()) + remove: removeFromAll, + clear: clearAll } } diff --git a/src/app/shell/keyboard/use.ts b/src/app/shell/keyboard/use.ts index 38abea9c5..a223f8bfa 100644 --- a/src/app/shell/keyboard/use.ts +++ b/src/app/shell/keyboard/use.ts @@ -41,7 +41,7 @@ export function useKeyboard() { void openFileDialog() }, closeActiveTab: () => { - if (activeTabRef.value) closeTab(activeTabRef.value.id) + if (activeTabRef.value) void closeTab(activeTabRef.value.id) }, createTab: () => createTab() }) diff --git a/src/app/shell/menu/app-menu.ts b/src/app/shell/menu/app-menu.ts index 916faec1c..8f9c8970a 100644 --- a/src/app/shell/menu/app-menu.ts +++ b/src/app/shell/menu/app-menu.ts @@ -104,7 +104,7 @@ export function useAppMenu() { 'export-selection': () => exportSelection('png'), ...createSelectionMenuActions(store), close: () => { - if (activeTab.value) closeTab(activeTab.value.id) + if (activeTab.value) void closeTab(activeTab.value.id) }, settings: openSettingsDialog, 'export-png': () => exportSelection('png'), diff --git a/src/app/shell/menu/editor.ts b/src/app/shell/menu/editor.ts index d73cf60c3..7e2c7697b 100644 --- a/src/app/shell/menu/editor.ts +++ b/src/app/shell/menu/editor.ts @@ -34,7 +34,7 @@ export function useEditorMenu() { new: () => createTab(), open: () => void openFileDialog(), close: () => { - if (activeTab.value) closeTab(activeTab.value.id) + if (activeTab.value) void closeTab(activeTab.value.id) }, save: () => void store.saveFigFile(), 'save-as': () => void store.saveFigFileAs(), diff --git a/src/app/storage/local-store/idb.ts b/src/app/storage/local-store/idb.ts index d977fb41c..91e416d01 100644 --- a/src/app/storage/local-store/idb.ts +++ b/src/app/storage/local-store/idb.ts @@ -77,20 +77,20 @@ export function createIdbLocalCanvasStore(): LocalCanvasStore { const existing = (await metaStore.get(input.id)) ?? null let hasThumb = existing?.hasThumb ?? false - figStore.put(Uint8Array.from(input.figBytes), input.id) + await figStore.put(Uint8Array.from(input.figBytes), input.id) if (input.thumbBytes != null) { if (input.thumbBytes.byteLength > 0) { - thumbStore.put(Uint8Array.from(input.thumbBytes), input.id) + await thumbStore.put(Uint8Array.from(input.thumbBytes), input.id) hasThumb = true } else { - thumbStore.delete(input.id) + await thumbStore.delete(input.id) hasThumb = false } } const meta = buildWriteMeta(input, existing, hasThumb) - metaStore.put(meta) + await metaStore.put(meta) await transaction.done return meta }, @@ -100,7 +100,7 @@ export function createIdbLocalCanvasStore(): LocalCanvasStore { const store = transaction.objectStore('meta') const existing = (await store.get(input.id)) ?? null const meta = buildIndexMeta(input, existing) - store.put(meta) + await store.put(meta) await transaction.done return meta }, @@ -113,11 +113,11 @@ export function createIdbLocalCanvasStore(): LocalCanvasStore { await transaction.done return null } - transaction.objectStore('thumb').put(Uint8Array.from(thumbBytes), id) + await transaction.objectStore('thumb').put(Uint8Array.from(thumbBytes), id) // Thumb freshness is tracked by its own outbox job — never demote the // document's syncStatus here (it orphaned rows as 'pending' forever). const meta: LocalCanvasMeta = { ...existing, hasThumb: true } - metaStore.put(meta) + await metaStore.put(meta) await transaction.done return meta }, @@ -134,7 +134,7 @@ export function createIdbLocalCanvasStore(): LocalCanvasStore { return null } const next = { ...existing, ...patch, id: existing.id } - store.put(next) + await store.put(next) await transaction.done return next }, @@ -155,26 +155,30 @@ export function createIdbLocalCanvasStore(): LocalCanvasStore { await transaction.done return null } - transaction.objectStore('fig').delete(id) + await transaction.objectStore('fig').delete(id) const meta: LocalCanvasMeta = { ...existing, hasFig: false, figSize: 0 } - metaStore.put(meta) + await metaStore.put(meta) await transaction.done return meta }, async remove(id: string) { const transaction = (await database).transaction(['meta', 'fig', 'thumb'], 'readwrite') - transaction.objectStore('meta').delete(id) - transaction.objectStore('fig').delete(id) - transaction.objectStore('thumb').delete(id) + await Promise.all([ + transaction.objectStore('meta').delete(id), + transaction.objectStore('fig').delete(id), + transaction.objectStore('thumb').delete(id) + ]) await transaction.done }, async clearAll() { const transaction = (await database).transaction(['meta', 'fig', 'thumb'], 'readwrite') - transaction.objectStore('meta').clear() - transaction.objectStore('fig').clear() - transaction.objectStore('thumb').clear() + await Promise.all([ + transaction.objectStore('meta').clear(), + transaction.objectStore('fig').clear(), + transaction.objectStore('thumb').clear() + ]) await transaction.done } } diff --git a/src/app/storage/sync/outbox.ts b/src/app/storage/sync/outbox.ts index b079176d8..5a31ff934 100644 --- a/src/app/storage/sync/outbox.ts +++ b/src/app/storage/sync/outbox.ts @@ -110,9 +110,11 @@ export function createIdbOutbox(): Outbox { const existing = await store.getAll() const next = withJobQueued(existing, job) for (const queued of existing) { - if (!next.some((candidate) => candidate.id === queued.id)) store.delete(queued.id) + if (!next.some((candidate) => candidate.id === queued.id)) { + await store.delete(queued.id) + } } - store.put(job) + await store.put(job) await transaction.done return job }, diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index 59e51b2ff..56a6d2243 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -92,22 +92,24 @@ export function switchTab(tabId: string) { activateTab(tab) } -export function closeTab(tabId: string) { +export async function closeTab(tabId: string): Promise { const idx = tabsRef.value.findIndex((t) => t.id === tabId) if (idx === -1) return const closingTab = tabsRef.value[idx] const wasActive = activeTabId.value === tabId - const deletion = closingTab.store.discardRecovery().finally(() => { - pendingRecoveryDeletions.delete(deletion) - }) + const deletion = closingTab.store.discardRecovery() pendingRecoveryDeletions.add(deletion) + try { + await deletion + } finally { + pendingRecoveryDeletions.delete(deletion) + } closingTab.store.dispose() tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId) if (tabsRef.value.length === 0) { createTab() - closingTab.store.dispose() return } @@ -115,8 +117,6 @@ export function closeTab(tabId: string) { const newIdx = Math.min(idx, tabsRef.value.length - 1) activateTab(tabsRef.value[newIdx]) } - - closingTab.store.dispose() } function yieldToUI(): Promise { @@ -296,7 +296,7 @@ export async function restoreRecoverySnapshot(id: string): Promise { store.replaceGraph(imported) store.undo.clear() store.state.documentName = snapshot.documentName - store.adoptRecoverySnapshot(id, snapshot.sceneVersion) + await store.adoptRecoverySnapshot(id, snapshot.sceneVersion) store.clearSelection() const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId await store.switchPage(pageId) diff --git a/src/components/TabBar.vue b/src/components/TabBar.vue index 7e42f0713..b66020998 100644 --- a/src/components/TabBar.vue +++ b/src/components/TabBar.vue @@ -22,13 +22,13 @@ const modelValue = computed({ function onMiddleClick(e: MouseEvent, tabId: string) { if (e.button === 1) { e.preventDefault() - closeTab(tabId) + void closeTab(tabId) } } function onClose(e: MouseEvent, tabId: string) { e.stopPropagation() - closeTab(tabId) + void closeTab(tabId) } diff --git a/tests/engine/app/document/recovery/controller.test.ts b/tests/engine/app/document/recovery/controller.test.ts index c3f71f971..572a072b0 100644 --- a/tests/engine/app/document/recovery/controller.test.ts +++ b/tests/engine/app/document/recovery/controller.test.ts @@ -139,6 +139,28 @@ describe('document recovery controller', () => { recovery.disposeRecovery() }) + test('adoption waits for an active write and removes the previous recovery id', async () => { + const deferred = deferredWriteStore() + const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Draft' }) + const recovery = createDocumentRecovery({ + state, + store: deferred.store, + recoveryId: 'previous', + hasWritableSource: () => false, + buildFigFile: () => new Uint8Array([1]) + }) + state.sceneVersion = 1 + const write = recovery.persistNow() + await Promise.resolve() + const adoption = recovery.adoptRecoverySnapshot('recovered', 7) + deferred.release() + await Promise.all([write, adoption]) + + expect(recovery.getRecoveryId()).toBe('recovered') + expect(await deferred.store.read('previous')).toBeNull() + recovery.disposeRecovery() + }) + test('preserves a snapshot newer than the saved version', async () => { const { state, store, recovery } = setup() state.sceneVersion = 2