feat(persistence): add V2 persistence composable and migration

Complete the V2 integration:

- useWorkflowPersistenceV2: New composable with:
  - 512ms debounce on graphChanged events
  - Uses V2 draft store with per-draft keys
  - Uses tab state composable for session pointers
  - Clears storage on signout (cloud only)

- migrateV1toV2: One-time migration from V1 blob to V2 per-draft keys
  - Runs on first load if V2 index doesn't exist
  - Preserves LRU order during migration
  - Keeps V1 data intact for rollback until 2026-07-15

The V2 composable is ready but not yet wired as the default export.
This allows gradual rollout and easy rollback.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c16f4-05a2-779d-aa0e-a0e098308a95
This commit is contained in:
bymyself
2026-01-31 18:38:55 -08:00
parent 0c88af0d42
commit 9da21660cf
4 changed files with 663 additions and 1 deletions

View File

@@ -138,7 +138,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'

View File

@@ -0,0 +1,263 @@
/**
* V2 Workflow Persistence Composable
*
* Key changes from V1:
* - Uses V2 draft store with per-draft keys
* - Uses tab state composable for session pointers
* - Adds 512ms debounce on graph change persistence
* - Runs V1→V2 migration on first load
*/
import { debounce } from 'es-toolkit'
import { useToast } from 'primevue'
import { tryOnScopeDispose } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { PERSIST_DEBOUNCE_MS } from '../base/draftTypes'
import { clearAllV2Storage } from '../base/storageIO'
import { migrateV1toV2 } from '../migration/migrateV1toV2'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowTabState } from './useWorkflowTabState'
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
export function useWorkflowPersistenceV2() {
const { t } = useI18n()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const route = useRoute()
const router = useRouter()
const templateUrlLoader = useTemplateUrlLoader()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const draftStore = useWorkflowDraftStoreV2()
const tabState = useWorkflowTabState()
const toast = useToast()
const { onUserLogout } = useCurrentUser()
// Run migration on module load
migrateV1toV2()
// Clear workflow persistence storage when user signs out (cloud only)
onUserLogout(() => {
if (isCloud) {
clearAllV2Storage()
}
})
const ensureTemplateQueryFromIntent = async () => {
hydratePreservedQuery(TEMPLATE_NAMESPACE)
const mergedQuery = mergePreservedQueryIntoQuery(
TEMPLATE_NAMESPACE,
route.query
)
if (mergedQuery) {
await router.replace({ query: mergedQuery })
}
return mergedQuery ?? route.query
}
const workflowPersistenceEnabled = computed(() =>
settingStore.get('Comfy.Workflow.Persist')
)
const lastSavedJsonByPath = ref<Record<string, string>>({})
const persistCurrentWorkflow = () => {
if (!workflowPersistenceEnabled.value) return
const activeWorkflow = workflowStore.activeWorkflow
if (!activeWorkflow) return
const graphData = comfyApp.rootGraph.serialize()
const workflowJson = JSON.stringify(graphData)
const workflowPath = activeWorkflow.path
// Skip if unchanged
if (workflowJson === lastSavedJsonByPath.value[workflowPath]) return
// Save to V2 draft store
const saved = draftStore.saveDraft(workflowPath, workflowJson, {
name: activeWorkflow.key,
isTemporary: activeWorkflow.isTemporary
})
if (!saved) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft'),
life: 3000
})
return
}
// Update session pointer
tabState.setActivePath(workflowPath)
lastSavedJsonByPath.value[workflowPath] = workflowJson
// Clean up draft if workflow is saved and unmodified
if (!activeWorkflow.isTemporary && !activeWorkflow.isModified) {
draftStore.removeDraft(workflowPath)
}
}
// Debounced version for graphChanged events
const debouncedPersist = debounce(persistCurrentWorkflow, PERSIST_DEBOUNCE_MS)
const loadPreviousWorkflowFromStorage = async () => {
// 1. Try session pointer (for tab restoration)
const sessionPath = tabState.getActivePath()
if (
sessionPath &&
(await draftStore.loadPersistedWorkflow({
workflowName: null,
preferredPath: sessionPath
}))
) {
return true
}
// 2. Fall back to most recent draft
return await draftStore.loadPersistedWorkflow({
workflowName: null,
fallbackToLatestDraft: true
})
}
const loadDefaultWorkflow = async () => {
if (!settingStore.get('Comfy.TutorialCompleted')) {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
await useCommandStore().execute('Comfy.BrowseTemplates')
} else {
await comfyApp.loadGraphData()
}
}
const initializeWorkflow = async () => {
if (!workflowPersistenceEnabled.value) return
try {
const restored = await loadPreviousWorkflowFromStorage()
if (!restored) {
await loadDefaultWorkflow()
}
} catch (err) {
console.error('Error loading previous workflow', err)
await loadDefaultWorkflow()
}
}
const loadTemplateFromUrlIfPresent = async () => {
const query = await ensureTemplateQueryFromIntent()
const hasTemplateUrl = query.template && typeof query.template === 'string'
if (hasTemplateUrl) {
await templateUrlLoader.loadTemplateFromUrl()
}
}
// Setup watchers
watch(
() => workflowStore.activeWorkflow?.key,
(activeWorkflowKey) => {
if (!activeWorkflowKey) return
// Persist immediately on workflow switch (not debounced)
persistCurrentWorkflow()
}
)
// Debounced persistence on graph changes
api.addEventListener('graphChanged', debouncedPersist)
// Clean up event listener when component unmounts
tryOnScopeDispose(() => {
api.removeEventListener('graphChanged', debouncedPersist)
debouncedPersist.cancel()
})
// Restore workflow tabs states
const openWorkflows = computed(() => workflowStore.openWorkflows)
const activeWorkflow = computed(() => workflowStore.activeWorkflow)
const restoreState = computed<{ paths: string[]; activeIndex: number }>(
() => {
if (!openWorkflows.value || !activeWorkflow.value) {
return { paths: [], activeIndex: -1 }
}
const paths = openWorkflows.value
.map((workflow) => workflow?.path)
.filter(
(path): path is string =>
typeof path === 'string' && path.startsWith(ComfyWorkflow.basePath)
)
const activeIndex = paths.indexOf(activeWorkflow.value.path)
return { paths, activeIndex }
}
)
// Get storage values before setting watchers
const storedTabState = tabState.getOpenPaths()
const storedWorkflows = storedTabState?.paths ?? []
const storedActiveIndex = storedTabState?.activeIndex ?? -1
watch(restoreState, ({ paths, activeIndex }) => {
if (workflowPersistenceEnabled.value) {
tabState.setOpenPaths(paths, activeIndex)
}
})
const restoreWorkflowTabsState = () => {
if (!workflowPersistenceEnabled.value) return
const isRestorable = storedWorkflows.length > 0 && storedActiveIndex >= 0
if (!isRestorable) return
storedWorkflows.forEach((path: string) => {
if (workflowStore.getWorkflowByPath(path)) return
const draft = draftStore.getDraft(path)
if (!draft?.isTemporary) return
try {
const workflowData = JSON.parse(draft.data)
workflowStore.createTemporary(draft.name, workflowData)
} catch (err) {
console.warn(
'Failed to parse workflow draft, creating with default',
err
)
draftStore.removeDraft(path)
workflowStore.createTemporary(draft.name)
}
})
workflowStore.openWorkflowsInBackground({
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
})
}
return {
initializeWorkflow,
loadTemplateFromUrlIfPresent,
restoreWorkflowTabsState
}
}

