From 351d43a95a1b460797a9b3a257f15e30f2254fe9 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Thu, 19 Feb 2026 22:04:19 -0800 Subject: [PATCH] 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 --- knip.config.ts | 3 +- .../persistence/base/draftCacheV2.test.ts | 338 ++++++++++++++++++ .../workflow/persistence/base/draftCacheV2.ts | 193 ++++++++++ .../workflow/persistence/base/draftTypes.ts | 85 +++++ .../persistence/base/storageIO.test.ts | 228 ++++++++++++ .../workflow/persistence/base/storageIO.ts | 278 ++++++++++++++ 6 files changed, 1124 insertions(+), 1 deletion(-) create mode 100644 src/platform/workflow/persistence/base/draftCacheV2.test.ts create mode 100644 src/platform/workflow/persistence/base/draftCacheV2.ts create mode 100644 src/platform/workflow/persistence/base/draftTypes.ts create mode 100644 src/platform/workflow/persistence/base/storageIO.test.ts create mode 100644 src/platform/workflow/persistence/base/storageIO.ts diff --git a/knip.config.ts b/knip.config.ts index e000e1882..a9f6310f7 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -64,7 +64,8 @@ const config: KnipConfig = { }, tags: [ '-knipIgnoreUnusedButUsedByCustomNodes', - '-knipIgnoreUnusedButUsedByVueNodesBranch' + '-knipIgnoreUnusedButUsedByVueNodesBranch', + '-knipIgnoreUsedByStackedPR' ] } diff --git a/src/platform/workflow/persistence/base/draftCacheV2.test.ts b/src/platform/workflow/persistence/base/draftCacheV2.test.ts new file mode 100644 index 000000000..6c621f114 --- /dev/null +++ b/src/platform/workflow/persistence/base/draftCacheV2.test.ts @@ -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]) + }) + }) +}) diff --git a/src/platform/workflow/persistence/base/draftCacheV2.ts b/src/platform/workflow/persistence/base/draftCacheV2.ts new file mode 100644 index 000000000..113d16fa8 --- /dev/null +++ b/src/platform/workflow/persistence/base/draftCacheV2.ts @@ -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, + 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 +): DraftIndexV2 { + const entries: Record = {} + 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 + } +} diff --git a/src/platform/workflow/persistence/base/draftTypes.ts b/src/platform/workflow/persistence/base/draftTypes.ts new file mode 100644 index 000000000..51be15258 --- /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 + +/** @knipIgnoreUsedByStackedPR Used by workflowPersistenceV2.ts (PR #3) */ +export const PERSIST_DEBOUNCE_MS = 512 diff --git a/src/platform/workflow/persistence/base/storageIO.test.ts b/src/platform/workflow/persistence/base/storageIO.test.ts new file mode 100644 index 000000000..10543b776 --- /dev/null +++ b/src/platform/workflow/persistence/base/storageIO.test.ts @@ -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') + }) + }) +}) diff --git a/src/platform/workflow/persistence/base/storageIO.ts b/src/platform/workflow/persistence/base/storageIO.ts new file mode 100644 index 000000000..6f97b46c3 --- /dev/null +++ b/src/platform/workflow/persistence/base/storageIO.ts @@ -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 +): 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 + } +}