mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-19 14:30:07 +00:00
feat(persistence): add workspace-scoped storage keys and types (#8517)
## Summary Adds the foundational types and key generation utilities for workspace-scoped workflow draft persistence. This enables storing drafts per-workspace to prevent data leakage between different ComfyUI instances. [Screencast from 2026-02-08 18-17-45.webm](https://github.com/user-attachments/assets/f16226e9-c1db-469d-a0b7-aa6af725db53) ## Changes - **What**: Type definitions for draft storage (`DraftIndexV2`, `DraftPayloadV2`, session pointers) and key generation utilities with workspace/client scoping - **Why**: The current persistence system stores all drafts globally, causing cross-workspace data leakage when users work with multiple ComfyUI instances --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
85
src/platform/workflow/persistence/base/draftTypes.ts
Normal file
85
src/platform/workflow/persistence/base/draftTypes.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* V2 Workflow Persistence Type Definitions
|
||||
*
|
||||
* Two-layer state system:
|
||||
* - sessionStorage: Per-tab pointers (tiny, scoped by clientId)
|
||||
* - localStorage: Persistent drafts (per-workspace, per-draft keys)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Metadata for a single draft entry stored in the index.
|
||||
* The actual workflow data is stored separately in a Draft payload key.
|
||||
*/
|
||||
export interface DraftEntryMeta {
|
||||
/** Workflow path (e.g., "workflows/Untitled.json") */
|
||||
path: string
|
||||
/** Display name of the workflow */
|
||||
name: string
|
||||
/** Whether this is an unsaved temporary workflow */
|
||||
isTemporary: boolean
|
||||
/** Last update timestamp (ms since epoch) */
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft index stored in localStorage.
|
||||
* Contains LRU order and metadata for all drafts in a workspace.
|
||||
*
|
||||
* Key: `Comfy.Workflow.DraftIndex.v2:${workspaceId}`
|
||||
*/
|
||||
export interface DraftIndexV2 {
|
||||
/** Schema version */
|
||||
v: 2
|
||||
/** Last update timestamp */
|
||||
updatedAt: number
|
||||
/** LRU order: oldest → newest (draftKey array) */
|
||||
order: string[]
|
||||
/** Metadata keyed by draftKey (hash of path) */
|
||||
entries: Record<string, DraftEntryMeta>
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual draft payload stored in localStorage.
|
||||
*
|
||||
* Key: `Comfy.Workflow.Draft.v2:${workspaceId}:${draftKey}`
|
||||
*/
|
||||
export interface DraftPayloadV2 {
|
||||
/** Serialized workflow JSON */
|
||||
data: string
|
||||
/** Last update timestamp */
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer stored in sessionStorage to track active workflow per tab.
|
||||
* Includes workspaceId for validation on read.
|
||||
*
|
||||
* Key: `Comfy.Workflow.ActivePath:${clientId}`
|
||||
*/
|
||||
export interface ActivePathPointer {
|
||||
/** Workspace ID for validation */
|
||||
workspaceId: string
|
||||
/** Path to the active workflow */
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer stored in sessionStorage to track open workflow tabs.
|
||||
* Includes workspaceId for validation on read.
|
||||
*
|
||||
* Key: `Comfy.Workflow.OpenPaths:${clientId}`
|
||||
*/
|
||||
export interface OpenPathsPointer {
|
||||
/** Workspace ID for validation */
|
||||
workspaceId: string
|
||||
/** Ordered list of open workflow paths */
|
||||
paths: string[]
|
||||
/** Index of the active workflow in paths array */
|
||||
activeIndex: number
|
||||
}
|
||||
|
||||
/** Maximum number of drafts to keep per workspace */
|
||||
export const MAX_DRAFTS = 32
|
||||
|
||||
/** Debounce delay for persisting graph changes (ms) */
|
||||
export const PERSIST_DEBOUNCE_MS = 512
|
||||
65
src/platform/workflow/persistence/base/hashUtil.test.ts
Normal file
65
src/platform/workflow/persistence/base/hashUtil.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { fnv1a, hashPath } from './hashUtil'
|
||||
|
||||
describe('fnv1a', () => {
|
||||
it('returns consistent hash for same input', () => {
|
||||
const hash1 = fnv1a('workflows/test.json')
|
||||
const hash2 = fnv1a('workflows/test.json')
|
||||
expect(hash1).toBe(hash2)
|
||||
})
|
||||
|
||||
it('returns different hashes for different inputs', () => {
|
||||
const hash1 = fnv1a('workflows/a.json')
|
||||
const hash2 = fnv1a('workflows/b.json')
|
||||
expect(hash1).not.toBe(hash2)
|
||||
})
|
||||
|
||||
it('returns unsigned 32-bit integer', () => {
|
||||
const hash = fnv1a('test')
|
||||
expect(hash).toBeGreaterThanOrEqual(0)
|
||||
expect(hash).toBeLessThanOrEqual(0xffffffff)
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
const hash = fnv1a('')
|
||||
expect(hash).toBe(2166136261)
|
||||
})
|
||||
|
||||
it('handles unicode characters', () => {
|
||||
const hash = fnv1a('workflows/工作流程.json')
|
||||
expect(hash).toBeGreaterThanOrEqual(0)
|
||||
expect(hash).toBeLessThanOrEqual(0xffffffff)
|
||||
})
|
||||
|
||||
it('handles special characters', () => {
|
||||
const hash = fnv1a('workflows/My Workflow (Copy 2).json')
|
||||
expect(hash).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hashPath', () => {
|
||||
it('returns 8-character hex string', () => {
|
||||
const result = hashPath('workflows/test.json')
|
||||
expect(result).toMatch(/^[0-9a-f]{8}$/)
|
||||
})
|
||||
|
||||
it('pads short hashes with leading zeros', () => {
|
||||
const result = hashPath('')
|
||||
expect(result).toHaveLength(8)
|
||||
expect(result).toBe('811c9dc5')
|
||||
})
|
||||
|
||||
it('returns consistent results', () => {
|
||||
const path = 'workflows/My Complex Workflow Name.json'
|
||||
const hash1 = hashPath(path)
|
||||
const hash2 = hashPath(path)
|
||||
expect(hash1).toBe(hash2)
|
||||
})
|
||||
|
||||
it('produces different hashes for similar paths', () => {
|
||||
const hash1 = hashPath('workflows/Untitled.json')
|
||||
const hash2 = hashPath('workflows/Untitled (2).json')
|
||||
expect(hash1).not.toBe(hash2)
|
||||
})
|
||||
})
|
||||
30
src/platform/workflow/persistence/base/hashUtil.ts
Normal file
30
src/platform/workflow/persistence/base/hashUtil.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* FNV-1a hash function for creating short, deterministic keys from strings.
|
||||
*
|
||||
* Used to create 8-character hex keys from workflow paths for localStorage keys.
|
||||
* FNV-1a is chosen for its simplicity, speed, and good distribution properties.
|
||||
*
|
||||
* @param str - The string to hash (typically a workflow path)
|
||||
* @returns A 32-bit unsigned integer hash
|
||||
*/
|
||||
export function fnv1a(str: string): number {
|
||||
let hash = 2166136261
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash ^= str.charCodeAt(i)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an 8-character hex key from a workflow path using FNV-1a hash.
|
||||
*
|
||||
* @param path - The workflow path (e.g., "workflows/My Workflow.json")
|
||||
* @returns An 8-character hex string (e.g., "a1b2c3d4")
|
||||
*
|
||||
* @example
|
||||
* hashPath("workflows/Untitled.json") // "1a2b3c4d"
|
||||
*/
|
||||
export function hashPath(path: string): string {
|
||||
return fnv1a(path).toString(16).padStart(8, '0')
|
||||
}
|
||||
94
src/platform/workflow/persistence/base/storageKeys.test.ts
Normal file
94
src/platform/workflow/persistence/base/storageKeys.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('storageKeys', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
describe('getWorkspaceId', () => {
|
||||
it('returns personal when no workspace is set', async () => {
|
||||
const { getWorkspaceId } = await import('./storageKeys')
|
||||
expect(getWorkspaceId()).toBe('personal')
|
||||
})
|
||||
|
||||
it('returns personal for personal workspace type', async () => {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.Workspace.Current',
|
||||
JSON.stringify({ type: 'personal', id: null })
|
||||
)
|
||||
const { getWorkspaceId } = await import('./storageKeys')
|
||||
expect(getWorkspaceId()).toBe('personal')
|
||||
})
|
||||
|
||||
it('returns workspace ID for team workspace', async () => {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.Workspace.Current',
|
||||
JSON.stringify({ type: 'team', id: 'ws-abc-123' })
|
||||
)
|
||||
const { getWorkspaceId } = await import('./storageKeys')
|
||||
expect(getWorkspaceId()).toBe('ws-abc-123')
|
||||
})
|
||||
|
||||
it('returns personal when JSON parsing fails', async () => {
|
||||
sessionStorage.setItem('Comfy.Workspace.Current', 'invalid-json')
|
||||
const { getWorkspaceId } = await import('./storageKeys')
|
||||
expect(getWorkspaceId()).toBe('personal')
|
||||
})
|
||||
})
|
||||
|
||||
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(
|
||||
'Comfy.Workflow.DraftIndex.v2:ws-123'
|
||||
)
|
||||
})
|
||||
|
||||
it('generates draftPayload key with hash', async () => {
|
||||
const { StorageKeys } = await import('./storageKeys')
|
||||
const key = StorageKeys.draftPayload('workflows/test.json', 'ws-1')
|
||||
|
||||
expect(key).toMatch(/^Comfy\.Workflow\.Draft\.v2:ws-1:[0-9a-f]{8}$/)
|
||||
})
|
||||
|
||||
it('generates consistent draftKey from path', async () => {
|
||||
const { StorageKeys } = await import('./storageKeys')
|
||||
const key1 = StorageKeys.draftKey('workflows/test.json')
|
||||
const key2 = StorageKeys.draftKey('workflows/test.json')
|
||||
|
||||
expect(key1).toBe(key2)
|
||||
expect(key1).toMatch(/^[0-9a-f]{8}$/)
|
||||
})
|
||||
|
||||
it('generates activePath key with clientId', async () => {
|
||||
const { StorageKeys } = await import('./storageKeys')
|
||||
expect(StorageKeys.activePath('client-abc')).toBe(
|
||||
'Comfy.Workflow.ActivePath:client-abc'
|
||||
)
|
||||
})
|
||||
|
||||
it('generates openPaths key with clientId', async () => {
|
||||
const { StorageKeys } = await import('./storageKeys')
|
||||
expect(StorageKeys.openPaths('client-abc')).toBe(
|
||||
'Comfy.Workflow.OpenPaths:client-abc'
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes prefix patterns for cleanup', async () => {
|
||||
const { StorageKeys } = await import('./storageKeys')
|
||||
|
||||
expect(StorageKeys.prefixes.draftIndex).toBe(
|
||||
'Comfy.Workflow.DraftIndex.v2:'
|
||||
)
|
||||
expect(StorageKeys.prefixes.draftPayload).toBe('Comfy.Workflow.Draft.v2:')
|
||||
expect(StorageKeys.prefixes.activePath).toBe('Comfy.Workflow.ActivePath:')
|
||||
expect(StorageKeys.prefixes.openPaths).toBe('Comfy.Workflow.OpenPaths:')
|
||||
})
|
||||
})
|
||||
})
|
||||
93
src/platform/workflow/persistence/base/storageKeys.ts
Normal file
93
src/platform/workflow/persistence/base/storageKeys.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
|
||||
import { hashPath } from './hashUtil'
|
||||
|
||||
/**
|
||||
* Gets the current workspace ID from sessionStorage.
|
||||
* Returns 'personal' for personal workspace or when no workspace is set.
|
||||
*/
|
||||
function getCurrentWorkspaceId(): string {
|
||||
try {
|
||||
const json = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE
|
||||
)
|
||||
if (!json) return 'personal'
|
||||
|
||||
const workspace = JSON.parse(json)
|
||||
if (workspace.type === 'personal' || !workspace.id) return 'personal'
|
||||
return workspace.id
|
||||
} catch {
|
||||
return 'personal'
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
*
|
||||
* localStorage keys are scoped by workspaceId.
|
||||
* sessionStorage keys are scoped by clientId.
|
||||
*/
|
||||
export const StorageKeys = {
|
||||
/**
|
||||
* Draft index key for localStorage.
|
||||
* Contains LRU order and metadata for all drafts.
|
||||
*/
|
||||
draftIndex(workspaceId: string = CURRENT_WORKSPACE_ID): string {
|
||||
return `Comfy.Workflow.DraftIndex.v2:${workspaceId}`
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const draftKey = hashPath(path)
|
||||
return `Comfy.Workflow.Draft.v2:${workspaceId}:${draftKey}`
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a draft key (hash) from a workflow path.
|
||||
*/
|
||||
draftKey(path: string): string {
|
||||
return hashPath(path)
|
||||
},
|
||||
|
||||
/**
|
||||
* Active workflow pointer key for sessionStorage.
|
||||
* @param clientId - Browser tab identifier from api.clientId
|
||||
*/
|
||||
activePath(clientId: string): string {
|
||||
return `Comfy.Workflow.ActivePath:${clientId}`
|
||||
},
|
||||
|
||||
/**
|
||||
* Open workflows pointer key for sessionStorage.
|
||||
* @param clientId - Browser tab identifier from api.clientId
|
||||
*/
|
||||
openPaths(clientId: string): string {
|
||||
return `Comfy.Workflow.OpenPaths:${clientId}`
|
||||
},
|
||||
|
||||
/**
|
||||
* Prefix patterns for cleanup operations.
|
||||
*/
|
||||
prefixes: {
|
||||
draftIndex: 'Comfy.Workflow.DraftIndex.v2:',
|
||||
draftPayload: 'Comfy.Workflow.Draft.v2:',
|
||||
activePath: 'Comfy.Workflow.ActivePath:',
|
||||
openPaths: 'Comfy.Workflow.OpenPaths:'
|
||||
}
|
||||
} as const
|
||||
Reference in New Issue
Block a user