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:
Christian Byrne
2026-02-19 22:04:19 -08:00
committed by GitHub
parent 9dc6203b3d
commit 351d43a95a
6 changed files with 1124 additions and 1 deletions

View File

@@ -64,7 +64,8 @@ const config: KnipConfig = {
},
tags: [
'-knipIgnoreUnusedButUsedByCustomNodes',
'-knipIgnoreUnusedButUsedByVueNodesBranch'
'-knipIgnoreUnusedButUsedByVueNodesBranch',
'-knipIgnoreUsedByStackedPR'
]
}

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

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

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
/** @knipIgnoreUsedByStackedPR Used by workflowPersistenceV2.ts (PR #3) */
export const PERSIST_DEBOUNCE_MS = 512

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

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