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:
Christian Byrne
2026-02-18 19:31:24 -08:00
committed by GitHub
parent 2dbd7e86c3
commit 8ab9a7b887
5 changed files with 367 additions and 0 deletions

View 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

View 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)
})
})

View 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')
}

View 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:')
})
})
})

View 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