/ src / lib / server / storage-cache.ts
storage-cache.ts
  1  import { safeJsonParseObject } from './json-utils'
  2  import { hmrSingleton } from '@/lib/shared-utils'
  3  import type { AppSettings } from '@/types'
  4  
  5  // --- TTL Cache (read-through with write-through invalidation) ---
  6  
  7  interface TTLEntry<T> {
  8    value: T
  9    expiresAt: number
 10  }
 11  
 12  /**
 13   * Simple TTL cache for hot-path reads that rarely change.
 14   * Stored on globalThis so HMR doesn't reset it.
 15   */
 16  export class TTLCache<T> {
 17    private entry: TTLEntry<T> | null = null
 18    constructor(private readonly ttlMs: number) {}
 19  
 20    get(): T | undefined {
 21      if (!this.entry) return undefined
 22      if (Date.now() > this.entry.expiresAt) {
 23        this.entry = null
 24        return undefined
 25      }
 26      return this.entry.value
 27    }
 28  
 29    set(value: T): void {
 30      this.entry = { value, expiresAt: Date.now() + this.ttlMs }
 31    }
 32  
 33    invalidate(): void {
 34      this.entry = null
 35    }
 36  }
 37  
 38  type TTLCacheStore = {
 39    settings?: TTLCache<AppSettings>
 40    agents?: TTLCache<Record<string, unknown>>
 41    sessions?: TTLCache<Record<string, unknown>>
 42  }
 43  const ttlCaches: TTLCacheStore = hmrSingleton<TTLCacheStore>('__swarmclaw_ttl_caches__', () => ({}))
 44  
 45  export function getSettingsCache() { return ttlCaches.settings ?? (ttlCaches.settings = new TTLCache(60_000)) }
 46  export function getAgentsCache() { return ttlCaches.agents ?? (ttlCaches.agents = new TTLCache(15_000)) }
 47  export function getSessionsCache() { return ttlCaches.sessions ?? (ttlCaches.sessions = new TTLCache(5_000)) }
 48  
 49  // --- LRU Cache ---
 50  
 51  const DEFAULT_LRU_CAPACITY = 5000
 52  
 53  /** Per-collection capacity overrides from COLLECTION_CACHE_LIMITS env var (JSON). */
 54  export function parseCacheLimits(): Record<string, number> {
 55    const raw = process.env.COLLECTION_CACHE_LIMITS
 56    if (!raw) return {}
 57    const parsed = safeJsonParseObject(raw)
 58    if (!parsed) return {}
 59    const result: Record<string, number> = {}
 60    for (const [k, v] of Object.entries(parsed)) {
 61      if (typeof v === 'number' && v > 0) result[k] = v
 62    }
 63    return result
 64  }
 65  
 66  const cacheLimits = parseCacheLimits()
 67  
 68  export function capacityFor(collection: string): number {
 69    return cacheLimits[collection] ?? DEFAULT_LRU_CAPACITY
 70  }
 71  
 72  /**
 73   * A Map wrapper with LRU eviction. JS Maps iterate in insertion order,
 74   * so the *first* key is the least-recently-used entry.
 75   */
 76  export class LRUMap<K, V> {
 77    private readonly map = new Map<K, V>()
 78    readonly capacity: number
 79  
 80    constructor(capacity: number) {
 81      this.capacity = Math.max(1, capacity)
 82    }
 83  
 84    get(key: K): V | undefined {
 85      if (!this.map.has(key)) return undefined
 86      const value = this.map.get(key)!
 87      // Move to end (most-recently-used)
 88      this.map.delete(key)
 89      this.map.set(key, value)
 90      return value
 91    }
 92  
 93    set(key: K, value: V): this {
 94      if (this.map.has(key)) {
 95        this.map.delete(key)
 96      }
 97      this.map.set(key, value)
 98      // Evict oldest if over capacity
 99      if (this.map.size > this.capacity) {
100        const oldest = this.map.keys().next().value as K
101        this.map.delete(oldest)
102      }
103      return this
104    }
105  
106    has(key: K): boolean {
107      return this.map.has(key)
108    }
109  
110    delete(key: K): boolean {
111      return this.map.delete(key)
112    }
113  
114    get size(): number {
115      return this.map.size
116    }
117  
118    clear(): void {
119      this.map.clear()
120    }
121  
122    keys(): MapIterator<K> {
123      return this.map.keys()
124    }
125  
126    values(): MapIterator<V> {
127      return this.map.values()
128    }
129  
130    entries(): MapIterator<[K, V]> {
131      return this.map.entries()
132    }
133  
134    [Symbol.iterator](): MapIterator<[K, V]> {
135      return this.map[Symbol.iterator]()
136    }
137  }
138  
139  /** Per-collection LRU cache of raw JSON strings, keyed by record id. */
140  export const collectionCache: Map<string, LRUMap<string, string>> =
141    hmrSingleton('__swarmclaw_storage_collection_cache__', () => new Map<string, LRUMap<string, string>>())
142  
143  /** TTL caches created by createCollectionStore (factory caches). */
144  export const factoryTtlCaches = hmrSingleton('__swarmclaw_factory_ttl__', () => new Map<string, TTLCache<Record<string, unknown>>>())