View File

@@ -0,0 +1,242 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { hashPath } from '../base/hashUtil'
import {
cleanupV1Data,
getMigrationStatus,
isV2MigrationComplete,
migrateV1toV2
} from './migrateV1toV2'
describe('migrateV1toV2', () => {
const workspaceId = 'test-workspace'
beforeEach(() => {
vi.resetModules()
localStorage.clear()
sessionStorage.clear()
})
afterEach(() => {
localStorage.clear()
sessionStorage.clear()
})
function setV1Data(
drafts: Record<
string,
{ data: string; updatedAt: number; name: string; isTemporary: boolean }
>,
order: string[]
) {
localStorage.setItem(
`Comfy.Workflow.Drafts:${workspaceId}`,
JSON.stringify(drafts)
)
localStorage.setItem(
`Comfy.Workflow.DraftOrder:${workspaceId}`,
JSON.stringify(order)
)
}
describe('isV2MigrationComplete', () => {
it('returns false when no V2 index exists', () => {
expect(isV2MigrationComplete(workspaceId)).toBe(false)
})
it('returns true when V2 index exists', () => {
localStorage.setItem(
`Comfy.Workflow.DraftIndex.v2:${workspaceId}`,
JSON.stringify({ v: 2, order: [], entries: {}, updatedAt: Date.now() })
)
expect(isV2MigrationComplete(workspaceId)).toBe(true)
})
})
describe('migrateV1toV2', () => {
it('returns -1 if V2 already exists', () => {
localStorage.setItem(
`Comfy.Workflow.DraftIndex.v2:${workspaceId}`,
JSON.stringify({ v: 2, order: [], entries: {}, updatedAt: Date.now() })
)
expect(migrateV1toV2(workspaceId)).toBe(-1)
})
it('creates empty V2 index if no V1 data', () => {
expect(migrateV1toV2(workspaceId)).toBe(0)
const indexJson = localStorage.getItem(
`Comfy.Workflow.DraftIndex.v2:${workspaceId}`
)
expect(indexJson).not.toBeNull()
const index = JSON.parse(indexJson!)
expect(index.v).toBe(2)
expect(index.order).toEqual([])
})
it('migrates V1 drafts to V2 format', () => {
const v1Drafts = {
'workflows/a.json': {
data: '{"nodes":[1]}',
updatedAt: 1000,
name: 'a',
isTemporary: true
},
'workflows/b.json': {
data: '{"nodes":[2]}',
updatedAt: 2000,
name: 'b',
isTemporary: false
}
}
setV1Data(v1Drafts, ['workflows/a.json', 'workflows/b.json'])
const migrated = migrateV1toV2(workspaceId)
expect(migrated).toBe(2)
// Check V2 index
const indexJson = localStorage.getItem(
`Comfy.Workflow.DraftIndex.v2:${workspaceId}`
)
const index = JSON.parse(indexJson!)
expect(index.order).toHaveLength(2)
// Check payloads
const keyA = hashPath('workflows/a.json')
const keyB = hashPath('workflows/b.json')
const payloadA = localStorage.getItem(
`Comfy.Workflow.Draft.v2:${workspaceId}:${keyA}`
)
const payloadB = localStorage.getItem(
`Comfy.Workflow.Draft.v2:${workspaceId}:${keyB}`
)
expect(payloadA).not.toBeNull()
expect(payloadB).not.toBeNull()
expect(JSON.parse(payloadA!).data).toBe('{"nodes":[1]}')
expect(JSON.parse(payloadB!).data).toBe('{"nodes":[2]}')
})
it('preserves LRU order during migration', () => {
const v1Drafts = {
'workflows/first.json': {
data: '{}',
updatedAt: 1000,
name: 'first',
isTemporary: true
},
'workflows/second.json': {
data: '{}',
updatedAt: 2000,
name: 'second',
isTemporary: true
},
'workflows/third.json': {
data: '{}',
updatedAt: 3000,
name: 'third',
isTemporary: true
}
}
setV1Data(v1Drafts, [
'workflows/first.json',
'workflows/second.json',
'workflows/third.json'
])
migrateV1toV2(workspaceId)
const indexJson = localStorage.getItem(
`Comfy.Workflow.DraftIndex.v2:${workspaceId}`
)
const index = JSON.parse(indexJson!)
// Order should be preserved (oldest to newest)
const expectedOrder = [
hashPath('workflows/first.json'),
hashPath('workflows/second.json'),
hashPath('workflows/third.json')
]
expect(index.order).toEqual(expectedOrder)
})
it('keeps V1 data intact after migration', () => {
const v1Drafts = {
'workflows/test.json': {
data: '{}',
updatedAt: 1000,
name: 'test',
isTemporary: true
}
}
setV1Data(v1Drafts, ['workflows/test.json'])
migrateV1toV2(workspaceId)
// V1 data should still exist
expect(
localStorage.getItem(`Comfy.Workflow.Drafts:${workspaceId}`)
).not.toBeNull()
expect(
localStorage.getItem(`Comfy.Workflow.DraftOrder:${workspaceId}`)
).not.toBeNull()
})
})
describe('cleanupV1Data', () => {
it('removes V1 keys', () => {
setV1Data(
{
'workflows/test.json': {
data: '{}',
updatedAt: 1,
name: 'test',
isTemporary: true
}
},
['workflows/test.json']
)
cleanupV1Data(workspaceId)
expect(
localStorage.getItem(`Comfy.Workflow.Drafts:${workspaceId}`)
).toBeNull()
expect(
localStorage.getItem(`Comfy.Workflow.DraftOrder:${workspaceId}`)
).toBeNull()
})
})
describe('getMigrationStatus', () => {
it('reports correct status', () => {
setV1Data(
{
'workflows/a.json': {
data: '{}',
updatedAt: 1,
name: 'a',
isTemporary: true
},
'workflows/b.json': {
data: '{}',
updatedAt: 2,
name: 'b',
isTemporary: true
}
},
['workflows/a.json', 'workflows/b.json']
)
const status = getMigrationStatus(workspaceId)
expect(status.v1Exists).toBe(true)
expect(status.v2Exists).toBe(false)
expect(status.v1DraftCount).toBe(2)
expect(status.v2DraftCount).toBe(0)
})
})
})

