From 40aa7c59740d5e291ae40171df94d6a50a1cd83a Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 21 Feb 2026 00:57:50 -0800 Subject: [PATCH] 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 Co-authored-by: GitHub Action Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com> --- browser_tests/tests/colorPalette.spec.ts | 18 +- browser_tests/tests/interaction.spec.ts | 34 ++- src/components/graph/GraphCanvas.vue | 2 +- .../auth/useFirebaseAuthActions.ts | 12 + src/locales/en/main.json | 4 +- .../workflow/persistence/base/draftCacheV2.ts | 12 +- .../workflow/persistence/base/draftTypes.ts | 1 - .../persistence/base/storageIO.test.ts | 93 ++++++ .../workflow/persistence/base/storageIO.ts | 97 ++++-- .../persistence/base/storageKeys.test.ts | 39 ++- .../workflow/persistence/base/storageKeys.ts | 23 +- .../composables/useWorkflowPersistenceV2.ts | 285 ++++++++++++++++++ .../composables/useWorkflowTabState.test.ts | 16 + .../composables/useWorkflowTabState.ts | 24 +- .../migration/migrateV1toV2.test.ts | 242 +++++++++++++++ .../persistence/migration/migrateV1toV2.ts | 157 ++++++++++ .../stores/workflowDraftStoreV2.ts | 46 ++- 17 files changed, 1016 insertions(+), 89 deletions(-) create mode 100644 src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts create mode 100644 src/platform/workflow/persistence/migration/migrateV1toV2.test.ts create mode 100644 src/platform/workflow/persistence/migration/migrateV1toV2.ts diff --git a/browser_tests/tests/colorPalette.spec.ts b/browser_tests/tests/colorPalette.spec.ts index 5ec6453c0..66312a7dc 100644 --- a/browser_tests/tests/colorPalette.spec.ts +++ b/browser_tests/tests/colorPalette.spec.ts @@ -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) { diff --git a/browser_tests/tests/interaction.spec.ts b/browser_tests/tests/interaction.spec.ts index f3ac5678b..82d203373 100644 --- a/browser_tests/tests/interaction.spec.ts +++ b/browser_tests/tests/interaction.spec.ts @@ -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 }) }) diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index d15eee0e8..0f9c6570b 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -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' diff --git a/src/composables/auth/useFirebaseAuthActions.ts b/src/composables/auth/useFirebaseAuthActions.ts index 802c7c92c..6da741e3a 100644 --- a/src/composables/auth/useFirebaseAuthActions.ts +++ b/src/composables/auth/useFirebaseAuthActions.ts @@ -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', diff --git a/src/locales/en/main.json b/src/locales/en/main.json index ac6d5a52f..5dcc00345 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/src/platform/workflow/persistence/base/draftCacheV2.ts b/src/platform/workflow/persistence/base/draftCacheV2.ts index 113d16fa8..55b3c90b5 100644 --- a/src/platform/workflow/persistence/base/draftCacheV2.ts +++ b/src/platform/workflow/persistence/base/draftCacheV2.ts @@ -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 diff --git a/src/platform/workflow/persistence/base/draftTypes.ts b/src/platform/workflow/persistence/base/draftTypes.ts index 51be15258..abb99d731 100644 --- a/src/platform/workflow/persistence/base/draftTypes.ts +++ b/src/platform/workflow/persistence/base/draftTypes.ts @@ -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 diff --git a/src/platform/workflow/persistence/base/storageIO.test.ts b/src/platform/workflow/persistence/base/storageIO.test.ts index 10543b776..bac277d86 100644 --- a/src/platform/workflow/persistence/base/storageIO.test.ts +++ b/src/platform/workflow/persistence/base/storageIO.test.ts @@ -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', () => { diff --git a/src/platform/workflow/persistence/base/storageIO.ts b/src/platform/workflow/persistence/base/storageIO.ts index a18eaa289..c09943022 100644 --- a/src/platform/workflow/persistence/base/storageIO.ts +++ b/src/platform/workflow/persistence/base/storageIO.ts @@ -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( + 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( + 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(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( + 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( + StorageKeys.openPaths(clientId), + StorageKeys.prefixes.openPaths, + targetWorkspaceId + ) } /** diff --git a/src/platform/workflow/persistence/base/storageKeys.test.ts b/src/platform/workflow/persistence/base/storageKeys.test.ts index 249cd180f..72fd7e659 100644 --- a/src/platform/workflow/persistence/base/storageKeys.test.ts +++ b/src/platform/workflow/persistence/base/storageKeys.test.ts @@ -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' ) }) diff --git a/src/platform/workflow/persistence/base/storageKeys.ts b/src/platform/workflow/persistence/base/storageKeys.ts index 4fb74aa35..18b943891 100644 --- a/src/platform/workflow/persistence/base/storageKeys.ts +++ b/src/platform/workflow/persistence/base/storageKeys.ts @@ -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}` }, diff --git a/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts b/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts new file mode 100644 index 000000000..ceec7d642 --- /dev/null +++ b/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts @@ -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>({}) + + 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 + } +} diff --git a/src/platform/workflow/persistence/composables/useWorkflowTabState.test.ts b/src/platform/workflow/persistence/composables/useWorkflowTabState.test.ts index 05e5218ca..d54c37d90 100644 --- a/src/platform/workflow/persistence/composables/useWorkflowTabState.test.ts +++ b/src/platform/workflow/persistence/composables/useWorkflowTabState.test.ts @@ -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']) + }) }) }) diff --git a/src/platform/workflow/persistence/composables/useWorkflowTabState.ts b/src/platform/workflow/persistence/composables/useWorkflowTabState.ts index e1dfc3888..f1eae2b8d 100644 --- a/src/platform/workflow/persistence/composables/useWorkflowTabState.ts +++ b/src/platform/workflow/persistence/composables/useWorkflowTabState.ts @@ -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 } diff --git a/src/platform/workflow/persistence/migration/migrateV1toV2.test.ts b/src/platform/workflow/persistence/migration/migrateV1toV2.test.ts new file mode 100644 index 000000000..40681560a --- /dev/null +++ b/src/platform/workflow/persistence/migration/migrateV1toV2.test.ts @@ -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) + }) + }) +}) diff --git a/src/platform/workflow/persistence/migration/migrateV1toV2.ts b/src/platform/workflow/persistence/migration/migrateV1toV2.ts new file mode 100644 index 000000000..b5b326a70 --- /dev/null +++ b/src/platform/workflow/persistence/migration/migrateV1toV2.ts @@ -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; 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 + 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 + } +} diff --git a/src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts b/src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts index ce426de24..03adf7a33 100644 --- a/src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts +++ b/src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts @@ -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>({}) - // In-memory cache of the index (synced with localStorage) - const indexCache = ref(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 {