From 0c88af0d423da824cc4575eb0c0e48ef91c51fb7 Mon Sep 17 00:00:00 2001 From: bymyself Date: Sat, 31 Jan 2026 18:35:41 -0800 Subject: [PATCH] feat(persistence): add V2 store and tab state composable Add the V2 draft store and tab state management: - workflowDraftStoreV2: Uses per-draft localStorage keys instead of a single blob. Handles LRU eviction with loop on quota exceeded. Maintains in-memory index cache synced with localStorage. - useWorkflowTabState: Manages sessionStorage pointers scoped by api.clientId. Validates workspaceId on read to prevent cross-workspace contamination. The V2 store is not yet wired to the app - the old store remains active. This allows the integration to happen in a separate commit. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019c16f4-05a2-779d-aa0e-a0e098308a95 --- .../composables/useWorkflowTabState.test.ts | 98 +++++ .../composables/useWorkflowTabState.ts | 101 +++++ .../stores/workflowDraftStoreV2.test.ts | 227 ++++++++++++ .../stores/workflowDraftStoreV2.ts | 346 ++++++++++++++++++ 4 files changed, 772 insertions(+) create mode 100644 src/platform/workflow/persistence/composables/useWorkflowTabState.test.ts create mode 100644 src/platform/workflow/persistence/composables/useWorkflowTabState.ts create mode 100644 src/platform/workflow/persistence/stores/workflowDraftStoreV2.test.ts create mode 100644 src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts diff --git a/src/platform/workflow/persistence/composables/useWorkflowTabState.test.ts b/src/platform/workflow/persistence/composables/useWorkflowTabState.test.ts new file mode 100644 index 000000000..05e5218ca --- /dev/null +++ b/src/platform/workflow/persistence/composables/useWorkflowTabState.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/scripts/api', () => ({ + api: { + clientId: 'test-client-id', + initialClientId: 'test-client-id' + } +})) + +describe('useWorkflowTabState', () => { + beforeEach(() => { + vi.resetModules() + sessionStorage.clear() + }) + + describe('activePath', () => { + it('returns null when no pointer exists', async () => { + const { useWorkflowTabState } = await import('./useWorkflowTabState') + const { getActivePath } = useWorkflowTabState() + + expect(getActivePath()).toBeNull() + }) + + it('saves and retrieves active path', async () => { + const { useWorkflowTabState } = await import('./useWorkflowTabState') + const { getActivePath, setActivePath } = useWorkflowTabState() + + setActivePath('workflows/test.json') + expect(getActivePath()).toBe('workflows/test.json') + }) + + it('ignores pointer from different workspace', async () => { + sessionStorage.setItem( + 'Comfy.Workspace.Current', + JSON.stringify({ type: 'team', id: 'ws-1' }) + ) + const { useWorkflowTabState } = await import('./useWorkflowTabState') + const { setActivePath } = useWorkflowTabState() + setActivePath('workflows/test.json') + + vi.resetModules() + sessionStorage.setItem( + 'Comfy.Workspace.Current', + JSON.stringify({ type: 'team', id: 'ws-2' }) + ) + + const { useWorkflowTabState: useWorkflowTabState2 } = + await import('./useWorkflowTabState') + const { getActivePath } = useWorkflowTabState2() + + expect(getActivePath()).toBeNull() + }) + }) + + describe('openPaths', () => { + it('returns null when no pointer exists', async () => { + const { useWorkflowTabState } = await import('./useWorkflowTabState') + const { getOpenPaths } = useWorkflowTabState() + + expect(getOpenPaths()).toBeNull() + }) + + it('saves and retrieves open paths', async () => { + const { useWorkflowTabState } = await import('./useWorkflowTabState') + const { getOpenPaths, setOpenPaths } = useWorkflowTabState() + + const paths = ['workflows/a.json', 'workflows/b.json'] + setOpenPaths(paths, 1) + + const result = getOpenPaths() + expect(result).not.toBeNull() + expect(result!.paths).toEqual(paths) + expect(result!.activeIndex).toBe(1) + }) + + it('ignores pointer from different workspace', async () => { + sessionStorage.setItem( + 'Comfy.Workspace.Current', + JSON.stringify({ type: 'team', id: 'ws-1' }) + ) + const { useWorkflowTabState } = await import('./useWorkflowTabState') + const { setOpenPaths } = useWorkflowTabState() + setOpenPaths(['workflows/test.json'], 0) + + vi.resetModules() + sessionStorage.setItem( + 'Comfy.Workspace.Current', + JSON.stringify({ type: 'team', id: 'ws-2' }) + ) + + const { useWorkflowTabState: useWorkflowTabState2 } = + await import('./useWorkflowTabState') + const { getOpenPaths } = useWorkflowTabState2() + + expect(getOpenPaths()).toBeNull() + }) + }) +}) diff --git a/src/platform/workflow/persistence/composables/useWorkflowTabState.ts b/src/platform/workflow/persistence/composables/useWorkflowTabState.ts new file mode 100644 index 000000000..e1dfc3888 --- /dev/null +++ b/src/platform/workflow/persistence/composables/useWorkflowTabState.ts @@ -0,0 +1,101 @@ +/** + * Tab State Management - Per-tab workflow pointers in sessionStorage. + * + * Uses api.clientId to scope pointers per browser tab. + * Includes workspaceId for validation to prevent cross-workspace contamination. + */ + +import type { ActivePathPointer, OpenPathsPointer } from '../base/draftTypes' +import { getWorkspaceId } from '../base/storageKeys' +import { + readActivePath, + readOpenPaths, + writeActivePath, + writeOpenPaths +} from '../base/storageIO' +import { api } from '@/scripts/api' + +/** + * Gets the current client ID for browser tab identification. + * Falls back to initialClientId if clientId is not yet set. + */ +function getClientId(): string | null { + return api.clientId ?? api.initialClientId ?? 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() + 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 + } + + /** + * Sets the active workflow path for the current tab. + */ + function setActivePath(path: string): void { + const clientId = getClientId() + if (!clientId) return + + const pointer: ActivePathPointer = { + workspaceId: currentWorkspaceId, + path + } + writeActivePath(clientId, pointer) + } + + /** + * Gets the open workflow paths for the current tab. + * Returns null if no pointer exists or workspaceId doesn't match. + */ + function getOpenPaths(): { paths: string[]; activeIndex: number } | null { + const clientId = getClientId() + if (!clientId) return null + + const pointer = readOpenPaths(clientId) + if (!pointer) return null + + // Validate workspace + if (pointer.workspaceId !== currentWorkspaceId) return null + + return { paths: pointer.paths, activeIndex: pointer.activeIndex } + } + + /** + * Sets the open workflow paths for the current tab. + */ + function setOpenPaths(paths: string[], activeIndex: number): void { + const clientId = getClientId() + if (!clientId) return + + const pointer: OpenPathsPointer = { + workspaceId: currentWorkspaceId, + paths, + activeIndex + } + writeOpenPaths(clientId, pointer) + } + + return { + getActivePath, + setActivePath, + getOpenPaths, + setOpenPaths + } +} diff --git a/src/platform/workflow/persistence/stores/workflowDraftStoreV2.test.ts b/src/platform/workflow/persistence/stores/workflowDraftStoreV2.test.ts new file mode 100644 index 000000000..fd792b2f6 --- /dev/null +++ b/src/platform/workflow/persistence/stores/workflowDraftStoreV2.test.ts @@ -0,0 +1,227 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useWorkflowDraftStoreV2 } from './workflowDraftStoreV2' + +vi.mock('@/scripts/api', () => ({ + api: { + clientId: 'test-client', + initialClientId: 'test-client' + } +})) + +vi.mock('@/scripts/app', () => ({ + app: { + loadGraphData: vi.fn().mockResolvedValue(undefined) + } +})) + +describe('workflowDraftStoreV2', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + localStorage.clear() + sessionStorage.clear() + vi.clearAllMocks() + }) + + afterEach(() => { + localStorage.clear() + sessionStorage.clear() + }) + + describe('saveDraft', () => { + it('saves draft to localStorage with separate payload', () => { + const store = useWorkflowDraftStoreV2() + + const result = store.saveDraft('workflows/test.json', '{"nodes":[]}', { + name: 'test', + isTemporary: true + }) + + expect(result).toBe(true) + + // Verify index exists + const indexKey = 'Comfy.Workflow.DraftIndex.v2:personal' + const indexJson = localStorage.getItem(indexKey) + expect(indexJson).not.toBeNull() + + const index = JSON.parse(indexJson!) + expect(index.v).toBe(2) + expect(index.order).toHaveLength(1) + + // Verify payload exists separately + const payloadKeys = Object.keys(localStorage).filter((k) => + k.startsWith('Comfy.Workflow.Draft.v2:personal:') + ) + expect(payloadKeys).toHaveLength(1) + }) + + it('updates existing draft', () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/test.json', '{"nodes":[]}', { + name: 'test', + isTemporary: true + }) + + store.saveDraft('workflows/test.json', '{"nodes":[1,2,3]}', { + name: 'test-updated', + isTemporary: false + }) + + const draft = store.getDraft('workflows/test.json') + expect(draft).not.toBeNull() + expect(draft!.data).toBe('{"nodes":[1,2,3]}') + expect(draft!.name).toBe('test-updated') + expect(draft!.isTemporary).toBe(false) + }) + + it('evicts oldest when over limit', () => { + const store = useWorkflowDraftStoreV2() + + // Save 32 drafts (MAX_DRAFTS) + for (let i = 0; i < 32; i++) { + store.saveDraft(`workflows/draft${i}.json`, `{"id":${i}}`, { + name: `draft${i}`, + isTemporary: true + }) + } + + // Save one more + store.saveDraft('workflows/new.json', '{"id":"new"}', { + name: 'new', + isTemporary: true + }) + + // First draft should be evicted + expect(store.getDraft('workflows/draft0.json')).toBeNull() + expect(store.getDraft('workflows/new.json')).not.toBeNull() + }) + }) + + describe('removeDraft', () => { + it('removes draft from index and payload', () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/test.json', '{}', { + name: 'test', + isTemporary: true + }) + expect(store.getDraft('workflows/test.json')).not.toBeNull() + + store.removeDraft('workflows/test.json') + expect(store.getDraft('workflows/test.json')).toBeNull() + + // Verify payload is deleted + const payloadKeys = Object.keys(localStorage).filter((k) => + k.startsWith('Comfy.Workflow.Draft.v2:personal:') + ) + expect(payloadKeys).toHaveLength(0) + }) + }) + + describe('moveDraft', () => { + it('moves draft to new path with new name', () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/old.json', '{"data":"test"}', { + name: 'old', + isTemporary: true + }) + + store.moveDraft('workflows/old.json', 'workflows/new.json', 'new') + + expect(store.getDraft('workflows/old.json')).toBeNull() + + const newDraft = store.getDraft('workflows/new.json') + expect(newDraft).not.toBeNull() + expect(newDraft!.name).toBe('new') + expect(newDraft!.data).toBe('{"data":"test"}') + }) + }) + + describe('getMostRecentPath', () => { + it('returns most recently saved path', () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/a.json', '{}', { + name: 'a', + isTemporary: true + }) + store.saveDraft('workflows/b.json', '{}', { + name: 'b', + isTemporary: true + }) + + expect(store.getMostRecentPath()).toBe('workflows/b.json') + }) + + it('returns null when no drafts', () => { + const store = useWorkflowDraftStoreV2() + expect(store.getMostRecentPath()).toBeNull() + }) + }) + + describe('loadPersistedWorkflow', () => { + it('loads from preferred path when available', async () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/test.json', '{"nodes":[]}', { + name: 'test', + isTemporary: true + }) + + const result = await store.loadPersistedWorkflow({ + workflowName: 'test', + preferredPath: 'workflows/test.json' + }) + + expect(result).toBe(true) + }) + + it('falls back to most recent when preferredPath missing', async () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/recent.json', '{"nodes":[]}', { + name: 'recent', + isTemporary: true + }) + + const result = await store.loadPersistedWorkflow({ + workflowName: null, + preferredPath: 'workflows/missing.json', + fallbackToLatestDraft: true + }) + + expect(result).toBe(true) + }) + + it('returns false when no drafts available', async () => { + const store = useWorkflowDraftStoreV2() + + const result = await store.loadPersistedWorkflow({ + workflowName: null, + fallbackToLatestDraft: true + }) + + expect(result).toBe(false) + }) + }) + + describe('reset', () => { + it('clears in-memory cache', () => { + const store = useWorkflowDraftStoreV2() + + store.saveDraft('workflows/test.json', '{}', { + name: 'test', + isTemporary: true + }) + + store.reset() + + // Draft should still be loadable from localStorage + expect(store.getDraft('workflows/test.json')).not.toBeNull() + }) + }) +}) diff --git a/src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts b/src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts new file mode 100644 index 000000000..092bca7b1 --- /dev/null +++ b/src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts @@ -0,0 +1,346 @@ +/** + * V2 Workflow Draft Store + * + * Uses per-draft keys in localStorage instead of a single blob. + * Handles LRU eviction and quota management. + */ + +import { defineStore } from 'pinia' +import { ref } from 'vue' + +import type { DraftIndexV2 } from '../base/draftTypes' +import { MAX_DRAFTS } from '../base/draftTypes' +import { + createEmptyIndex, + getEntryByPath, + getMostRecentKey, + moveEntry, + removeEntry, + removeOrphanedEntries, + upsertEntry +} from '../base/draftCacheV2' +import { hashPath } from '../base/hashUtil' +import { getWorkspaceId } from '../base/storageKeys' +import { + deleteOrphanPayloads, + deletePayload, + deletePayloads, + getPayloadKeys, + isStorageAvailable, + markStorageUnavailable, + readIndex, + readPayload, + writeIndex, + writePayload +} from '../base/storageIO' +import { api } from '@/scripts/api' +import { app as comfyApp } from '@/scripts/app' + +interface DraftMeta { + name: string + isTemporary: boolean +} + +interface LoadPersistedWorkflowOptions { + workflowName: string | null + preferredPath?: string | null + fallbackToLatestDraft?: boolean +} + +export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => { + const workspaceId = getWorkspaceId() + + // In-memory cache of the index (synced with localStorage) + const indexCache = ref(null) + + /** + * Loads the index from localStorage or creates empty. + */ + function loadIndex(): DraftIndexV2 { + if (indexCache.value) return indexCache.value + + 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 + + // Also clean up orphan payloads + const indexKeys = new Set(cleaned.order) + deleteOrphanPayloads(workspaceId, indexKeys) + + return cleaned + } + + indexCache.value = createEmptyIndex() + return indexCache.value + } + + /** + * Persists the current index to localStorage. + */ + function persistIndex(index: DraftIndexV2): boolean { + indexCache.value = index + return writeIndex(workspaceId, index) + } + + /** + * Saves a draft (data + metadata). + * Writes payload first, then updates index. + */ + function saveDraft(path: string, data: string, meta: DraftMeta): boolean { + if (!isStorageAvailable()) return false + + const draftKey = hashPath(path) + const now = Date.now() + + // Write payload first (before index update) + const payloadWritten = writePayload(workspaceId, draftKey, { + data, + updatedAt: now + }) + + if (!payloadWritten) { + // Quota exceeded - try eviction loop + return handleQuotaExceeded(path, data, meta) + } + + // Update index + const index = loadIndex() + const { index: newIndex, evicted } = upsertEntry( + index, + path, + { ...meta, updatedAt: now }, + MAX_DRAFTS + ) + + // Delete evicted payloads + deletePayloads(workspaceId, evicted) + + // Persist index + if (!persistIndex(newIndex)) { + // Index write failed - try to recover + deletePayload(workspaceId, draftKey) + return false + } + + return true + } + + /** + * Handles quota exceeded by evicting oldest drafts until write succeeds. + */ + function handleQuotaExceeded( + path: string, + data: string, + meta: DraftMeta + ): boolean { + const index = loadIndex() + const draftKey = hashPath(path) + + // Try evicting oldest entries until we can write + let currentIndex = index + while (currentIndex.order.length > 0) { + const oldestKey = currentIndex.order[0] + if (oldestKey === draftKey) break // Don't evict the one we're trying to save + + // Evict oldest + const oldestEntry = Object.values(currentIndex.entries).find( + (e) => hashPath(e.path) === oldestKey + ) + if (!oldestEntry) break + + const result = removeEntry(currentIndex, oldestEntry.path) + currentIndex = result.index + if (result.removedKey) { + deletePayload(workspaceId, result.removedKey) + } + + // Try writing again + const success = writePayload(workspaceId, draftKey, { + data, + updatedAt: Date.now() + }) + + if (success) { + // Update index with the new entry + const { index: finalIndex } = upsertEntry( + currentIndex, + path, + { ...meta, updatedAt: Date.now() }, + MAX_DRAFTS + ) + persistIndex(finalIndex) + return true + } + } + + // All evictions failed - mark storage as unavailable + markStorageUnavailable() + return false + } + + /** + * Removes a draft. + */ + function removeDraft(path: string): void { + const index = loadIndex() + const { index: newIndex, removedKey } = removeEntry(index, path) + + if (removedKey) { + deletePayload(workspaceId, removedKey) + persistIndex(newIndex) + } + } + + /** + * Moves a draft from one path to another (rename). + */ + function moveDraft(oldPath: string, newPath: string, name: string): void { + const index = loadIndex() + const result = moveEntry(index, oldPath, newPath, name) + + if (result) { + // Read old payload + const oldPayload = readPayload(workspaceId, result.oldKey) + if (oldPayload) { + // Write to new key + writePayload(workspaceId, result.newKey, { + data: oldPayload.data, + updatedAt: Date.now() + }) + // Delete old key + deletePayload(workspaceId, result.oldKey) + } + persistIndex(result.index) + } + } + + /** + * Gets draft data by path. + */ + function getDraft( + path: string + ): { data: string; name: string; isTemporary: boolean } | null { + const index = loadIndex() + const entry = getEntryByPath(index, path) + if (!entry) return null + + const draftKey = hashPath(path) + const payload = readPayload(workspaceId, draftKey) + if (!payload) { + // Payload missing - clean up index + removeDraft(path) + return null + } + + return { + data: payload.data, + name: entry.name, + isTemporary: entry.isTemporary + } + } + + /** + * Gets the most recent draft path. + */ + function getMostRecentPath(): string | null { + const index = loadIndex() + const key = getMostRecentKey(index) + if (!key) return null + + const entry = index.entries[key] + return entry?.path ?? null + } + + /** + * Tries to load workflow data into the graph. + */ + async function tryLoadGraph( + payload: string | null, + workflowName: string | null, + onFailure?: () => void + ): Promise { + if (!payload) return false + try { + const workflow = JSON.parse(payload) + await comfyApp.loadGraphData(workflow, true, true, workflowName) + return true + } catch (err) { + console.error('Failed to load persisted workflow', err) + onFailure?.() + return false + } + } + + /** + * Loads a draft into the graph. + */ + async function loadDraft(path: string): Promise { + const draft = getDraft(path) + if (!draft) return false + + const loaded = await tryLoadGraph(draft.data, draft.name, () => { + removeDraft(path) + }) + + return loaded + } + + /** + * Loads a persisted workflow with fallback chain. + */ + async function loadPersistedWorkflow( + options: LoadPersistedWorkflowOptions + ): Promise { + const { + workflowName, + preferredPath, + fallbackToLatestDraft = false + } = options + + // 1. Try preferred path + if (preferredPath && (await loadDraft(preferredPath))) { + return true + } + + // 2. Fall back to most recent draft + if (fallbackToLatestDraft) { + const mostRecent = getMostRecentPath() + if (mostRecent && (await loadDraft(mostRecent))) { + return true + } + } + + // 3. Legacy fallback: sessionStorage payload (remove after 2026-07-15) + const clientId = api.initialClientId ?? api.clientId + if (clientId) { + const sessionPayload = sessionStorage.getItem(`workflow:${clientId}`) + if (await tryLoadGraph(sessionPayload, workflowName)) { + return true + } + } + + // 4. Legacy fallback: localStorage payload (remove after 2026-07-15) + const localPayload = localStorage.getItem('workflow') + return await tryLoadGraph(localPayload, workflowName) + } + + /** + * Resets the store (clears in-memory cache). + */ + function reset(): void { + indexCache.value = null + } + + return { + saveDraft, + removeDraft, + moveDraft, + getDraft, + getMostRecentPath, + loadPersistedWorkflow, + reset + } +})