Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/platform/workflow/persistence/base/draftTypes.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 65 additions & 0 deletions src/platform/workflow/persistence/base/hashUtil.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 30 additions & 0 deletions src/platform/workflow/persistence/base/hashUtil.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 94 additions & 0 deletions src/platform/workflow/persistence/base/storageKeys.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 93 additions & 0 deletions src/platform/workflow/persistence/base/storageKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'

Check failure on line 1 in src/platform/workflow/persistence/base/storageKeys.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

Unable to resolve path to module '@/platform/workspace/workspaceConstants'

Check failure on line 1 in src/platform/workflow/persistence/base/storageKeys.ts

View workflow job for this annotation

GitHub Actions / setup

Cannot find module '@/platform/workspace/workspaceConstants' or its corresponding type declarations.

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
Loading