Files
suaveplan/openspec/changes/complete-cache-storage/proposal.md
2026-05-27 23:29:53 +01:00

15 KiB

Complete @suaveplan/cache-storage to full functional surface

Package: @suaveplan/cache-storage (universal) Status: PROPOSED Type: feature-complete expansion — additive + minor MODIFIED to existing shape Supersedes: openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-cache-storage-cbor-codec/

  • Design: ./design.md
  • Spec delta: ./specs/cache-storage/spec.md

Why

@suaveplan/cache-storage currently ships a minimum-viable StorageCache<T> over AbstractStorage: 32-byte header, two codecs (JsonCodec, IdentityCodec), namespacing, lazy TTL on read, CacheEvents emission when an EventBusLike is provided, and 5 error codes. That surface stops short of what enterprise consumers expect from a cache and what the project-wide enterprise/cache.md audit calls out (cache-storage itself has no upstream enterprise/*.md — its parent cache.md is the design intent):

  1. One codec. StructuredCloneCodec (CBOR-backed, round-trips Date/Map/Set/Uint8Array/bigint/RegExp/URL) is queued in the superseded change but never landed. JSON loses Date, throws on bigint, lossily encodes Map/Set/Uint8Array.
  2. No eviction. The parent cache package declares lru/lfu/fifo/ttl policies on CacheOptions, but StorageCache ignores them — every entry is permanent until the backend evicts or delete/clear is called. Size-bounded backends (Redis with maxmemory, OPFS with quota) hit hard limits instead of soft evicting.
  3. TTL only fires on read. The enterprise/cache.md audit mandates lazy-on-read AND a configurable background sweep. The current implementation drops expired entries only when re-read, leaving them in the backend indefinitely.
  4. No stampede protection. Concurrent cache.wrap(key, loader) calls invoke the loader once per concurrent caller. The cache parent's wrap doesn't dedupe in-flight promises; for an expensive loader (DB query, remote fetch) the cache becomes a dog-pile amplifier.
  5. No stale-while-revalidate. CacheSetOptions.staleWhileRevalidate is in the type signature on the parent but the parent's wrap doesn't implement it. StorageCache ships no SWR pipeline either.
  6. No bulk byte ops on the backend. getMultiple/setMultiple round-trip every key one at a time even when the backend supports bulk reads.
  7. No atomic ops. getOrSet and compare-and-swap (CAS via header-carried version) are absent. Callers writing leader-election, distributed locks, or optimistic concurrency on top of the cache have to hand-roll the read/encode/write race themselves.
  8. No serialization size limits beyond the 10 MiB hard cap — but with no per-cache override, no KEY_TOO_LONG for typed key budgets, and no rejection metric.
  9. No observability beyond CacheEvents. No OTel spans on get/set/wrap/mget, no counters for hits/misses/evictions/stampede-prevented, no histogram on payload bytes.
  10. No multi-backend conformance harness. Bugs in StorageCache against memory storage do not surface against Redis or S3 storage until a real consumer hits them. The codec conformance harness queued in the superseded change is missing too.
  11. 5 error codes cover encode/backend/header. Missing: stampede timeout, CAS conflict, eviction failure, codec-unsupported-type, codec-unknown-tag, sweep failure, namespace-conflict.

This change lands the full surface as a single feature-complete release of cache-storage so that downstream packages (cache-redis, future cache-opfs, cache-s3) plug into a stable contract rather than chasing a moving target.

What changes

@suaveplan/cache-storage — ## ADDED

  • Codecs (3 total). Keep JsonCodec (default), keep IdentityCodec, add StructuredCloneCodec<T> (CBOR-backed; IANA tags 0/2/3/32/35/64/258/259 per the superseded design.md). All three pass a new runCodecConformance(codec, { profile }) harness from ./codec/conformance.
  • Eviction policies. Pluggable EvictionPolicy interface in ./eviction/policy.types.ts, plus four implementations in ./eviction/: LruPolicy, LfuPolicy, FifoPolicy, TtlOnlyPolicy. Each maintains a side-index keyed by prefixedKey; StorageCache.set consults the policy after every write to decide whether to evict (reason: "lru" | "lfu" | "fifo" | "ttl" | "size"). Caller selects via StorageCacheOptions.eviction: { policy: EvictionPolicy; maxEntries?: number; maxBytes?: number }.
  • Background TTL sweep. StorageCacheOptions.sweep: { intervalMs: number; batchSize?: number }. When set, StorageCache schedules a sweep via the injected TimersLike from @suaveplan/types (no globals) that lists keys via storage.list({ prefix: namespace }) in batchSize pages, decodes each header, and storage.deletes expired entries — emitting expire events and incrementing evictions. start()/stop() lifecycle on the cache; idempotent.
  • Stampede protection (single-flight). ./stampede/inflight-map.ts keyed by prefixedKey. cache.wrap(key, loader, { stampede: { timeoutMs } }) deduplicates concurrent calls: the second caller awaits the first caller's promise. On timeout the dedup map clears and CACHE_STORAGE.STAMPEDE_TIMEOUT throws. Default timeoutMs: 30000. Counter stampedePrevented exposed via getStats().
  • Stale-while-revalidate. cache.wrap(key, loader, { staleWhileRevalidate: true, staleThresholdMs }) returns the stale value immediately when clock() >= expiresAt - staleThresholdMs, and fires the loader on a background microtask scheduled via TimersLike.queueMicrotask. Background refresh errors emit a swr-refresh-failed event but never reject the caller.
  • Bulk ops. mget(keys: string[]): Promise<Map<string, T>>, mset(entries: Array<{ key, value, options? }>): Promise<void>, mdelete(keys: string[]): Promise<number>. Each delegates to the new AbstractStorage.getMultiple/setMultiple when present (duck-typed) and falls back to per-key calls.
  • Atomic ops. getOrSet(key, loader, options?) is wrap with stampede.timeoutMs enforced by default. compareAndSet(key, expectedVersion, value, options?) reads the header's reserved version: u32 BE (bytes 24..27 — previously reserved-zero), compares, and writes only when equal; on mismatch throws CACHE_STORAGE.CAS_CONFLICT carrying the observed version. getWithVersion(key) returns { value, version } | undefined so callers can drive the CAS loop.
  • Serialization size limits. New constructor option limits: { maxKeyBytes?: number; maxValueBytes?: number } overrides the existing hard-coded MAX_KEY_LENGTH = 1024 and MAX_VALUE_BYTES = 10 MiB. Defaults preserved. Rejection on overflow throws KEY_TOO_LONG / VALUE_TOO_LARGE (existing codes) and emits a rejected event.
  • Per-namespace stats. getStats() continues to return cache-wide counters; new getStats({ namespace }) returns counters scoped to a sub-namespace when a NamespacedCache-style child has been created via cache.namespace("sub"). Implementation maintains a Map<namespace, Counters>.
  • OTel observability. Every public op (get/set/delete/has/clear/wrap/mget/mset/mdelete/compareAndSet/getOrSet) opens a span via @opentelemetry/api trace.getTracer("@suaveplan/cache-storage"). Counters: cache.hits, cache.misses, cache.evictions, cache.stampede_prevented, cache.swr_refreshes, cache.rejected. Histogram: cache.payload_bytes. All no-op when no exporter is registered.
  • Multi-backend conformance harness. ./testing/conformance.ts exports runStorageCacheConformance({ createStorage, name }) that runs the full StorageCache API + every eviction policy + stampede + SWR + CAS against a caller-supplied AbstractStorage factory. Lives in cache-storage (not @suaveplan/testing) because it depends on @suaveplan/cache-storage itself; testing-infra rule 8 forbids the inverse.
  • Codec conformance harness. ./codec/conformance.ts exports runCodecConformance(codec, { name, profile: "json" | "structured-clone" | "bytes" }) per the superseded change's design. Has a self-test proving teeth.
  • Error registry extended. 5 → 12 codes: keep all 5 existing, ADD STAMPEDE_TIMEOUT, CAS_CONFLICT, SWEEP_FAILED, EVICTION_FAILED, CODEC_UNSUPPORTED_TYPE, CODEC_UNKNOWN_TAG, NAMESPACE_CONFLICT.
  • Subpath exports. Add ./codec/structured-clone, ./codec/conformance, ./eviction/lru, ./eviction/lfu, ./eviction/fifo, ./eviction/ttl, ./eviction/policy, ./stampede/inflight-map, ./testing/conformance.

@suaveplan/cache-storage — ## MODIFIED

  • Header layout (v2). Bytes 24..27 — previously reserved-zero — are repurposed for version: u32 BE. Bytes 28..31 remain reserved-zero. flags bit 2 (FLAG_HAS_VERSION) is set when the header carries a non-zero version. Existing v1 headers (where bytes 24..31 are zero) decode as version: 0, so the on-disk format remains forward-compatible: every legacy entry reads back as version: 0, and a compareAndSet(key, 0, ...) on a legacy entry succeeds on first write.
  • StorageCacheOptions. Extended with the new optional fields (eviction, sweep, limits, timers, tracer, metrics). All defaults preserve current behaviour: zero-config callers get exactly today's StorageCache.
  • StorageCache.wrap override. The inherited AbstractCache.wrap is overridden to add stampede dedup, SWR, and OTel span. The override SHALL be observationally equivalent to the parent when stampede/staleWhileRevalidate are absent.

Out of scope

  • Backend-specific implementations. Each backend (@suaveplan/cache-redis, future cache-opfs, cache-s3, cache-memory) is its own package and consumes StorageCache + the conformance harness from here. This change only ships the universal core.

Parity Checklist

No upstream cache-storage package exists on main — cache-storage is a genesis-original. The parity comparison here is against the project-wide enterprise/cache.md design intent and against what backends (cache-redis) expect.

Capability Design intent (enterprise/cache.md) Current cache-storage After this change Notes
JsonCodec default required ok ok unchanged
IdentityCodec for Uint8Array required ok ok unchanged
StructuredCloneCodec (CBOR) required missing added picks up superseded change
LRU / LFU / FIFO / TTL eviction required missing added pluggable EvictionPolicy
Background TTL sweep required missing added TimersLike-injected
Stampede protection required missing added in-flight map, configurable timeout
Stale-while-revalidate required missing added background microtask refresh
Namespacing required ok ok unchanged
Bulk ops (mget/mset/mdelete) required inherited only added bulk-aware backend dispatch
Atomic ops (getOrSet, CAS) required missing added header version repurposes reserved bytes
Serialization size limits partial (hardcoded) hard-coded only configurable per-cache overrides
OTel spans + counters + histogram required missing added @opentelemetry/api no-op when unconfigured
EventBus lifecycle events required ok ok extended with expire/swr-refresh-failed/rejected
Error registry covers every failure path required 5 codes (incomplete) 12 codes every code reachable via test
Multi-backend conformance harness required missing added factory-injected AbstractStorage
100% coverage required ok ok maintained

Source mapping

  • packages/flow/cache-storage/src/storage-cache/storage-cache.ts:1-240 — current implementation; this change extends it (no greenfield rewrite).
  • packages/flow/cache-storage/src/header/header.ts:1-90 — header layout; bytes 24..27 move from reserved-zero to version: u32 BE.
  • packages/flow/cache-storage/src/errors/errors.ts:1-50 — CACHE_STORAGE_CODES extends 5 → 12.
  • packages/flow/cache-storage/src/codec/{json,identity,codec.types}.ts — codec seam; StructuredCloneCodec plugs into the same interface.
  • packages/flow/cache/src/types/common.ts:30-50 — CacheOptions, CacheSetOptions, CacheWrapOptions already declare the surface shape; StorageCache implements what the parent only typed.
  • packages/flow/cbor/src/index.ts — encode, decode, Tagged, type TagHandler consumed by StructuredCloneCodec.
  • openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-cache-storage-cbor-codec/{proposal,design,specs/cache-storage/spec}.md — superseded; the CBOR codec design and tag table are reused verbatim.
  • enterprise/cache.md (upstream /mnt/s/web/suaveplan/enterprise/cache.md) — design intent for the cache family; cache-storage is the storage-backed leaf of that contract.

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/cache-storage to enumerate.

  • @suaveplan/cache-redis — [ ] tests run / [x] docs reviewed / [ ] no action needed (reason: consumes StorageCache, Codec<T>, StorageCacheError. Header v2 is forward-compatible — legacy entries decode as version: 0. New eviction policies and stampede protection are opt-in. The new conformance harness from ./testing/conformance SHOULD be wired into cache-redis's test suite as part of the consumer's own next change, not this one.)
  • No other dependents at time of writing — verified per bunx turbo ls --filter=...@suaveplan/cache-storage.

Affected upstreams

  • @suaveplan/cbor — newly consumed at workspace:^. Not modified. [x] no action needed.
  • @suaveplan/cache — AbstractCache parent unchanged; this change overrides wrap and adds new methods. [x] no action needed.
  • @suaveplan/storage — AbstractStorage consumed via structural DriveStorage-style view; bulk-op dispatch is duck-typed so storage backends without getMultiple continue to work. [x] no action needed.
  • @suaveplan/types — TimersLike, Clock, EventBusLike consumed. [x] no action needed.
  • @suaveplan/error — defineCodes registry extended (additive). [x] no action needed.
  • @suaveplan/events — not a direct dep; EventBusLike from @suaveplan/types is the contract surface. [x] no action needed.

Migration

  • No breaking renames. All existing exports keep their names and signatures.
  • Header v2 is forward-compatible. Legacy v1 entries (bytes 24..31 = 0) decode cleanly with version: 0. A compareAndSet(key, 0, value) on a legacy entry succeeds on first write; callers who want strict CAS semantics must seed version via set before the first CAS.
  • Default behaviour preserved. Zero-config new StorageCache({ storage }) runs exactly like today — no eviction, no sweep, no stampede dedup, no SWR.
  • Opt-in surface. Eviction, sweep, stampede, SWR, CAS, OTel are all gated on constructor options or per-call options that default to today's behaviour. Callers opt in explicitly.
  • New error codes are additive. Existing 5 codes unchanged; the 7 new codes only throw from new code paths.