mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-22 07:44:11 +00:00
feat(persistence): fix QuotaExceededError and cross-workspace draft leakage (#8520)
## Summary Completes the workflow persistence overhaul by integrating the new draft system into the app and migrating existing data. Fixes two critical bugs: 1. **QuotaExceededError** - localStorage fills up with workflow drafts, breaking auto-save 2. **Cross-workspace data leakage** - Drafts from one ComfyUI instance appear in another ## Changes - **What**: - `useWorkflowPersistenceV2.ts` - Main composable that hooks into graph changes with 512ms debounce - `migrateV1toV2.ts` - One-time migration of existing drafts to the new scoped format - Updated E2E tests for new storage key patterns - **Why**: Users lose work when storage quota is exceeded, and see confusing workflows from other instances ## How It Works - **Workspace scoping**: Each ComfyUI instance (identified by server URL) has isolated draft storage - **LRU eviction**: When storage is full, oldest drafts are automatically removed (keeps 32 most recent) - **Tab isolation**: Each browser tab tracks its own active/open workflows via sessionStorage - **Debounced saves**: Graph changes are batched with 512ms delay to reduce storage writes ## Migration Existing V1 drafts are automatically migrated on first load. The migration: 1. Reads drafts from old `Comfy.Workflow.*` keys 2. Converts to new workspace-scoped format 3. Cleans up old keys after successful migration --- *Part 4 of 4 in the workflow persistence improvements stack* --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
This commit is contained in:
@@ -244,21 +244,9 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
const parsed = await (
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
const workflow = localStorage.getItem('workflow')
|
||||
if (!workflow) return null
|
||||
try {
|
||||
const data = JSON.parse(workflow)
|
||||
return Array.isArray(data?.nodes) ? data : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
).jsonValue()
|
||||
const parsed = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].graph.serialize()
|
||||
})
|
||||
expect(parsed.nodes).toBeDefined()
|
||||
expect(Array.isArray(parsed.nodes)).toBe(true)
|
||||
for (const node of parsed.nodes) {
|
||||
|
||||
@@ -736,6 +736,25 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'single_ksampler_modified.png'
|
||||
)
|
||||
// Wait for V2 persistence debounce to save the modified workflow
|
||||
const start = Date.now()
|
||||
await comfyPage.page.waitForFunction((since) => {
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i)
|
||||
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
|
||||
const json = window.localStorage.getItem(key)
|
||||
if (!json) continue
|
||||
try {
|
||||
const index = JSON.parse(json)
|
||||
if (typeof index.updatedAt === 'number' && index.updatedAt >= since) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, start)
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'single_ksampler_modified.png'
|
||||
@@ -758,10 +777,17 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowB)
|
||||
|
||||
// Wait for localStorage to persist the workflow paths before reloading
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => !!window.localStorage.getItem('Comfy.OpenWorkflowsPaths')
|
||||
)
|
||||
// Wait for sessionStorage to persist the workflow paths before reloading
|
||||
// V2 persistence uses sessionStorage with client-scoped keys
|
||||
await comfyPage.page.waitForFunction(() => {
|
||||
for (let i = 0; i < window.sessionStorage.length; i++) {
|
||||
const key = window.sessionStorage.key(i)
|
||||
if (key?.startsWith('Comfy.Workflow.OpenPaths:')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
})
|
||||
|
||||
|
||||
@@ -152,7 +152,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'
|
||||
|
||||
@@ -9,6 +9,7 @@ import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
|
||||
@@ -51,6 +52,17 @@ export const useFirebaseAuthActions = () => {
|
||||
}
|
||||
|
||||
const logout = wrapWithErrorHandlingAsync(async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
if (workflowStore.modifiedWorkflows.length > 0) {
|
||||
const dialogService = useDialogService()
|
||||
const confirmed = await dialogService.confirm({
|
||||
title: t('auth.signOut.unsavedChangesTitle'),
|
||||
message: t('auth.signOut.unsavedChangesMessage'),
|
||||
type: 'dirtyClose'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
await authStore.logout()
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
|
||||
@@ -2012,7 +2012,9 @@
|
||||
"signOut": {
|
||||
"signOut": "Log Out",
|
||||
"success": "Signed out successfully",
|
||||
"successDetail": "You have been signed out of your account."
|
||||
"successDetail": "You have been signed out of your account.",
|
||||
"unsavedChangesTitle": "Unsaved Changes",
|
||||
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?"
|
||||
},
|
||||
"passwordUpdate": {
|
||||
"success": "Password Updated",
|
||||
|
||||
@@ -49,22 +49,26 @@ export function upsertEntry(
|
||||
[draftKey]: { ...meta, path }
|
||||
}
|
||||
|
||||
const order = touchOrder(index.order, draftKey)
|
||||
const touchedOrder = touchOrder(index.order, draftKey)
|
||||
const evicted: string[] = []
|
||||
|
||||
while (order.length > effectiveLimit) {
|
||||
const oldest = order.shift()
|
||||
let evictCount = 0
|
||||
while (touchedOrder.length - evictCount > effectiveLimit) {
|
||||
const oldest = touchedOrder[evictCount]
|
||||
if (oldest && oldest !== draftKey) {
|
||||
delete entries[oldest]
|
||||
evicted.push(oldest)
|
||||
}
|
||||
evictCount++
|
||||
}
|
||||
|
||||
const finalOrder = touchedOrder.slice(evictCount)
|
||||
|
||||
return {
|
||||
index: {
|
||||
v: 2,
|
||||
updatedAt: Date.now(),
|
||||
order,
|
||||
order: finalOrder,
|
||||
entries
|
||||
},
|
||||
evicted
|
||||
|
||||
@@ -81,5 +81,4 @@ export interface OpenPathsPointer {
|
||||
/** Maximum number of drafts to keep per workspace */
|
||||
export const MAX_DRAFTS = 32
|
||||
|
||||
/** @knipIgnoreUsedByStackedPR Used by workflowPersistenceV2.ts (PR #3) */
|
||||
export const PERSIST_DEBOUNCE_MS = 512
|
||||
|
||||
@@ -186,6 +186,99 @@ describe('storageIO', () => {
|
||||
it('returns null for missing open paths', () => {
|
||||
expect(readOpenPaths('missing')).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to workspace search when clientId does not match and migrates', () => {
|
||||
const oldClientId = 'old-client'
|
||||
const newClientId = 'new-client'
|
||||
const workspaceId = 'ws-123'
|
||||
|
||||
// Store pointer with old clientId
|
||||
const pointer = {
|
||||
workspaceId,
|
||||
paths: ['workflows/a.json', 'workflows/b.json'],
|
||||
activeIndex: 0
|
||||
}
|
||||
writeOpenPaths(oldClientId, pointer)
|
||||
|
||||
// Read with new clientId but same workspace - should find via fallback
|
||||
const read = readOpenPaths(newClientId, workspaceId)
|
||||
expect(read).toEqual(pointer)
|
||||
|
||||
// Should have migrated to new key and removed old key
|
||||
const oldKey = `Comfy.Workflow.OpenPaths:${oldClientId}`
|
||||
const newKey = `Comfy.Workflow.OpenPaths:${newClientId}`
|
||||
expect(sessionStorage.getItem(oldKey)).toBeNull()
|
||||
expect(sessionStorage.getItem(newKey)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not fall back to different workspace pointer', () => {
|
||||
const oldClientId = 'old-client'
|
||||
const newClientId = 'new-client'
|
||||
|
||||
// Store pointer for workspace-A
|
||||
writeOpenPaths(oldClientId, {
|
||||
workspaceId: 'workspace-A',
|
||||
paths: ['workflows/a.json'],
|
||||
activeIndex: 0
|
||||
})
|
||||
|
||||
// Read with new clientId looking for workspace-B - should not find
|
||||
const read = readOpenPaths(newClientId, 'workspace-B')
|
||||
expect(read).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers exact clientId match over fallback search', () => {
|
||||
const clientId = 'my-client'
|
||||
const workspaceId = 'ws-123'
|
||||
|
||||
// Store pointer with different clientId for same workspace
|
||||
writeOpenPaths('other-client', {
|
||||
workspaceId,
|
||||
paths: ['workflows/old.json'],
|
||||
activeIndex: 0
|
||||
})
|
||||
|
||||
// Store pointer with exact clientId match
|
||||
const exactPointer = {
|
||||
workspaceId,
|
||||
paths: ['workflows/exact.json'],
|
||||
activeIndex: 0
|
||||
}
|
||||
writeOpenPaths(clientId, exactPointer)
|
||||
|
||||
// Should return exact match, not fallback
|
||||
const read = readOpenPaths(clientId, workspaceId)
|
||||
expect(read).toEqual(exactPointer)
|
||||
})
|
||||
|
||||
it('removes stale exact match from wrong workspace and falls back', () => {
|
||||
const clientId = 'my-client'
|
||||
|
||||
// Store pointer for workspace-A under this clientId
|
||||
writeActivePath(clientId, {
|
||||
workspaceId: 'ws-A',
|
||||
path: 'workflows/stale.json'
|
||||
})
|
||||
|
||||
// Store pointer for workspace-B under a different clientId
|
||||
writeActivePath('old-client', {
|
||||
workspaceId: 'ws-B',
|
||||
path: 'workflows/correct.json'
|
||||
})
|
||||
|
||||
// Reading with workspace-B should skip the stale ws-A pointer and find the fallback
|
||||
const result = readActivePath(clientId, 'ws-B')
|
||||
expect(result).toEqual({
|
||||
workspaceId: 'ws-B',
|
||||
path: 'workflows/correct.json'
|
||||
})
|
||||
|
||||
// Stale pointer should have been removed
|
||||
const raw = sessionStorage.getItem(
|
||||
`Comfy.Workflow.ActivePath:${clientId}`
|
||||
)
|
||||
expect(JSON.parse(raw!).workspaceId).toBe('ws-B')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearAllV2Storage', () => {
|
||||
|
||||
@@ -186,20 +186,83 @@ export function deleteOrphanPayloads(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the active path pointer from sessionStorage.
|
||||
* Searches sessionStorage for a pointer matching the target workspaceId
|
||||
* when the exact clientId key has no entry (e.g. clientId changed after reload).
|
||||
* Migrates the found pointer to the new clientId key.
|
||||
*/
|
||||
export function readActivePath(clientId: string): ActivePathPointer | null {
|
||||
try {
|
||||
const key = StorageKeys.activePath(clientId)
|
||||
const json = sessionStorage.getItem(key)
|
||||
if (!json) return null
|
||||
function findAndMigratePointer<T extends { workspaceId: string }>(
|
||||
newKey: string,
|
||||
prefix: string,
|
||||
targetWorkspaceId: string
|
||||
): T | null {
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const storageKey = sessionStorage.key(i)
|
||||
if (!storageKey?.startsWith(prefix) || storageKey === newKey) continue
|
||||
|
||||
return JSON.parse(json) as ActivePathPointer
|
||||
const json = sessionStorage.getItem(storageKey)
|
||||
if (!json) continue
|
||||
|
||||
try {
|
||||
const pointer = JSON.parse(json) as T
|
||||
if (pointer.workspaceId === targetWorkspaceId) {
|
||||
sessionStorage.setItem(newKey, json)
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return pointer
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a session pointer by clientId with workspace-based fallback.
|
||||
* Validates workspace on exact match and removes stale cross-workspace pointers.
|
||||
* If no valid entry exists, searches for any pointer matching the target
|
||||
* workspaceId and migrates it to the new key.
|
||||
*/
|
||||
function readSessionPointer<T extends { workspaceId: string }>(
|
||||
key: string,
|
||||
prefix: string,
|
||||
targetWorkspaceId?: string
|
||||
): T | null {
|
||||
try {
|
||||
const json = sessionStorage.getItem(key)
|
||||
if (json) {
|
||||
const pointer = JSON.parse(json) as T
|
||||
if (targetWorkspaceId && pointer.workspaceId !== targetWorkspaceId) {
|
||||
sessionStorage.removeItem(key)
|
||||
} else {
|
||||
return pointer
|
||||
}
|
||||
}
|
||||
|
||||
if (targetWorkspaceId) {
|
||||
return findAndMigratePointer<T>(key, prefix, targetWorkspaceId)
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the active path pointer from sessionStorage.
|
||||
* Falls back to workspace-based search when clientId changes after reload.
|
||||
*/
|
||||
export function readActivePath(
|
||||
clientId: string,
|
||||
targetWorkspaceId?: string
|
||||
): ActivePathPointer | null {
|
||||
return readSessionPointer<ActivePathPointer>(
|
||||
StorageKeys.activePath(clientId),
|
||||
StorageKeys.prefixes.activePath,
|
||||
targetWorkspaceId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the active path pointer to sessionStorage.
|
||||
*/
|
||||
@@ -217,17 +280,17 @@ export function writeActivePath(
|
||||
|
||||
/**
|
||||
* Reads the open paths pointer from sessionStorage.
|
||||
* Falls back to workspace-based search when clientId changes after reload.
|
||||
*/
|
||||
export function readOpenPaths(clientId: string): OpenPathsPointer | null {
|
||||
try {
|
||||
const key = StorageKeys.openPaths(clientId)
|
||||
const json = sessionStorage.getItem(key)
|
||||
if (!json) return null
|
||||
|
||||
return JSON.parse(json) as OpenPathsPointer
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
export function readOpenPaths(
|
||||
clientId: string,
|
||||
targetWorkspaceId?: string
|
||||
): OpenPathsPointer | null {
|
||||
return readSessionPointer<OpenPathsPointer>(
|
||||
StorageKeys.openPaths(clientId),
|
||||
StorageKeys.prefixes.openPaths,
|
||||
targetWorkspaceId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,17 +35,46 @@ describe('storageKeys', () => {
|
||||
const { getWorkspaceId } = await import('./storageKeys')
|
||||
expect(getWorkspaceId()).toBe('personal')
|
||||
})
|
||||
|
||||
it('returns personal when workspace has no id', async () => {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.Workspace.Current',
|
||||
JSON.stringify({ type: 'team', id: '' })
|
||||
)
|
||||
const { getWorkspaceId } = await import('./storageKeys')
|
||||
expect(getWorkspaceId()).toBe('personal')
|
||||
})
|
||||
|
||||
it('reads fresh value on each call (not cached)', async () => {
|
||||
const { getWorkspaceId } = await import('./storageKeys')
|
||||
|
||||
// Initially no workspace set
|
||||
expect(getWorkspaceId()).toBe('personal')
|
||||
|
||||
// Set workspace after import
|
||||
sessionStorage.setItem(
|
||||
'Comfy.Workspace.Current',
|
||||
JSON.stringify({ type: 'team', id: 'ws-new' })
|
||||
)
|
||||
|
||||
// Should read the new value (not cached 'personal')
|
||||
expect(getWorkspaceId()).toBe('ws-new')
|
||||
|
||||
// Change workspace again
|
||||
sessionStorage.setItem(
|
||||
'Comfy.Workspace.Current',
|
||||
JSON.stringify({ type: 'team', id: 'ws-another' })
|
||||
)
|
||||
|
||||
expect(getWorkspaceId()).toBe('ws-another')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StorageKeys', () => {
|
||||
it('generates draftIndex key with workspace scope', async () => {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.Workspace.Current',
|
||||
JSON.stringify({ type: 'team', id: 'ws-123' })
|
||||
)
|
||||
const { StorageKeys } = await import('./storageKeys')
|
||||
|
||||
expect(StorageKeys.draftIndex()).toBe(
|
||||
expect(StorageKeys.draftIndex('ws-123')).toBe(
|
||||
'Comfy.Workflow.DraftIndex.v2:ws-123'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,8 +5,12 @@ import { hashPath } from './hashUtil'
|
||||
/**
|
||||
* Gets the current workspace ID from sessionStorage.
|
||||
* Returns 'personal' for personal workspace or when no workspace is set.
|
||||
*
|
||||
* NOTE: This is called fresh each time rather than cached at module load,
|
||||
* because the workspace auth store may not have set sessionStorage yet
|
||||
* when this module is first imported.
|
||||
*/
|
||||
function getCurrentWorkspaceId(): string {
|
||||
export function getWorkspaceId(): string {
|
||||
try {
|
||||
const json = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE
|
||||
@@ -21,16 +25,6 @@ function getCurrentWorkspaceId(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache workspace ID at module load (static for page lifetime, workspace switch reloads page)
|
||||
const CURRENT_WORKSPACE_ID = getCurrentWorkspaceId()
|
||||
|
||||
/**
|
||||
* Returns the current workspace ID used for storage key scoping.
|
||||
*/
|
||||
export function getWorkspaceId(): string {
|
||||
return CURRENT_WORKSPACE_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage key generators for V2 workflow persistence.
|
||||
*
|
||||
@@ -42,7 +36,7 @@ export const StorageKeys = {
|
||||
* Draft index key for localStorage.
|
||||
* Contains LRU order and metadata for all drafts.
|
||||
*/
|
||||
draftIndex(workspaceId: string = CURRENT_WORKSPACE_ID): string {
|
||||
draftIndex(workspaceId: string): string {
|
||||
return `Comfy.Workflow.DraftIndex.v2:${workspaceId}`
|
||||
},
|
||||
|
||||
@@ -50,10 +44,7 @@ export const StorageKeys = {
|
||||
* Individual draft payload key for localStorage.
|
||||
* @param path - Workflow path (will be hashed to create key)
|
||||
*/
|
||||
draftPayload(
|
||||
path: string,
|
||||
workspaceId: string = CURRENT_WORKSPACE_ID
|
||||
): string {
|
||||
draftPayload(path: string, workspaceId: string): string {
|
||||
const draftKey = hashPath(path)
|
||||
return `Comfy.Workflow.Draft.v2:${workspaceId}:${draftKey}`
|
||||
},
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 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>>({})
|
||||
|
||||
watch(workflowPersistenceEnabled, (enabled) => {
|
||||
if (!enabled) {
|
||||
draftStore.reset()
|
||||
lastSavedJsonByPath.value = {}
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
// Flush any pending persistence from the previous workflow
|
||||
debouncedPersist.flush()
|
||||
// Persist the new workflow immediately
|
||||
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 }
|
||||
}
|
||||
)
|
||||
|
||||
// Track whether tab state has been properly restored to avoid
|
||||
// overwriting with stale data during initialization
|
||||
let tabStateRestored = false
|
||||
|
||||
watch(restoreState, ({ paths, activeIndex }) => {
|
||||
// Only persist after tab state has been restored to avoid
|
||||
// writing leaked data from wrong workspace during init
|
||||
if (workflowPersistenceEnabled.value && tabStateRestored) {
|
||||
tabState.setOpenPaths(paths, activeIndex)
|
||||
}
|
||||
})
|
||||
|
||||
const restoreWorkflowTabsState = () => {
|
||||
if (!workflowPersistenceEnabled.value) {
|
||||
tabStateRestored = true
|
||||
return
|
||||
}
|
||||
|
||||
// Read storage fresh at restore time, not at composable init,
|
||||
// to ensure workspace is properly determined
|
||||
const storedTabState = tabState.getOpenPaths()
|
||||
const storedWorkflows = storedTabState?.paths ?? []
|
||||
const storedActiveIndex = storedTabState?.activeIndex ?? -1
|
||||
|
||||
tabStateRestored = true
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -94,5 +94,21 @@ describe('useWorkflowTabState', () => {
|
||||
|
||||
expect(getOpenPaths()).toBeNull()
|
||||
})
|
||||
|
||||
it('retains paths when staying in same workspace', async () => {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.Workspace.Current',
|
||||
JSON.stringify({ type: 'team', id: 'ws-1' })
|
||||
)
|
||||
const { useWorkflowTabState } = await import('./useWorkflowTabState')
|
||||
const { setOpenPaths, getOpenPaths } = useWorkflowTabState()
|
||||
|
||||
setOpenPaths(['workflows/test.json'], 0)
|
||||
|
||||
// Simulate re-reading (same workspace, same clientId)
|
||||
const result = getOpenPaths()
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.paths).toEqual(['workflows/test.json'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,23 +27,17 @@ function getClientId(): string | null {
|
||||
* Composable for managing per-tab workflow state in sessionStorage.
|
||||
*/
|
||||
export function useWorkflowTabState() {
|
||||
const currentWorkspaceId = getWorkspaceId()
|
||||
|
||||
/**
|
||||
* Gets the active workflow path for the current tab.
|
||||
* Returns null if no pointer exists or workspaceId doesn't match.
|
||||
*/
|
||||
function getActivePath(): string | null {
|
||||
const clientId = getClientId()
|
||||
const workspaceId = getWorkspaceId()
|
||||
if (!clientId) return null
|
||||
|
||||
const pointer = readActivePath(clientId)
|
||||
if (!pointer) return null
|
||||
|
||||
// Validate workspace - ignore stale pointers from different workspace
|
||||
if (pointer.workspaceId !== currentWorkspaceId) return null
|
||||
|
||||
return pointer.path
|
||||
const pointer = readActivePath(clientId, workspaceId)
|
||||
return pointer?.path ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,10 +45,11 @@ export function useWorkflowTabState() {
|
||||
*/
|
||||
function setActivePath(path: string): void {
|
||||
const clientId = getClientId()
|
||||
const workspaceId = getWorkspaceId()
|
||||
if (!clientId) return
|
||||
|
||||
const pointer: ActivePathPointer = {
|
||||
workspaceId: currentWorkspaceId,
|
||||
workspaceId,
|
||||
path
|
||||
}
|
||||
writeActivePath(clientId, pointer)
|
||||
@@ -66,14 +61,12 @@ export function useWorkflowTabState() {
|
||||
*/
|
||||
function getOpenPaths(): { paths: string[]; activeIndex: number } | null {
|
||||
const clientId = getClientId()
|
||||
const workspaceId = getWorkspaceId()
|
||||
if (!clientId) return null
|
||||
|
||||
const pointer = readOpenPaths(clientId)
|
||||
const pointer = readOpenPaths(clientId, workspaceId)
|
||||
if (!pointer) return null
|
||||
|
||||
// Validate workspace
|
||||
if (pointer.workspaceId !== currentWorkspaceId) return null
|
||||
|
||||
return { paths: pointer.paths, activeIndex: pointer.activeIndex }
|
||||
}
|
||||
|
||||
@@ -82,10 +75,11 @@ export function useWorkflowTabState() {
|
||||
*/
|
||||
function setOpenPaths(paths: string[], activeIndex: number): void {
|
||||
const clientId = getClientId()
|
||||
const workspaceId = getWorkspaceId()
|
||||
if (!clientId) return
|
||||
|
||||
const pointer: OpenPathsPointer = {
|
||||
workspaceId: currentWorkspaceId,
|
||||
workspaceId,
|
||||
paths,
|
||||
activeIndex
|
||||
}
|
||||
|
||||
@@ -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
|
||||
if (!writeIndex(workspaceId, createEmptyIndex())) return -1
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -48,23 +48,34 @@ interface LoadPersistedWorkflowOptions {
|
||||
}
|
||||
|
||||
export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
|
||||
const workspaceId = getWorkspaceId()
|
||||
// In-memory cache of the index per workspace (synced with localStorage)
|
||||
// Key is workspaceId, value is the cached index
|
||||
const indexCacheByWorkspace = ref<Record<string, DraftIndexV2>>({})
|
||||
|
||||
// In-memory cache of the index (synced with localStorage)
|
||||
const indexCache = ref<DraftIndexV2 | null>(null)
|
||||
/**
|
||||
* Gets the current workspace ID fresh (not cached).
|
||||
* This ensures operations use the correct workspace after switches.
|
||||
*/
|
||||
function currentWorkspaceId(): string {
|
||||
return getWorkspaceId()
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the index from localStorage or creates empty.
|
||||
*/
|
||||
function loadIndex(): DraftIndexV2 {
|
||||
if (indexCache.value) return indexCache.value
|
||||
const workspaceId = currentWorkspaceId()
|
||||
|
||||
if (indexCacheByWorkspace.value[workspaceId]) {
|
||||
return indexCacheByWorkspace.value[workspaceId]
|
||||
}
|
||||
|
||||
const stored = readIndex(workspaceId)
|
||||
if (stored) {
|
||||
// Clean up any index/payload drift
|
||||
const payloadKeys = new Set(getPayloadKeys(workspaceId))
|
||||
const cleaned = removeOrphanedEntries(stored, payloadKeys)
|
||||
indexCache.value = cleaned
|
||||
indexCacheByWorkspace.value[workspaceId] = cleaned
|
||||
|
||||
// Also clean up orphan payloads
|
||||
const indexKeys = new Set(cleaned.order)
|
||||
@@ -73,15 +84,17 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
indexCache.value = createEmptyIndex()
|
||||
return indexCache.value
|
||||
const emptyIndex = createEmptyIndex()
|
||||
indexCacheByWorkspace.value[workspaceId] = emptyIndex
|
||||
return emptyIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the current index to localStorage.
|
||||
*/
|
||||
function persistIndex(index: DraftIndexV2): boolean {
|
||||
indexCache.value = index
|
||||
const workspaceId = currentWorkspaceId()
|
||||
indexCacheByWorkspace.value[workspaceId] = index
|
||||
return writeIndex(workspaceId, index)
|
||||
}
|
||||
|
||||
@@ -92,6 +105,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
|
||||
function saveDraft(path: string, data: string, meta: DraftMeta): boolean {
|
||||
if (!isStorageAvailable()) return false
|
||||
|
||||
const workspaceId = currentWorkspaceId()
|
||||
const draftKey = hashPath(path)
|
||||
const now = Date.now()
|
||||
|
||||
@@ -136,6 +150,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
|
||||
data: string,
|
||||
meta: DraftMeta
|
||||
): boolean {
|
||||
const workspaceId = currentWorkspaceId()
|
||||
const index = loadIndex()
|
||||
const draftKey = hashPath(path)
|
||||
|
||||
@@ -188,6 +203,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
|
||||
* Removes a draft.
|
||||
*/
|
||||
function removeDraft(path: string): void {
|
||||
const workspaceId = currentWorkspaceId()
|
||||
const index = loadIndex()
|
||||
const { index: newIndex, removedKey } = removeEntry(index, path)
|
||||
|
||||
@@ -201,6 +217,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
|
||||
* Moves a draft from one path to another (rename).
|
||||
*/
|
||||
function moveDraft(oldPath: string, newPath: string, name: string): void {
|
||||
const workspaceId = currentWorkspaceId()
|
||||
const index = loadIndex()
|
||||
const result = moveEntry(index, oldPath, newPath, name)
|
||||
|
||||
@@ -228,6 +245,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
|
||||
function getDraft(
|
||||
path: string
|
||||
): { data: string; name: string; isTemporary: boolean } | null {
|
||||
const workspaceId = currentWorkspaceId()
|
||||
const index = loadIndex()
|
||||
const entry = getEntryByPath(index, path)
|
||||
if (!entry) return null
|
||||
@@ -318,6 +336,13 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallbacks are NOT workspace-scoped and must only be used for
|
||||
// personal workspace to prevent cross-workspace data leakage.
|
||||
// These exist only for migration from V1 and should be removed after 2026-07-15.
|
||||
if (currentWorkspaceId() !== 'personal') {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Legacy fallback: sessionStorage payload (remove after 2026-07-15)
|
||||
const clientId = api.initialClientId ?? api.clientId
|
||||
if (clientId) {
|
||||
@@ -341,10 +366,11 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the store (clears in-memory cache).
|
||||
* Resets the store (clears in-memory cache for current workspace).
|
||||
*/
|
||||
function reset(): void {
|
||||
indexCache.value = null
|
||||
const workspaceId = currentWorkspaceId()
|
||||
delete indexCacheByWorkspace.value[workspaceId]
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user