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@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c16f4-05a2-779d-aa0e-a0e098308a95
This commit is contained in:
bymyself
2026-01-31 18:35:41 -08:00
parent 436fa0b85a
commit 0c88af0d42
4 changed files with 772 additions and 0 deletions

View File

@@ -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()
})
})
})

View File

@@ -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
}
}

View File

@@ -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()
})
})
})

View File

@@ -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<DraftIndexV2 | null>(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<boolean> {
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<boolean> {
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<boolean> {
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
}
})