mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-23 08:14:06 +00:00
feat(persistence): add LRU draft cache with quota management (#8518)
## Summary Adds an LRU (Least Recently Used) cache layer and storage I/O utilities that handle localStorage quota limits gracefully. When storage is full, the oldest drafts are automatically evicted to make room for new ones. ## Changes - **What**: - `draftCacheV2.ts` - In-memory LRU cache with configurable max entries (default 32) - `storageIO.ts` - Storage read/write with automatic quota management and eviction - **Why**: Users experience `QuotaExceededError` when localStorage fills up with workflow drafts, breaking auto-save functionality ## Review Focus - LRU eviction logic in `draftCacheV2.ts` - Quota error handling and recovery in `storageIO.ts` --- *Part 2 of 4 in the workflow persistence improvements stack* --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -64,7 +64,8 @@ const config: KnipConfig = {
|
||||
},
|
||||
tags: [
|
||||
'-knipIgnoreUnusedButUsedByCustomNodes',
|
||||
'-knipIgnoreUnusedButUsedByVueNodesBranch'
|
||||
'-knipIgnoreUnusedButUsedByVueNodesBranch',
|
||||
'-knipIgnoreUsedByStackedPR'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
338
src/platform/workflow/persistence/base/draftCacheV2.test.ts
Normal file
338
src/platform/workflow/persistence/base/draftCacheV2.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createEmptyIndex,
|
||||
getEntryByPath,
|
||||
getMostRecentKey,
|
||||
moveEntry,
|
||||
removeEntry,
|
||||
removeOrphanedEntries,
|
||||
touchOrder,
|
||||
upsertEntry
|
||||
} from './draftCacheV2'
|
||||
import { hashPath } from './hashUtil'
|
||||
|
||||
describe('draftCacheV2', () => {
|
||||
describe('createEmptyIndex', () => {
|
||||
it('creates index with version 2', () => {
|
||||
const index = createEmptyIndex()
|
||||
expect(index.v).toBe(2)
|
||||
expect(index.order).toEqual([])
|
||||
expect(index.entries).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('touchOrder', () => {
|
||||
it('moves existing key to end', () => {
|
||||
expect(touchOrder(['a', 'b', 'c'], 'a')).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('adds new key to end', () => {
|
||||
expect(touchOrder(['a', 'b'], 'c')).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('handles empty order', () => {
|
||||
expect(touchOrder([], 'a')).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertEntry', () => {
|
||||
it('adds new entry to empty index', () => {
|
||||
const index = createEmptyIndex()
|
||||
const { index: updated, evicted } = upsertEntry(
|
||||
index,
|
||||
'workflows/a.json',
|
||||
{
|
||||
name: 'a',
|
||||
isTemporary: true,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
)
|
||||
|
||||
expect(updated.order).toHaveLength(1)
|
||||
expect(Object.keys(updated.entries)).toHaveLength(1)
|
||||
expect(evicted).toEqual([])
|
||||
})
|
||||
|
||||
it('updates existing entry and moves to end of order', () => {
|
||||
let index = createEmptyIndex()
|
||||
index = upsertEntry(index, 'workflows/a.json', {
|
||||
name: 'a',
|
||||
isTemporary: true,
|
||||
updatedAt: 1000
|
||||
}).index
|
||||
index = upsertEntry(index, 'workflows/b.json', {
|
||||
name: 'b',
|
||||
isTemporary: true,
|
||||
updatedAt: 2000
|
||||
}).index
|
||||
|
||||
const keyA = hashPath('workflows/a.json')
|
||||
const keyB = hashPath('workflows/b.json')
|
||||
expect(index.order).toEqual([keyA, keyB])
|
||||
|
||||
const { index: updated } = upsertEntry(index, 'workflows/a.json', {
|
||||
name: 'a-updated',
|
||||
isTemporary: false,
|
||||
updatedAt: 3000
|
||||
})
|
||||
|
||||
expect(updated.order).toEqual([keyB, keyA])
|
||||
expect(updated.entries[keyA].name).toBe('a-updated')
|
||||
expect(updated.entries[keyA].isTemporary).toBe(false)
|
||||
})
|
||||
|
||||
it('evicts oldest entries when over limit', () => {
|
||||
let index = createEmptyIndex()
|
||||
const limit = 3
|
||||
|
||||
for (let i = 0; i < limit; i++) {
|
||||
index = upsertEntry(
|
||||
index,
|
||||
`workflows/draft${i}.json`,
|
||||
{ name: `draft${i}`, isTemporary: true, updatedAt: i },
|
||||
limit
|
||||
).index
|
||||
}
|
||||
|
||||
expect(index.order).toHaveLength(3)
|
||||
|
||||
const { index: updated, evicted } = upsertEntry(
|
||||
index,
|
||||
'workflows/new.json',
|
||||
{ name: 'new', isTemporary: true, updatedAt: 100 },
|
||||
limit
|
||||
)
|
||||
|
||||
expect(updated.order).toHaveLength(3)
|
||||
expect(evicted).toHaveLength(1)
|
||||
expect(evicted[0]).toBe(hashPath('workflows/draft0.json'))
|
||||
})
|
||||
|
||||
it('does not evict the entry being upserted', () => {
|
||||
let index = createEmptyIndex()
|
||||
|
||||
index = upsertEntry(
|
||||
index,
|
||||
'workflows/only.json',
|
||||
{ name: 'only', isTemporary: true, updatedAt: 1000 },
|
||||
1
|
||||
).index
|
||||
|
||||
const { index: updated, evicted } = upsertEntry(
|
||||
index,
|
||||
'workflows/only.json',
|
||||
{ name: 'only-updated', isTemporary: true, updatedAt: 2000 },
|
||||
1
|
||||
)
|
||||
|
||||
expect(updated.order).toHaveLength(1)
|
||||
expect(evicted).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeEntry', () => {
|
||||
it('removes existing entry', () => {
|
||||
let index = createEmptyIndex()
|
||||
index = upsertEntry(index, 'workflows/test.json', {
|
||||
name: 'test',
|
||||
isTemporary: true,
|
||||
updatedAt: Date.now()
|
||||
}).index
|
||||
|
||||
const { index: updated, removedKey } = removeEntry(
|
||||
index,
|
||||
'workflows/test.json'
|
||||
)
|
||||
|
||||
expect(updated.order).toHaveLength(0)
|
||||
expect(Object.keys(updated.entries)).toHaveLength(0)
|
||||
expect(removedKey).toBe(hashPath('workflows/test.json'))
|
||||
})
|
||||
|
||||
it('returns null for non-existent path', () => {
|
||||
const index = createEmptyIndex()
|
||||
const { index: updated, removedKey } = removeEntry(
|
||||
index,
|
||||
'workflows/missing.json'
|
||||
)
|
||||
|
||||
expect(updated).toBe(index)
|
||||
expect(removedKey).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveEntry', () => {
|
||||
it('moves entry to new path with new name', () => {
|
||||
let index = createEmptyIndex()
|
||||
index = upsertEntry(index, 'workflows/old.json', {
|
||||
name: 'old',
|
||||
isTemporary: true,
|
||||
updatedAt: 1000
|
||||
}).index
|
||||
|
||||
const result = moveEntry(
|
||||
index,
|
||||
'workflows/old.json',
|
||||
'workflows/new.json',
|
||||
'new'
|
||||
)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.index.entries[result!.newKey].name).toBe('new')
|
||||
expect(result!.index.entries[result!.newKey].path).toBe(
|
||||
'workflows/new.json'
|
||||
)
|
||||
expect(result!.index.entries[result!.oldKey]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns null for non-existent source', () => {
|
||||
const index = createEmptyIndex()
|
||||
const result = moveEntry(
|
||||
index,
|
||||
'workflows/missing.json',
|
||||
'workflows/new.json',
|
||||
'new'
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when destination path already exists', () => {
|
||||
let index = createEmptyIndex()
|
||||
index = upsertEntry(index, 'workflows/a.json', {
|
||||
name: 'a',
|
||||
isTemporary: true,
|
||||
updatedAt: 1000
|
||||
}).index
|
||||
index = upsertEntry(index, 'workflows/b.json', {
|
||||
name: 'b',
|
||||
isTemporary: true,
|
||||
updatedAt: 2000
|
||||
}).index
|
||||
|
||||
const result = moveEntry(
|
||||
index,
|
||||
'workflows/a.json',
|
||||
'workflows/b.json',
|
||||
'b-renamed'
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('moves entry to end of order', () => {
|
||||
let index = createEmptyIndex()
|
||||
index = upsertEntry(index, 'workflows/a.json', {
|
||||
name: 'a',
|
||||
isTemporary: true,
|
||||
updatedAt: 1000
|
||||
}).index
|
||||
index = upsertEntry(index, 'workflows/b.json', {
|
||||
name: 'b',
|
||||
isTemporary: true,
|
||||
updatedAt: 2000
|
||||
}).index
|
||||
|
||||
const result = moveEntry(
|
||||
index,
|
||||
'workflows/a.json',
|
||||
'workflows/c.json',
|
||||
'c'
|
||||
)
|
||||
|
||||
const keyB = hashPath('workflows/b.json')
|
||||
const keyC = hashPath('workflows/c.json')
|
||||
expect(result!.index.order).toEqual([keyB, keyC])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMostRecentKey', () => {
|
||||
it('returns last key in order', () => {
|
||||
let index = createEmptyIndex()
|
||||
index = upsertEntry(index, 'workflows/a.json', {
|
||||
name: 'a',
|
||||
isTemporary: true,
|
||||
updatedAt: 1000
|
||||
}).index
|
||||
index = upsertEntry(index, 'workflows/b.json', {
|
||||
name: 'b',
|
||||
isTemporary: true,
|
||||
updatedAt: 2000
|
||||
}).index
|
||||
|
||||
expect(getMostRecentKey(index)).toBe(hashPath('workflows/b.json'))
|
||||
})
|
||||
|
||||
it('returns null for empty index', () => {
|
||||
expect(getMostRecentKey(createEmptyIndex())).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEntryByPath', () => {
|
||||
it('returns entry for existing path', () => {
|
||||
let index = createEmptyIndex()
|
||||
index = upsertEntry(index, 'workflows/test.json', {
|
||||
name: 'test',
|
||||
isTemporary: true,
|
||||
updatedAt: 1000
|
||||
}).index
|
||||
|
||||
const entry = getEntryByPath(index, 'workflows/test.json')
|
||||
expect(entry).not.toBeNull()
|
||||
expect(entry!.name).toBe('test')
|
||||
})
|
||||
|
||||
it('returns null for missing path', () => {
|
||||
const index = createEmptyIndex()
|
||||
expect(getEntryByPath(index, 'workflows/missing.json')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeOrphanedEntries', () => {
|
||||
it('removes entries without payloads', () => {
|
||||
let index = createEmptyIndex()
|
||||
index = upsertEntry(index, 'workflows/a.json', {
|
||||
name: 'a',
|
||||
isTemporary: true,
|
||||
updatedAt: 1000
|
||||
}).index
|
||||
index = upsertEntry(index, 'workflows/b.json', {
|
||||
name: 'b',
|
||||
isTemporary: true,
|
||||
updatedAt: 2000
|
||||
}).index
|
||||
|
||||
const existingKeys = new Set([hashPath('workflows/a.json')])
|
||||
const cleaned = removeOrphanedEntries(index, existingKeys)
|
||||
|
||||
expect(cleaned.order).toHaveLength(1)
|
||||
expect(Object.keys(cleaned.entries)).toHaveLength(1)
|
||||
expect(cleaned.entries[hashPath('workflows/a.json')]).toBeDefined()
|
||||
})
|
||||
|
||||
it('preserves order of remaining entries', () => {
|
||||
let index = createEmptyIndex()
|
||||
index = upsertEntry(index, 'workflows/a.json', {
|
||||
name: 'a',
|
||||
isTemporary: true,
|
||||
updatedAt: 1000
|
||||
}).index
|
||||
index = upsertEntry(index, 'workflows/b.json', {
|
||||
name: 'b',
|
||||
isTemporary: true,
|
||||
updatedAt: 2000
|
||||
}).index
|
||||
index = upsertEntry(index, 'workflows/c.json', {
|
||||
name: 'c',
|
||||
isTemporary: true,
|
||||
updatedAt: 3000
|
||||
}).index
|
||||
|
||||
const keyA = hashPath('workflows/a.json')
|
||||
const keyC = hashPath('workflows/c.json')
|
||||
const existingKeys = new Set([keyA, keyC])
|
||||
const cleaned = removeOrphanedEntries(index, existingKeys)
|
||||
|
||||
expect(cleaned.order).toEqual([keyA, keyC])
|
||||
})
|
||||
})
|
||||
})
|
||||
193
src/platform/workflow/persistence/base/draftCacheV2.ts
Normal file
193
src/platform/workflow/persistence/base/draftCacheV2.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* V2 Draft Cache - Pure functions for draft index manipulation.
|
||||
*
|
||||
* This module provides immutable operations on the draft index structure.
|
||||
* All functions return new objects rather than mutating inputs.
|
||||
*/
|
||||
|
||||
import type { DraftEntryMeta, DraftIndexV2 } from './draftTypes'
|
||||
import { MAX_DRAFTS } from './draftTypes'
|
||||
import { hashPath } from './hashUtil'
|
||||
|
||||
/**
|
||||
* Creates an empty draft index.
|
||||
*/
|
||||
export function createEmptyIndex(): DraftIndexV2 {
|
||||
return {
|
||||
v: 2,
|
||||
updatedAt: Date.now(),
|
||||
order: [],
|
||||
entries: {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a draft key to the end of the LRU order (most recently used).
|
||||
*/
|
||||
export function touchOrder(order: string[], draftKey: string): string[] {
|
||||
const filtered = order.filter((key) => key !== draftKey)
|
||||
return [...filtered, draftKey]
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or updates a draft entry in the index.
|
||||
* Handles LRU eviction if over the limit.
|
||||
*
|
||||
* @returns Object with updated index and list of evicted draft keys
|
||||
*/
|
||||
export function upsertEntry(
|
||||
index: DraftIndexV2,
|
||||
path: string,
|
||||
meta: Omit<DraftEntryMeta, 'path'>,
|
||||
limit: number = MAX_DRAFTS
|
||||
): { index: DraftIndexV2; evicted: string[] } {
|
||||
const draftKey = hashPath(path)
|
||||
const effectiveLimit = Math.max(1, limit)
|
||||
|
||||
const entries = {
|
||||
...index.entries,
|
||||
[draftKey]: { ...meta, path }
|
||||
}
|
||||
|
||||
const order = touchOrder(index.order, draftKey)
|
||||
const evicted: string[] = []
|
||||
|
||||
while (order.length > effectiveLimit) {
|
||||
const oldest = order.shift()
|
||||
if (oldest && oldest !== draftKey) {
|
||||
delete entries[oldest]
|
||||
evicted.push(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
index: {
|
||||
v: 2,
|
||||
updatedAt: Date.now(),
|
||||
order,
|
||||
entries
|
||||
},
|
||||
evicted
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a draft entry from the index.
|
||||
*
|
||||
* @returns Object with updated index and the removed draft key (if any)
|
||||
*/
|
||||
export function removeEntry(
|
||||
index: DraftIndexV2,
|
||||
path: string
|
||||
): { index: DraftIndexV2; removedKey: string | null } {
|
||||
const draftKey = hashPath(path)
|
||||
|
||||
if (!(draftKey in index.entries)) {
|
||||
return { index, removedKey: null }
|
||||
}
|
||||
|
||||
const entries = { ...index.entries }
|
||||
delete entries[draftKey]
|
||||
|
||||
return {
|
||||
index: {
|
||||
v: 2,
|
||||
updatedAt: Date.now(),
|
||||
order: index.order.filter((key) => key !== draftKey),
|
||||
entries
|
||||
},
|
||||
removedKey: draftKey
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a draft from one path to another (rename operation).
|
||||
*
|
||||
* @returns Object with updated index and keys involved
|
||||
*/
|
||||
export function moveEntry(
|
||||
index: DraftIndexV2,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
newName: string
|
||||
): { index: DraftIndexV2; oldKey: string; newKey: string } | null {
|
||||
const oldKey = hashPath(oldPath)
|
||||
const newKey = hashPath(newPath)
|
||||
|
||||
const oldEntry = index.entries[oldKey]
|
||||
if (!oldEntry) return null
|
||||
if (oldKey !== newKey && index.entries[newKey]) return null
|
||||
|
||||
const entries = { ...index.entries }
|
||||
delete entries[oldKey]
|
||||
|
||||
entries[newKey] = {
|
||||
...oldEntry,
|
||||
path: newPath,
|
||||
name: newName,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
const order = index.order
|
||||
.filter((key) => key !== oldKey && key !== newKey)
|
||||
.concat(newKey)
|
||||
|
||||
return {
|
||||
index: {
|
||||
v: 2,
|
||||
updatedAt: Date.now(),
|
||||
order,
|
||||
entries
|
||||
},
|
||||
oldKey,
|
||||
newKey
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the most recently used draft key.
|
||||
*/
|
||||
export function getMostRecentKey(index: DraftIndexV2): string | null {
|
||||
return index.order.length > 0 ? index.order[index.order.length - 1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets entry metadata by path.
|
||||
*/
|
||||
export function getEntryByPath(
|
||||
index: DraftIndexV2,
|
||||
path: string
|
||||
): DraftEntryMeta | null {
|
||||
const draftKey = hashPath(path)
|
||||
return index.entries[draftKey] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes entries from index that don't have corresponding payloads.
|
||||
* Used for index/payload drift recovery.
|
||||
*
|
||||
* @param index - The draft index
|
||||
* @param existingPayloadKeys - Set of draft keys that have payloads in storage
|
||||
* @returns Updated index with orphaned entries removed
|
||||
*/
|
||||
export function removeOrphanedEntries(
|
||||
index: DraftIndexV2,
|
||||
existingPayloadKeys: Set<string>
|
||||
): DraftIndexV2 {
|
||||
const entries: Record<string, DraftEntryMeta> = {}
|
||||
const order: string[] = []
|
||||
|
||||
for (const key of index.order) {
|
||||
if (existingPayloadKeys.has(key) && index.entries[key]) {
|
||||
entries[key] = index.entries[key]
|
||||
order.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
v: 2,
|
||||
updatedAt: Date.now(),
|
||||
order,
|
||||
entries
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
/** @knipIgnoreUsedByStackedPR Used by workflowPersistenceV2.ts (PR #3) */
|
||||
export const PERSIST_DEBOUNCE_MS = 512
|
||||
228
src/platform/workflow/persistence/base/storageIO.test.ts
Normal file
228
src/platform/workflow/persistence/base/storageIO.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DraftIndexV2, DraftPayloadV2 } from './draftTypes'
|
||||
import {
|
||||
clearAllV2Storage,
|
||||
deleteOrphanPayloads,
|
||||
deletePayload,
|
||||
deletePayloads,
|
||||
getPayloadKeys,
|
||||
readActivePath,
|
||||
readIndex,
|
||||
readOpenPaths,
|
||||
readPayload,
|
||||
writeActivePath,
|
||||
writeIndex,
|
||||
writeOpenPaths,
|
||||
writePayload
|
||||
} from './storageIO'
|
||||
|
||||
describe('storageIO', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
describe('index operations', () => {
|
||||
const workspaceId = 'test-workspace'
|
||||
|
||||
it('reads and writes index', () => {
|
||||
const index: DraftIndexV2 = {
|
||||
v: 2,
|
||||
updatedAt: Date.now(),
|
||||
order: ['abc123'],
|
||||
entries: {
|
||||
abc123: {
|
||||
path: 'workflows/test.json',
|
||||
name: 'test',
|
||||
isTemporary: true,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(writeIndex(workspaceId, index)).toBe(true)
|
||||
|
||||
const read = readIndex(workspaceId)
|
||||
expect(read).not.toBeNull()
|
||||
expect(read!.v).toBe(2)
|
||||
expect(read!.order).toEqual(['abc123'])
|
||||
})
|
||||
|
||||
it('returns null for missing index', () => {
|
||||
expect(readIndex(workspaceId)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for invalid JSON', () => {
|
||||
localStorage.setItem(
|
||||
'Comfy.Workflow.DraftIndex.v2:test-workspace',
|
||||
'invalid'
|
||||
)
|
||||
expect(readIndex(workspaceId)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for wrong version', () => {
|
||||
localStorage.setItem(
|
||||
'Comfy.Workflow.DraftIndex.v2:test-workspace',
|
||||
JSON.stringify({ v: 1 })
|
||||
)
|
||||
expect(readIndex(workspaceId)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('payload operations', () => {
|
||||
const workspaceId = 'test-workspace'
|
||||
const draftKey = 'abc12345'
|
||||
|
||||
it('reads and writes payload', () => {
|
||||
const payload: DraftPayloadV2 = {
|
||||
data: '{"nodes":[]}',
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
expect(writePayload(workspaceId, draftKey, payload)).toBe(true)
|
||||
|
||||
const read = readPayload(workspaceId, draftKey)
|
||||
expect(read).not.toBeNull()
|
||||
expect(read!.data).toBe('{"nodes":[]}')
|
||||
})
|
||||
|
||||
it('returns null for missing payload', () => {
|
||||
expect(readPayload(workspaceId, 'missing')).toBeNull()
|
||||
})
|
||||
|
||||
it('deletes payload', () => {
|
||||
const payload: DraftPayloadV2 = {
|
||||
data: '{}',
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
writePayload(workspaceId, draftKey, payload)
|
||||
expect(readPayload(workspaceId, draftKey)).not.toBeNull()
|
||||
|
||||
deletePayload(workspaceId, draftKey)
|
||||
expect(readPayload(workspaceId, draftKey)).toBeNull()
|
||||
})
|
||||
|
||||
it('deletes multiple payloads', () => {
|
||||
writePayload(workspaceId, 'key1', { data: '{}', updatedAt: 1 })
|
||||
writePayload(workspaceId, 'key2', { data: '{}', updatedAt: 2 })
|
||||
writePayload(workspaceId, 'key3', { data: '{}', updatedAt: 3 })
|
||||
|
||||
deletePayloads(workspaceId, ['key1', 'key3'])
|
||||
|
||||
expect(readPayload(workspaceId, 'key1')).toBeNull()
|
||||
expect(readPayload(workspaceId, 'key2')).not.toBeNull()
|
||||
expect(readPayload(workspaceId, 'key3')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPayloadKeys', () => {
|
||||
it('returns all payload keys for workspace', () => {
|
||||
localStorage.setItem('Comfy.Workflow.Draft.v2:ws-1:abc', '{"data":""}')
|
||||
localStorage.setItem('Comfy.Workflow.Draft.v2:ws-1:def', '{"data":""}')
|
||||
localStorage.setItem('Comfy.Workflow.Draft.v2:ws-2:ghi', '{"data":""}')
|
||||
localStorage.setItem('unrelated-key', 'value')
|
||||
|
||||
const keys = getPayloadKeys('ws-1')
|
||||
expect(keys).toHaveLength(2)
|
||||
expect(keys).toContain('abc')
|
||||
expect(keys).toContain('def')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteOrphanPayloads', () => {
|
||||
it('deletes payloads not in index', () => {
|
||||
localStorage.setItem('Comfy.Workflow.Draft.v2:ws-1:keep', '{"data":""}')
|
||||
localStorage.setItem(
|
||||
'Comfy.Workflow.Draft.v2:ws-1:orphan1',
|
||||
'{"data":""}'
|
||||
)
|
||||
localStorage.setItem(
|
||||
'Comfy.Workflow.Draft.v2:ws-1:orphan2',
|
||||
'{"data":""}'
|
||||
)
|
||||
|
||||
const indexKeys = new Set(['keep'])
|
||||
const deleted = deleteOrphanPayloads('ws-1', indexKeys)
|
||||
|
||||
expect(deleted).toBe(2)
|
||||
expect(getPayloadKeys('ws-1')).toEqual(['keep'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session storage pointers', () => {
|
||||
const clientId = 'client-abc'
|
||||
|
||||
it('reads and writes active path pointer', () => {
|
||||
const pointer = { workspaceId: 'ws-1', path: 'workflows/test.json' }
|
||||
writeActivePath(clientId, pointer)
|
||||
|
||||
const read = readActivePath(clientId)
|
||||
expect(read).toEqual(pointer)
|
||||
})
|
||||
|
||||
it('returns null for missing active path', () => {
|
||||
expect(readActivePath('missing')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads and writes open paths pointer', () => {
|
||||
const pointer = {
|
||||
workspaceId: 'ws-1',
|
||||
paths: ['workflows/a.json', 'workflows/b.json'],
|
||||
activeIndex: 1
|
||||
}
|
||||
writeOpenPaths(clientId, pointer)
|
||||
|
||||
const read = readOpenPaths(clientId)
|
||||
expect(read).toEqual(pointer)
|
||||
})
|
||||
|
||||
it('returns null for missing open paths', () => {
|
||||
expect(readOpenPaths('missing')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearAllV2Storage', () => {
|
||||
it('clears all V2 keys from localStorage', () => {
|
||||
localStorage.setItem('Comfy.Workflow.DraftIndex.v2:ws-1', '{}')
|
||||
localStorage.setItem('Comfy.Workflow.Draft.v2:ws-1:abc', '{}')
|
||||
localStorage.setItem('Comfy.Workflow.Draft.v2:ws-2:def', '{}')
|
||||
localStorage.setItem('unrelated', 'keep')
|
||||
|
||||
clearAllV2Storage()
|
||||
|
||||
expect(
|
||||
localStorage.getItem('Comfy.Workflow.DraftIndex.v2:ws-1')
|
||||
).toBeNull()
|
||||
expect(
|
||||
localStorage.getItem('Comfy.Workflow.Draft.v2:ws-1:abc')
|
||||
).toBeNull()
|
||||
expect(
|
||||
localStorage.getItem('Comfy.Workflow.Draft.v2:ws-2:def')
|
||||
).toBeNull()
|
||||
expect(localStorage.getItem('unrelated')).toBe('keep')
|
||||
})
|
||||
|
||||
it('clears all V2 keys from sessionStorage', () => {
|
||||
sessionStorage.setItem('Comfy.Workflow.ActivePath:client-1', '{}')
|
||||
sessionStorage.setItem('Comfy.Workflow.OpenPaths:client-2', '{}')
|
||||
sessionStorage.setItem('unrelated', 'keep')
|
||||
|
||||
clearAllV2Storage()
|
||||
|
||||
expect(
|
||||
sessionStorage.getItem('Comfy.Workflow.ActivePath:client-1')
|
||||
).toBeNull()
|
||||
expect(
|
||||
sessionStorage.getItem('Comfy.Workflow.OpenPaths:client-2')
|
||||
).toBeNull()
|
||||
expect(sessionStorage.getItem('unrelated')).toBe('keep')
|
||||
})
|
||||
})
|
||||
})
|
||||
278
src/platform/workflow/persistence/base/storageIO.ts
Normal file
278
src/platform/workflow/persistence/base/storageIO.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* V2 Storage I/O - localStorage read/write with error handling.
|
||||
*
|
||||
* Handles quota management, orphan cleanup, and graceful degradation.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ActivePathPointer,
|
||||
DraftIndexV2,
|
||||
DraftPayloadV2,
|
||||
OpenPathsPointer
|
||||
} from './draftTypes'
|
||||
import { StorageKeys } from './storageKeys'
|
||||
|
||||
/** Flag indicating if storage is available */
|
||||
let storageAvailable = true
|
||||
|
||||
/** @knipIgnoreUsedByStackedPR Used by workflowPersistenceV2.ts (PR #3) */
|
||||
export function isStorageAvailable(): boolean {
|
||||
return storageAvailable
|
||||
}
|
||||
|
||||
/** @knipIgnoreUsedByStackedPR Used by workflowPersistenceV2.ts (PR #3) */
|
||||
export function markStorageUnavailable(): void {
|
||||
storageAvailable = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses the draft index from localStorage.
|
||||
*/
|
||||
export function readIndex(workspaceId: string): DraftIndexV2 | null {
|
||||
if (!storageAvailable) return null
|
||||
|
||||
try {
|
||||
const key = StorageKeys.draftIndex(workspaceId)
|
||||
const json = localStorage.getItem(key)
|
||||
if (!json) return null
|
||||
|
||||
const parsed = JSON.parse(json)
|
||||
if (parsed.v !== 2) return null
|
||||
|
||||
return parsed as DraftIndexV2
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the draft index to localStorage.
|
||||
*/
|
||||
export function writeIndex(workspaceId: string, index: DraftIndexV2): boolean {
|
||||
if (!storageAvailable) return false
|
||||
|
||||
try {
|
||||
const key = StorageKeys.draftIndex(workspaceId)
|
||||
localStorage.setItem(key, JSON.stringify(index))
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a draft payload from localStorage.
|
||||
*/
|
||||
export function readPayload(
|
||||
workspaceId: string,
|
||||
draftKey: string
|
||||
): DraftPayloadV2 | null {
|
||||
if (!storageAvailable) return null
|
||||
|
||||
try {
|
||||
const key = `${StorageKeys.prefixes.draftPayload}${workspaceId}:${draftKey}`
|
||||
const json = localStorage.getItem(key)
|
||||
if (!json) return null
|
||||
|
||||
return JSON.parse(json) as DraftPayloadV2
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a draft payload to localStorage.
|
||||
*/
|
||||
export function writePayload(
|
||||
workspaceId: string,
|
||||
draftKey: string,
|
||||
payload: DraftPayloadV2
|
||||
): boolean {
|
||||
if (!storageAvailable) return false
|
||||
|
||||
try {
|
||||
const key = `${StorageKeys.prefixes.draftPayload}${workspaceId}:${draftKey}`
|
||||
localStorage.setItem(key, JSON.stringify(payload))
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a draft payload from localStorage.
|
||||
*/
|
||||
export function deletePayload(workspaceId: string, draftKey: string): void {
|
||||
try {
|
||||
const key = `${StorageKeys.prefixes.draftPayload}${workspaceId}:${draftKey}`
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
// Ignore errors during deletion
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes multiple draft payloads from localStorage.
|
||||
*/
|
||||
export function deletePayloads(workspaceId: string, draftKeys: string[]): void {
|
||||
for (const draftKey of draftKeys) {
|
||||
deletePayload(workspaceId, draftKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all draft payload keys for a workspace from localStorage.
|
||||
*/
|
||||
export function getPayloadKeys(workspaceId: string): string[] {
|
||||
if (!storageAvailable) return []
|
||||
|
||||
const prefix = `${StorageKeys.prefixes.draftPayload}${workspaceId}:`
|
||||
const keys: string[] = []
|
||||
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key?.startsWith(prefix)) {
|
||||
keys.push(key.slice(prefix.length))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes orphan payloads that are not in the index.
|
||||
*/
|
||||
export function deleteOrphanPayloads(
|
||||
workspaceId: string,
|
||||
indexKeys: Set<string>
|
||||
): number {
|
||||
const payloadKeys = getPayloadKeys(workspaceId)
|
||||
let deleted = 0
|
||||
|
||||
for (const key of payloadKeys) {
|
||||
if (!indexKeys.has(key)) {
|
||||
deletePayload(workspaceId, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
|
||||
return deleted
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the active path pointer from sessionStorage.
|
||||
*/
|
||||
export function readActivePath(clientId: string): ActivePathPointer | null {
|
||||
try {
|
||||
const key = StorageKeys.activePath(clientId)
|
||||
const json = sessionStorage.getItem(key)
|
||||
if (!json) return null
|
||||
|
||||
return JSON.parse(json) as ActivePathPointer
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the active path pointer to sessionStorage.
|
||||
*/
|
||||
export function writeActivePath(
|
||||
clientId: string,
|
||||
pointer: ActivePathPointer
|
||||
): void {
|
||||
try {
|
||||
const key = StorageKeys.activePath(clientId)
|
||||
sessionStorage.setItem(key, JSON.stringify(pointer))
|
||||
} catch {
|
||||
// Best effort - ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the open paths pointer from sessionStorage.
|
||||
*/
|
||||
export function readOpenPaths(clientId: string): OpenPathsPointer | null {
|
||||
try {
|
||||
const key = StorageKeys.openPaths(clientId)
|
||||
const json = sessionStorage.getItem(key)
|
||||
if (!json) return null
|
||||
|
||||
return JSON.parse(json) as OpenPathsPointer
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the open paths pointer to sessionStorage.
|
||||
*/
|
||||
export function writeOpenPaths(
|
||||
clientId: string,
|
||||
pointer: OpenPathsPointer
|
||||
): void {
|
||||
try {
|
||||
const key = StorageKeys.openPaths(clientId)
|
||||
sessionStorage.setItem(key, JSON.stringify(pointer))
|
||||
} catch {
|
||||
// Best effort - ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all V2 workflow persistence data from storage.
|
||||
* Used during signout to prevent data leakage.
|
||||
*/
|
||||
export function clearAllV2Storage(): void {
|
||||
if (!storageAvailable) return
|
||||
|
||||
const prefixes = [
|
||||
StorageKeys.prefixes.draftIndex,
|
||||
StorageKeys.prefixes.draftPayload
|
||||
]
|
||||
|
||||
try {
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const key = localStorage.key(i)
|
||||
if (key && prefixes.some((prefix) => key.startsWith(prefix))) {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
const sessionPrefixes = [
|
||||
StorageKeys.prefixes.activePath,
|
||||
StorageKeys.prefixes.openPaths
|
||||
]
|
||||
|
||||
try {
|
||||
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
||||
const key = sessionStorage.key(i)
|
||||
if (key && sessionPrefixes.some((prefix) => key.startsWith(prefix))) {
|
||||
try {
|
||||
sessionStorage.removeItem(key)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user