fix(app): harden document recovery lifecycle
- Serialize recovery cleanup with active snapshot writes - Preserve version ordering across autosave, restore, and cleanup - Fall back to memory when IndexedDB operations fail
This commit is contained in:
parent
16b74a2087
commit
7b8e5fbfbe
|
|
@ -5,7 +5,7 @@
|
|||
"recoverUnsavedWork": "Récupérer le travail non enregistré",
|
||||
"recoverUnsavedWorkDescription": "OpenPencil a trouvé des documents d’une session précédente.",
|
||||
"restore": "Restaurer",
|
||||
"discard": "Ignorer",
|
||||
"discard": "Supprimer",
|
||||
"recoveryFailed": "Impossible de restaurer ce document.",
|
||||
"rename": "Renommer",
|
||||
"renameLayers": "Renommer {count} calques",
|
||||
|
|
|
|||
|
|
@ -87,7 +87,9 @@ export function createDocumentSourceActions({
|
|||
getSavedVersion,
|
||||
hasWritableSource: () => !!getFileHandle() || !!getFilePath() || !!getStorageBinding(),
|
||||
saveCurrentDocument: async () => {
|
||||
await writeFile(await buildFigFile())
|
||||
const version = state.sceneVersion
|
||||
const data = await buildFigFile()
|
||||
await writeFile(data, version)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,11 @@ export function createDocumentWriter({
|
|||
}: DocumentWriterOptions) {
|
||||
async function finishWrite(version: number): Promise<true> {
|
||||
setSavedVersion(version)
|
||||
await onWriteSuccess?.(version)
|
||||
try {
|
||||
await onWriteSuccess?.(version)
|
||||
} catch (error) {
|
||||
console.warn('[Recovery] Cleanup after document write failed:', error)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export function createDocumentRecovery({
|
|||
}: DocumentRecoveryOptions): DocumentRecoveryController {
|
||||
let id = recoveryId
|
||||
let protectedVersion = state.sceneVersion
|
||||
let persistedVersion: number | null = null
|
||||
let requestedVersion = protectedVersion
|
||||
let lifecycleGeneration = 0
|
||||
let writing: Promise<void> | null = null
|
||||
|
|
@ -52,6 +53,7 @@ export function createDocumentRecovery({
|
|||
sceneVersion: version,
|
||||
figBytes: bytes
|
||||
})
|
||||
persistedVersion = version
|
||||
if (generation !== lifecycleGeneration) return
|
||||
protectedVersion = version
|
||||
if (requestedVersion !== version) await runWrites(generation)
|
||||
|
|
@ -80,25 +82,35 @@ export function createDocumentRecovery({
|
|||
{ debounce: 3000, maxWait: 10000 }
|
||||
)
|
||||
|
||||
async function invalidateActiveWrite(): Promise<void> {
|
||||
lifecycleGeneration++
|
||||
await writing
|
||||
}
|
||||
|
||||
return {
|
||||
getRecoveryId: () => id,
|
||||
adoptRecoverySnapshot(nextId, sceneVersion) {
|
||||
lifecycleGeneration++
|
||||
id = nextId
|
||||
protectedVersion = sceneVersion
|
||||
persistedVersion = sceneVersion
|
||||
requestedVersion = sceneVersion
|
||||
disposed = false
|
||||
},
|
||||
persistNow,
|
||||
async markProtectedVersion(version) {
|
||||
lifecycleGeneration++
|
||||
await invalidateActiveWrite()
|
||||
protectedVersion = version
|
||||
requestedVersion = version
|
||||
await store.remove(id)
|
||||
requestedVersion = state.sceneVersion
|
||||
if (persistedVersion == null || persistedVersion <= version) {
|
||||
await store.remove(id)
|
||||
persistedVersion = null
|
||||
}
|
||||
},
|
||||
async discardRecovery() {
|
||||
lifecycleGeneration++
|
||||
await invalidateActiveWrite()
|
||||
protectedVersion = state.sceneVersion
|
||||
persistedVersion = null
|
||||
requestedVersion = state.sceneVersion
|
||||
await store.remove(id)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,20 +1,54 @@
|
|||
import { createIdbRecoveryStore } from '@/app/document/recovery/idb'
|
||||
import { createMemoryRecoveryStore } from '@/app/document/recovery/memory'
|
||||
import type { RecoveryStore } from '@/app/document/recovery/types'
|
||||
import type {
|
||||
RecoverySnapshot,
|
||||
RecoverySnapshotInput,
|
||||
RecoverySnapshotMeta,
|
||||
RecoveryStore
|
||||
} from '@/app/document/recovery/types'
|
||||
|
||||
let singleton: RecoveryStore | null = null
|
||||
let memoryFallback = false
|
||||
|
||||
function warnMemoryFallback(error?: unknown): void {
|
||||
if (memoryFallback) return
|
||||
console.warn('[Recovery] IndexedDB unavailable; crash recovery is limited to this session', error)
|
||||
memoryFallback = true
|
||||
}
|
||||
|
||||
function createResilientRecoveryStore(primary: RecoveryStore): RecoveryStore {
|
||||
let current = primary
|
||||
|
||||
async function run<T>(operation: (store: RecoveryStore) => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation(current)
|
||||
} catch (error) {
|
||||
if (current !== primary) throw error
|
||||
warnMemoryFallback(error)
|
||||
current = createMemoryRecoveryStore()
|
||||
return operation(current)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
list: () => run((store) => store.list()),
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
export function getRecoveryStore(): RecoveryStore {
|
||||
if (singleton) return singleton
|
||||
if (typeof indexedDB !== 'undefined') {
|
||||
singleton = createIdbRecoveryStore()
|
||||
memoryFallback = false
|
||||
} else {
|
||||
console.warn('[Recovery] IndexedDB unavailable; crash recovery is limited to this session')
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
warnMemoryFallback()
|
||||
singleton = createMemoryRecoveryStore()
|
||||
memoryFallback = true
|
||||
return singleton
|
||||
}
|
||||
memoryFallback = false
|
||||
singleton = createResilientRecoveryStore(createIdbRecoveryStore())
|
||||
return singleton
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ function generateTabId(): string {
|
|||
}
|
||||
|
||||
const tabsRef = shallowRef<Tab[]>([])
|
||||
const pendingRecoveryDeletions = new Set<Promise<void>>()
|
||||
const activeTabId = shallowRef('')
|
||||
|
||||
export const activeTab = computed(() => tabsRef.value.find((t) => t.id === activeTabId.value))
|
||||
|
|
@ -97,8 +98,11 @@ export function closeTab(tabId: string) {
|
|||
|
||||
const closingTab = tabsRef.value[idx]
|
||||
const wasActive = activeTabId.value === tabId
|
||||
const deletion = closingTab.store.discardRecovery().finally(() => {
|
||||
pendingRecoveryDeletions.delete(deletion)
|
||||
})
|
||||
pendingRecoveryDeletions.add(deletion)
|
||||
closingTab.store.dispose()
|
||||
void closingTab.store.discardRecovery()
|
||||
tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId)
|
||||
|
||||
if (tabsRef.value.length === 0) {
|
||||
|
|
@ -292,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, store.state.sceneVersion)
|
||||
store.adoptRecoverySnapshot(id, snapshot.sceneVersion)
|
||||
store.clearSelection()
|
||||
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
|
||||
await store.switchPage(pageId)
|
||||
|
|
@ -300,7 +304,10 @@ export async function restoreRecoverySnapshot(id: string): Promise<void> {
|
|||
}
|
||||
|
||||
export async function prepareForReload(): Promise<void> {
|
||||
await Promise.all(tabsRef.value.map((tab) => tab.store.persistRecoveryNow()))
|
||||
await Promise.all([
|
||||
...pendingRecoveryDeletions,
|
||||
...tabsRef.value.map((tab) => tab.store.persistRecoveryNow())
|
||||
])
|
||||
}
|
||||
|
||||
export function tabCount(): number {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ async function restore(snapshot: RecoverySnapshotMeta): Promise<void> {
|
|||
try {
|
||||
await restoreRecoverySnapshot(snapshot.id)
|
||||
snapshots.value = snapshots.value.filter((candidate) => candidate.id !== snapshot.id)
|
||||
if (snapshots.value.length === 0) open.value = false
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : dialogs.value.recoveryFailed)
|
||||
} finally {
|
||||
|
|
@ -40,6 +41,7 @@ async function discard(snapshot: RecoverySnapshotMeta): Promise<void> {
|
|||
try {
|
||||
await discardRecoverySnapshot(snapshot.id)
|
||||
snapshots.value = snapshots.value.filter((candidate) => candidate.id !== snapshot.id)
|
||||
if (snapshots.value.length === 0) open.value = false
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,22 @@ import { createDefaultEditorState } from '@open-pencil/core/editor'
|
|||
|
||||
import { createDocumentRecovery } from '@/app/document/recovery/controller'
|
||||
import { createMemoryRecoveryStore } from '@/app/document/recovery/memory'
|
||||
import type { RecoverySnapshotInput, RecoveryStore } from '@/app/document/recovery/types'
|
||||
|
||||
function deferredWriteStore() {
|
||||
const memory = createMemoryRecoveryStore()
|
||||
let release: (() => void) | null = null
|
||||
const store: RecoveryStore = {
|
||||
...memory,
|
||||
async write(input: RecoverySnapshotInput) {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return memory.write(input)
|
||||
}
|
||||
}
|
||||
return { store, release: () => release?.() }
|
||||
}
|
||||
|
||||
function setup(buildFigFile = async () => new Uint8Array([1, 2, 3])) {
|
||||
const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Agent draft' })
|
||||
|
|
@ -80,4 +96,57 @@ describe('document recovery controller', () => {
|
|||
expect(await store.list()).toEqual([])
|
||||
recovery.disposeRecovery()
|
||||
})
|
||||
|
||||
test('save waits for an active write before deleting its snapshot', async () => {
|
||||
const deferred = deferredWriteStore()
|
||||
const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Draft' })
|
||||
const recovery = createDocumentRecovery({
|
||||
state,
|
||||
store: deferred.store,
|
||||
recoveryId: 'recovery-1',
|
||||
hasWritableSource: () => false,
|
||||
buildFigFile: () => new Uint8Array([1])
|
||||
})
|
||||
state.sceneVersion = 1
|
||||
const write = recovery.persistNow()
|
||||
await Promise.resolve()
|
||||
const cleanup = recovery.markProtectedVersion(1)
|
||||
deferred.release()
|
||||
await Promise.all([write, cleanup])
|
||||
|
||||
expect(await deferred.store.list()).toEqual([])
|
||||
recovery.disposeRecovery()
|
||||
})
|
||||
|
||||
test('discard waits for an active write before deleting its snapshot', async () => {
|
||||
const deferred = deferredWriteStore()
|
||||
const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Draft' })
|
||||
const recovery = createDocumentRecovery({
|
||||
state,
|
||||
store: deferred.store,
|
||||
recoveryId: 'recovery-1',
|
||||
hasWritableSource: () => false,
|
||||
buildFigFile: () => new Uint8Array([1])
|
||||
})
|
||||
state.sceneVersion = 1
|
||||
const write = recovery.persistNow()
|
||||
await Promise.resolve()
|
||||
const discard = recovery.discardRecovery()
|
||||
deferred.release()
|
||||
await Promise.all([write, discard])
|
||||
|
||||
expect(await deferred.store.list()).toEqual([])
|
||||
recovery.disposeRecovery()
|
||||
})
|
||||
|
||||
test('preserves a snapshot newer than the saved version', async () => {
|
||||
const { state, store, recovery } = setup()
|
||||
state.sceneVersion = 2
|
||||
await recovery.persistNow()
|
||||
|
||||
await recovery.markProtectedVersion(1)
|
||||
|
||||
expect((await store.read('recovery-1'))?.sceneVersion).toBe(2)
|
||||
recovery.disposeRecovery()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -56,6 +56,23 @@ describe('Tauri document IO helpers', () => {
|
|||
expect(savedVersions).toEqual([42])
|
||||
})
|
||||
|
||||
test('keeps a completed write successful when recovery cleanup fails', async () => {
|
||||
await mockTauriIPC(() => null)
|
||||
const savedVersions: number[] = []
|
||||
const write = createDocumentWriter({
|
||||
state: { sceneVersion: 42 } as Parameters<typeof createDocumentWriter>[0]['state'],
|
||||
getFilePath: () => '/tmp/document.fig',
|
||||
getFileHandle: () => null,
|
||||
getStorageBinding: () => null,
|
||||
setSavedVersion: (version) => savedVersions.push(version),
|
||||
setLastWriteTime: () => undefined,
|
||||
onWriteSuccess: () => Promise.reject(new Error('IndexedDB blocked'))
|
||||
})
|
||||
|
||||
await expect(write(new Uint8Array([1]), 17)).resolves.toBe(true)
|
||||
expect(savedVersions).toEqual([17])
|
||||
})
|
||||
|
||||
test('chooses a Tauri save path through plugin-dialog', async () => {
|
||||
await mockTauriIPC((cmd, args) => {
|
||||
expect(cmd).toBe('plugin:dialog|save')
|
||||
|
|
|
|||
Loading…
Reference in a new issue