Files
suaveplan/openspec/archive/2026-04-24-enterprise-cache/proposal.md
2026-05-27 23:29:53 +01:00

10 KiB

OpenSpec Change: enterprise-cache

  • Change ID: enterprise-cache
  • Package: @suaveplan/cache
  • Category: universal
  • Target path: universal/database/cache
  • Status: PROPOSED
  • Date: 2026-04-24
  • Reference spec: /mnt/s/web/suaveplan/enterprise/cache.md

Summary

Deliver @suaveplan/cache on the genesis branch from scratch at enterprise quality. The package provides a type-safe caching abstraction: an AbstractCache base class, a built-in LRU MemoryCache, a CacheFactory with module-augmentation-based provider registry, namespaced caches, TTL support, stale-while-revalidate, multi-get/set, lifecycle hooks via @suaveplan/events, and cache statistics. Genesis adds structured errors via @suaveplan/error, Zod schemas for all option types, OTel instrumentation, injectable clock and RNG (no Date.now() at module scope), AbortSignal on async operations, property-based tests (fast-check), and a mitata benchmark suite.


Current-state audit (genesis)

The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data.


Available workspace deps on genesis

Package Role
@suaveplan/error (workspace:^) Base class for CacheError; defineCodes for CACHE.* codes
@suaveplan/types (workspace:^) Clock (injectable for TTL expiry, Date.now replacement), EventBusLike, LoggerLike
@suaveplan/events (workspace:^) EventBus for lifecycle hooks (hit, miss, set, evict, expire)
zod (catalog:forms) Zod schemas for all public option types
@opentelemetry/api (catalog:observability) Spans + counters on every public cache operation
tslib (catalog:build) Runtime helpers

Dev deps: vitest, @vitest/coverage-istanbul, fast-check, mitata (all catalog:testing), @suaveplan/testing (workspace:^).


Preserved public API surface (from main branch)

Abstract

abstract class AbstractCache<T = unknown> {
  abstract get(key: string): Promise<T | undefined>
  abstract set(key: string, value: T, options?: CacheSetOptions): Promise<void>
  abstract delete(key: string): Promise<boolean>
  abstract clear(): Promise<void>
  abstract has(key: string): Promise<boolean>
  abstract getStats(): CacheStats
  abstract getMultiple(keys: string[]): Promise<CacheMultiGetResult<T>>
  abstract setMultiple(entries: CacheMultiSetEntry<T>[]): Promise<void>
  wrap(key: string, fn: () => Promise<T>, options?: CacheWrapOptions<T>): Promise<T>
}

class NamespacedCache<T = unknown> extends AbstractCache<T>

Memory cache

class MemoryCache<T = unknown> extends AbstractCache<T> {
  constructor(options?: CacheOptions)
  get(key: string): Promise<T | undefined>
  set(key: string, value: T, options?: CacheSetOptions): Promise<void>
  delete(key: string): Promise<boolean>
  clear(): Promise<void>
  has(key: string): Promise<boolean>
  getStats(): CacheStats
  getMultiple(keys: string[]): Promise<CacheMultiGetResult<T>>
  setMultiple(entries: CacheMultiSetEntry<T>[]): Promise<void>
}

Factory

class CacheFactory {
  static create<T>(type: CacheProviderType, config?: CacheProviderConfig): AbstractCache<T>
  static register(type: string, impl: CacheProviderImplementation): void
  static unregister(type: string): void
  static has(type: string): boolean
  static providers(): string[]
}

function createCache<T>(type: CacheProviderType, config?: CacheProviderConfig): AbstractCache<T>
function createCacheWithConfig<T>(config: CacheFactoryConfig): AbstractCache<T>
function registerCacheProvider(type: string, impl: CacheProviderImplementation): void
function unregisterCacheProvider(type: string): void
function hasCacheProvider(type: string): boolean
function getCacheProviders(): string[]
function clearCacheProviders(): void

Types

interface CacheEntry<T = unknown> {
  value: T
  expiresAt: number
  createdAt: number
  lastAccessedAt: number
  hitCount: number
}

interface CacheStats { hits: number; misses: number; size: number; hitRate: number; memoryUsage?: number }
type CacheEvictionPolicy = 'lru' | 'lfu' | 'fifo' | 'ttl'

interface CacheOptions {
  maxSize?: number
  defaultTTL?: number
  evictionPolicy?: CacheEvictionPolicy
  enableStats?: boolean
  checkExpirationInterval?: number
}

interface CacheSetOptions { ttl?: number; staleWhileRevalidate?: boolean; staleThreshold?: number }

interface CacheWrapOptions<T> {
  ttl?: number
  staleWhileRevalidate?: boolean
  staleThreshold?: number
  forceRefresh?: boolean
  shouldCache?: (value: T) => boolean
}

interface CacheTransformHook<T = unknown> { name: string; transform: (key: string, value: T) => Promise<T> | T; priority?: number }
interface CacheMultiGetResult<T = unknown> { found: Map<string, T>; missing: string[] }
interface CacheMultiSetEntry<T = unknown> { key: string; value: T; options?: CacheSetOptions }
type CacheProviderType = keyof CacheProviderRegistry
interface CacheProviderConfig extends CacheOptions { [key: string]: unknown }
type CacheProviderConfigFor<K extends keyof CacheProviderRegistry> = CacheProviderRegistry[K] extends new (opts: infer O) => unknown ? O : CacheProviderConfig
interface CacheProviderImplementation { new(options?: CacheProviderConfig): AbstractCache }
interface CacheFactoryConfig { type: CacheProviderType; options?: CacheProviderConfig }
interface CacheProviderRegistry { memory: typeof MemoryCache }

