mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 22:37:32 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
346
src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts
Normal file
346
src/platform/workflow/persistence/stores/workflowDraftStoreV2.ts
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user