fix(app): serialize document recovery lifecycle

- Await IndexedDB writes and tab recovery cleanup

- Serialize recovery backend fallback and snapshot adoption

- Preserve queued snapshots when IndexedDB falls back to memory
This commit is contained in:
Danila Poyarkov 2026-08-13 14:57:36 +03:00
parent 7b8e5fbfbe
commit f22d2c9bad
10 changed files with 114 additions and 43 deletions

View file

@ -19,7 +19,7 @@ interface DocumentRecoveryOptions {
export interface DocumentRecoveryController {
getRecoveryId(): string
adoptRecoverySnapshot(id: string, sceneVersion: number): void
adoptRecoverySnapshot(id: string, sceneVersion: number): Promise<void>
persistNow(): Promise<void>
markProtectedVersion(version: number): Promise<void>
discardRecovery(): Promise<void>
@ -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) {

View file

@ -18,16 +18,57 @@ function warnMemoryFallback(error?: unknown): void {
function createResilientRecoveryStore(primary: RecoveryStore): RecoveryStore {
let current = primary
let queue = Promise.resolve()
async function run<T>(operation: (store: RecoveryStore) => Promise<T>): Promise<T> {
function serialized<T>(operation: () => Promise<T>): Promise<T> {
const result = queue.then(operation, operation)
queue = result.then(
() => undefined,
() => undefined
)
return result
}
async function switchToMemory(error: unknown): Promise<RecoveryStore> {
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<T>(operation: (store: RecoveryStore) => Promise<T>): Promise<T> {
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<void> {
return serialized(async () => {
await primary.remove(id)
if (current !== primary) await current.remove(id)
})
}
function clearAll(): Promise<void> {
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<RecoverySnapshot | null> => run((store) => store.read(id)),
write: (input: RecoverySnapshotInput): Promise<RecoverySnapshotMeta> =>
run((store) => store.write(input)),
remove: (id: string): Promise<void> => run((store) => store.remove(id)),
clear: (): Promise<void> => run((store) => store.clear())
remove: removeFromAll,
clear: clearAll
}
}

View file

@ -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()
})

View file

@ -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'),

View file

@ -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(),

View file

@ -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
}
}

View file

@ -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
},

View file

@ -92,22 +92,24 @@ export function switchTab(tabId: string) {
activateTab(tab)
}
export function closeTab(tabId: string) {
export async function closeTab(tabId: string): Promise<void> {
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<void> {
@ -296,7 +296,7 @@ export async function restoreRecoverySnapshot(id: string): Promise<void> {
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)

View file

@ -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)
}
</script>

View file

@ -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