interface CacheEvents {
  hit: { key: string }
  miss: { key: string }
  set: { key: string; ttl?: number }
  delete: { key: string }
  clear: {}
  evict: { key: string; reason: 'ttl' | 'lru' | 'lfu' | 'manual' }
  expire: { key: string }
}

Error codes

type CacheErrorCode =
  | 'CACHE_KEY_INVALID'
  | 'CACHE_CAPACITY_EXCEEDED'
  | 'CACHE_PROVIDER_NOT_FOUND'
  | 'CACHE_SET_FAILED'
  | 'CACHE_GET_FAILED'
  | 'CACHE_SERIALIZATION_FAILED'

New additions for genesis

1. Structured CacheError

CacheError extends SuaveplanError from @suaveplan/error carrying a CacheErrorCode discriminant. All internal throw sites use named factory helpers.

2. Zod option schemas

  • CacheOptionsSchema
  • CacheSetOptionsSchema
  • CacheWrapOptionsSchema
  • CacheFactoryConfigSchema
  • CacheTransformHookSchema

3. Injectable clock

CacheOptions gains an optional clock?: Clock field (from @suaveplan/types). MemoryCache uses clock.now() for all TTL calculations instead of bare Date.now(). Defaults to { now: () => Date.now() }.

4. Event bus integration

CacheOptions gains an optional events?: EventBusLike field. When provided, MemoryCache emits typed CacheEvents on every hit/miss/set/delete/evict/expire. Uses @suaveplan/events EventBus shape — no external event emitter library.

5. AbortSignal on wrap

CacheWrapOptions gains an optional signal?: AbortSignal. The background refresh in stale-while-revalidate is aborted when the signal fires.

6. OTel instrumentation

Operation Signal
get counter cache.get_total, attribute cache.hit (bool), attribute cache.namespace
set counter cache.set_total, attribute cache.ttl_set (bool)
delete counter cache.delete_total
clear counter cache.clear_total
wrap span cache.wrap, histogram cache.wrap_duration_ms
getStats gauge cache.size, gauge cache.hit_rate

7. LFU eviction policy

The lfu eviction policy is fully implemented (main branch had the type but the implementation may have been a stub). MemoryCache uses a frequency map + linked-list for O(1) LFU eviction when evictionPolicy: 'lfu' is set.

8. Property-based tests (fast-check)

  • LRU eviction invariant: after filling a cache of capacity N and setting N+1 items, assert the least-recently-used item is no longer present.
  • TTL expiry: for items set with TTL T, after advancing the injectable clock by T+1 ms, assert has() returns false and get() returns undefined.
  • getMultiple completeness: for M items set in cache and K random keys queried (K ≥ M), assert found.size + missing.length === K always.
  • Stats accuracy: for N random get/set/miss sequences, assert hits + misses === total get calls and hitRate ≈ hits / (hits + misses).

9. Benchmark suite

  • get_hot_1k: 1000 get() calls, all hits, LRU cache of 500 items.
  • set_1k: 1000 set() calls with TTL.
  • wrap_cold: 100 wrap() calls where all miss (async factory call each time).
  • lru_eviction_pressure: fill cache to 2x capacity, measure eviction throughput.

File layout

universal/database/cache/
  package.json
  tsconfig.json
  vitest.config.ts
  src/
    index.ts                          # Public barrel
    error-codes.ts                    # CACHE.* codes + registerCacheErrorCodes
    constants/
      index.ts                        # DEFAULT_MAX_SIZE, DEFAULT_TTL, DEFAULT_EVICTION_POLICY
    types/
      common.ts                       # All public TS interfaces and type aliases
      common.md
      schemas.ts                      # Zod schemas
      schemas.md
    abstract/
      abstract-cache/
        abstract-cache.ts             # AbstractCache base class
        abstract-cache.md
        abstract-cache.types.ts
        abstract-cache.test.ts
        index.ts
      namespaced-cache/
        namespaced-cache.ts
        namespaced-cache.md
        namespaced-cache.test.ts
        index.ts
    memory/
      memory-cache/
        memory-cache.ts               # LRU/LFU/FIFO/TTL MemoryCache
        memory-cache.md
        memory-cache.types.ts
        memory-cache.test.ts
        index.ts
    factory/
      cache-factory/
        cache-factory.ts              # CacheFactory class + free functions
        cache-factory.md
        cache-factory.types.ts
        cache-factory.test.ts
        index.ts
      registry/
        registry.ts                   # Built-in memory provider registration
        registry.md
        index.ts
    __benchmarks__/
      cache.bench.ts
  tests/
    integration.test.ts               # Cross-cutting: namespaced + factory + wrap round-trip
    property.test.ts                  # fast-check property tests

Impact

Downstream packages: @suaveplan/idempotency and any server-side package that needs caching will depend on @suaveplan/cache. No existing genesis package currently requires it.

Breaking changes from main-branch API: none. New optional fields (clock, events, signal) are additive.

Workspace dep prerequisites: @suaveplan/error, @suaveplan/types, @suaveplan/events must be published to Verdaccio before this package can be built or tested.