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:
parent
7b8e5fbfbe
commit
f22d2c9bad
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue