Files
ComfyUI_frontend/src/platform/workflow/persistence/base/draftCacheV2.ts
Christian Byrne 351d43a95a 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>
2026-02-19 22:04:19 -08:00

194 lines
4.3 KiB
TypeScript

/**
* 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
}
}