View File

@@ -0,0 +1,157 @@
/**
* V1 to V2 Migration
*
* Migrates draft data from V1 blob format to V2 per-draft keys.
* Runs once on first load if V2 index doesn't exist.
* Keeps V1 data intact for rollback until 2026-07-15.
*/
import type { DraftIndexV2 } from '../base/draftTypes'
import { upsertEntry, createEmptyIndex } from '../base/draftCacheV2'
import { hashPath } from '../base/hashUtil'
import { getWorkspaceId } from '../base/storageKeys'
import { readIndex, writeIndex, writePayload } from '../base/storageIO'
/**
* V1 draft snapshot structure (from draftCache.ts)
*/
interface V1DraftSnapshot {
data: string
updatedAt: number
name: string
isTemporary: boolean
}
/**
* V1 storage keys - workspace-scoped blob format
*/
const V1_KEYS = {
drafts: (workspaceId: string) => `Comfy.Workflow.Drafts:${workspaceId}`,
order: (workspaceId: string) => `Comfy.Workflow.DraftOrder:${workspaceId}`
}
/**
* Checks if V2 migration has been completed for the current workspace.
*/
export function isV2MigrationComplete(workspaceId: string): boolean {
const v2Index = readIndex(workspaceId)
return v2Index !== null
}
/**
* Reads V1 drafts from localStorage.
*/
function readV1Drafts(
workspaceId: string
): { drafts: Record<string, V1DraftSnapshot>; order: string[] } | null {
try {
const draftsJson = localStorage.getItem(V1_KEYS.drafts(workspaceId))
const orderJson = localStorage.getItem(V1_KEYS.order(workspaceId))
if (!draftsJson) return null
const drafts = JSON.parse(draftsJson) as Record<string, V1DraftSnapshot>
const order = orderJson ? (JSON.parse(orderJson) as string[]) : []
return { drafts, order }
} catch {
return null
}
}
/**
* Migrates V1 drafts to V2 format.
*
* @returns Number of drafts migrated, or -1 if migration not needed/failed
*/
export function migrateV1toV2(workspaceId: string = getWorkspaceId()): number {
// Check if V2 already exists
if (isV2MigrationComplete(workspaceId)) {
return -1
}
// Read V1 data
const v1Data = readV1Drafts(workspaceId)
if (!v1Data) {
// No V1 data to migrate - create empty V2 index
writeIndex(workspaceId, createEmptyIndex())
return 0
}
// Build V2 index and write payloads
let index: DraftIndexV2 = createEmptyIndex()
let migrated = 0
// Process in order (oldest first) to maintain LRU order
for (const path of v1Data.order) {
const draft = v1Data.drafts[path]
if (!draft) continue
const draftKey = hashPath(path)
// Write payload
const payloadWritten = writePayload(workspaceId, draftKey, {
data: draft.data,
updatedAt: draft.updatedAt
})
if (!payloadWritten) {
console.warn(`[V2 Migration] Failed to write payload for ${path}`)
continue
}
// Update index
const { index: newIndex } = upsertEntry(index, path, {
name: draft.name,
isTemporary: draft.isTemporary,
updatedAt: draft.updatedAt
})
index = newIndex
migrated++
}
// Write final index
if (!writeIndex(workspaceId, index)) {
console.error('[V2 Migration] Failed to write index')
return -1
}
if (migrated > 0) {
console.warn(`[V2 Migration] Migrated ${migrated} drafts from V1 to V2`)
}
return migrated
}
/**
* Cleans up V1 data after successful migration.
* Should NOT be called until 2026-07-15 to allow rollback.
*/
export function cleanupV1Data(workspaceId: string = getWorkspaceId()): void {
try {
localStorage.removeItem(V1_KEYS.drafts(workspaceId))
localStorage.removeItem(V1_KEYS.order(workspaceId))
console.warn('[V2 Migration] Cleaned up V1 data')
} catch {
// Ignore cleanup errors
}
}
/**
* Gets migration status for debugging.
*/
export function getMigrationStatus(workspaceId: string = getWorkspaceId()): {
v1Exists: boolean
v2Exists: boolean
v1DraftCount: number
v2DraftCount: number
} {
const v1Data = readV1Drafts(workspaceId)
const v2Index = readIndex(workspaceId)
return {
v1Exists: v1Data !== null,
v2Exists: v2Index !== null,
v1DraftCount: v1Data ? v1Data.order.length : 0,
v2DraftCount: v2Index ? v2Index.order.length : 0
}
}