mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-09 01:20:09 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
157
src/platform/workflow/persistence/migration/migrateV1toV2.ts
Normal file
157
src/platform/workflow/persistence/migration/migrateV1toV2.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user