Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
# 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.delete`s 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.