From 8ab9a7b887587ab164776ddbed2848d37a6e62da Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Wed, 18 Feb 2026 19:31:24 -0800 Subject: [PATCH] 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 --- .../workflow/persistence/base/draftTypes.ts | 85 +++++++++++++++++ .../persistence/base/hashUtil.test.ts | 65 +++++++++++++ .../workflow/persistence/base/hashUtil.ts | 30 ++++++ .../persistence/base/storageKeys.test.ts | 94 +++++++++++++++++++ .../workflow/persistence/base/storageKeys.ts | 93 ++++++++++++++++++ 5 files changed, 367 insertions(+) create mode 100644 src/platform/workflow/persistence/base/draftTypes.ts create mode 100644 src/platform/workflow/persistence/base/hashUtil.test.ts create mode 100644 src/platform/workflow/persistence/base/hashUtil.ts create mode 100644 src/platform/workflow/persistence/base/storageKeys.test.ts create mode 100644 src/platform/workflow/persistence/base/storageKeys.ts diff --git a/src/platform/workflow/persistence/base/draftTypes.ts b/src/platform/workflow/persistence/base/draftTypes.ts new file mode 100644 index 0000000000..e2ffcaeca5 --- /dev/null +++ b/src/platform/workflow/persistence/base/draftTypes.ts @@ -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 +} + +/** + * 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 diff --git a/src/platform/workflow/persistence/base/hashUtil.test.ts b/src/platform/workflow/persistence/base/hashUtil.test.ts new file mode 100644 index 0000000000..30595b2876 --- /dev/null +++ b/src/platform/workflow/persistence/base/hashUtil.test.ts @@ -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) + }) +}) diff --git a/src/platform/workflow/persistence/base/hashUtil.ts b/src/platform/workflow/persistence/base/hashUtil.ts new file mode 100644 index 0000000000..85fe99368c --- /dev/null +++ b/src/platform/workflow/persistence/base/hashUtil.ts @@ -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') +} diff --git a/src/platform/workflow/persistence/base/storageKeys.test.ts b/src/platform/workflow/persistence/base/storageKeys.test.ts new file mode 100644 index 0000000000..249cd180fa --- /dev/null +++ b/src/platform/workflow/persistence/base/storageKeys.test.ts @@ -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:') + }) + }) +}) diff --git a/src/platform/workflow/persistence/base/storageKeys.ts b/src/platform/workflow/persistence/base/storageKeys.ts new file mode 100644 index 0000000000..4fb74aa354 --- /dev/null +++ b/src/platform/workflow/persistence/base/storageKeys.ts @@ -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