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,343 @@
# Design — Complete @suaveplan/cache-storage
> Non-normative. Normative requirements live in `./specs/cache-storage/spec.md`.
## Goal
Take `@suaveplan/cache-storage` from a minimum-viable `StorageCache` (header + 2 codecs + namespacing + lazy TTL) to a feature-complete enterprise cache: third codec, pluggable eviction, background sweep, stampede protection, stale-while-revalidate, bulk + atomic ops, configurable size limits, OTel observability, 12-code error registry, and a multi-backend conformance harness — all without touching backend implementations and without breaking the existing public surface.
## Composition over rewrite
The current `StorageCache.ts` (240 LoC) is correct and small. Instead of replacing it, this change extends it via composition:
```text
StorageCache (orchestrator, ~250 LoC)
├─ Codec<T> — ./codec/{json,identity,structured-clone}
├─ EvictionPolicy — ./eviction/{lru,lfu,fifo,ttl-only,policy.types}
├─ InflightMap — ./stampede/inflight-map
├─ TtlSweeper — ./sweep/ttl-sweeper
├─ SwrScheduler — ./swr/swr-scheduler
├─ HeaderV2 codec — ./header (extended)
├─ MetricsRecorder — ./observability/metrics
├─ TraceRecorder — ./observability/trace
└─ NamespaceStats — ./stats/namespace-stats
```
Every collaborator is a plain class with constructor-injected dependencies, fully unit-testable in isolation, and reachable via subpath import. `StorageCache` becomes the glue layer; today's 240-line file stays under the 300-line cap by extracting collaborators into their own modules.
## Header v2 — version field
```text
byte: 0 1 2 3 4 5 6 7 8 9 ...15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
field: createdAt expiresAt flags hitCount version reserved
width: u64 BE u64 BE u32 BE u32 BE u32 BE zeros
```
- Bytes 24..27 — previously reserved-zero — now carry `version: u32 BE`. Legacy entries (where every reserved byte is zero) decode as `version: 0`. New entries written by this version of `cache-storage` start at `version: 1` and increment on every `set` / successful `compareAndSet`.
- Bytes 28..31 remain reserved (zero). The next on-disk evolution (HMAC tag, codec discriminant, namespace hash) can claim them without breaking v2 readers.
- `flags` bit 2 (`FLAG_HAS_VERSION = 1 << 2`) is set when the writer is v2-aware. A v2 reader trusts bytes 24..27 only when this flag is set; otherwise it returns `version: 0`. This preserves the "reserved bytes happened to be zero on a v1 write" case.
The header codec stays in `src/header/header.ts`. The reserved-byte reinterpretation is an internal change — `HEADER_BYTES = 32` and the existing `encodeHeader`/`decodeHeader` signatures are unchanged.
## Eviction — pluggable policy
```ts
// src/eviction/policy.types.ts
export interface EvictionPolicy {
/** Called after a successful set; returns keys to evict (may be empty). */
onSet(key: string, sizeBytes: number, now: number): readonly string[];
/** Called after a hit; lets the policy update its recency / frequency index. */
onHit(key: string, now: number): void;
/** Called after a delete / expire to drop the key from the policy's index. */
onDelete(key: string): void;
/** Clear the policy's internal state when the cache is cleared. */
reset(): void;
/** Snapshot the policy's view of size (entries, bytes). */
snapshot(): { entries: number; bytes: number };
}
export interface EvictionConfig {
policy: EvictionPolicy;
maxEntries?: number;
maxBytes?: number;
}
```
Four built-ins:
| Policy | Eviction rule | Index |
|---|---|---|
| `LruPolicy` | Drop the least-recently-touched key when `entries > maxEntries` or `bytes > maxBytes`. | Doubly-linked list keyed by `prefixedKey` for O(1) touch/evict. |
| `LfuPolicy` | Drop the lowest-frequency key. Tie-break by oldest first-insertion. | Frequency buckets (`Map<freq, Set<key>>`) for O(1) eviction; per-key `freq` counter saturates at `u32`. |
| `FifoPolicy` | Drop the oldest-inserted key. | Insertion-ordered `Map` (JS spec guarantees insertion order). |
| `TtlOnlyPolicy` | Never evict on count/size; relies entirely on TTL + background sweeper. | None. |
Eviction is applied by `StorageCache.set` after a successful write:
```ts
const victims = this.policy.onSet(prefixed, blob.length, now);
for (const victim of victims) {
try {
await this.storage.delete(victim);
this.policy.onDelete(victim);
this.size -= 1;
this.evictions += 1;
this.emit("evict", { key: this.stripNamespace(victim), reason: policyReason });
} catch (cause) {
throw new StorageCacheError(CACHE_STORAGE_CODES.EVICTION_FAILED, "error.cache-storage.eviction.delete", { cause, key: victim });
}
}
```
The policy lives entirely in-memory inside `StorageCache`. A second `StorageCache` instance pointed at the same backend will keep its own policy view; this is documented as the contract — coherent eviction across instances requires a shared coordination layer (out of scope; backend-specific).
## Background TTL sweep
```ts
// src/sweep/ttl-sweeper.ts
export class TtlSweeper {
constructor(private deps: {
storage: AbstractStorage;
clock: Clock;
timers: TimersLike;
namespace: string;
intervalMs: number;
batchSize: number;
onExpired: (key: string) => Promise<void>;
onSweepError: (cause: unknown) => void;
}) {}
start(): void { /* setInterval via timers.setTimeout chain */ }
stop(): void { /* clear handle */ }
}
```
`timers: TimersLike` comes from `@suaveplan/types` and provides `setTimeout`/`clearTimeout`/`queueMicrotask`/`now` so tests can drive the sweeper with `FakeTimers` without touching globals. The sweeper:
1. Lists keys via `storage.list({ prefix: namespace, limit: batchSize, cursor })`.
2. For each key, reads the header only (caller passes a `headerProbe` helper that fetches `HEADER_BYTES` and parses), no body decode.
3. If `expiresAt !== 0 && now >= expiresAt`, `storage.delete(key)` and `onExpired(strippedKey)`.
4. On any error, calls `onSweepError(cause)` (which records `CACHE_STORAGE.SWEEP_FAILED` and emits a `sweep-error` event) but continues — one bad key never poisons the sweep.
5. After all pages, schedules the next sweep `intervalMs` later.
`start()`/`stop()` are idempotent. `stop()` clears the pending timer and resolves any in-flight page before returning.
## Stampede protection — single-flight
```ts
// src/stampede/inflight-map.ts
export class InflightMap<T> {
#map = new Map<string, Promise<T>>();
constructor(private deps: { timers: TimersLike; defaultTimeoutMs: number }) {}
async run(key: string, loader: () => Promise<T>, timeoutMs = this.deps.defaultTimeoutMs): Promise<T> {
const existing = this.#map.get(key);
if (existing !== undefined) return existing;
const work = (async () => { ... })();
const timed = withTimeout(work, timeoutMs, this.deps.timers);
this.#map.set(key, timed);
try {
return await timed;
} finally {
this.#map.delete(key);
}
}
get pending(): number { return this.#map.size; }
}
```
`withTimeout` races the loader against a `timers.setTimeout` that rejects with `CACHE_STORAGE.STAMPEDE_TIMEOUT`. The timeout cancels the timer on resolution to keep the event loop clean. The counter `stampedePrevented` increments on every `run` call that returns the existing promise instead of starting new work.
`StorageCache.wrap` consults the in-flight map inside the miss path so concurrent misses share the loader; `getOrSet` is just `wrap` with stampede enabled by default.
## Stale-while-revalidate
`cache.wrap(key, loader, { staleWhileRevalidate: true, staleThresholdMs })`:
1. Read the entry. If `clock() < expiresAt - staleThresholdMs`, return the fresh value.
2. If `clock() >= expiresAt - staleThresholdMs` AND `clock() < expiresAt`, return the stale value immediately AND schedule a background refresh.
3. If `clock() >= expiresAt`, treat as a miss — go through the stampede-protected loader path.
The background refresh runs via `timers.queueMicrotask`; failure increments `swrRefreshFailures` and emits `swr-refresh-failed` but never reaches the caller. The refresh path also goes through the same `InflightMap` so a stampede of stale callers fires the loader once.
## Bulk ops — bulk-aware backend dispatch
```ts
interface BulkCapableStorage extends AbstractStorage {
getMultiple(keys: string[]): Promise<Map<string, Uint8Array>>;
setMultiple(entries: Array<{ key: string; value: Uint8Array }>): Promise<void>;
}
function hasBulk(s: AbstractStorage): s is BulkCapableStorage {
return typeof (s as Partial<BulkCapableStorage>).getMultiple === "function"
&& typeof (s as Partial<BulkCapableStorage>).setMultiple === "function";
}
```
`StorageCache.mget` checks `hasBulk(storage)`:
- If yes, one `storage.getMultiple(prefixedKeys)` call; iterate the returned `Map`, decode each header + body, build the result map. Single TTL check per key.
- If no, fall back to `Promise.all(keys.map(k => this.get(k)))`.
`mset` mirrors the dispatch. `mdelete` returns the count of keys that actually existed and were removed. All three open an OTel span with `cache.bulk.size = keys.length`.
## Atomic ops — `compareAndSet`
```ts
async compareAndSet(key: string, expectedVersion: number, value: T, options?: CacheSetOptions): Promise<{ version: number }> {
const prefixed = this.prefixed(key);
const current = await this.readWithHeader(prefixed); // throws BACKEND_ERROR / ENTRY_CORRUPT
const observed = current?.meta.version ?? 0;
if (observed !== expectedVersion) {
throw new StorageCacheError(CACHE_STORAGE_CODES.CAS_CONFLICT, "error.cache-storage.cas-conflict", { key, context: { expected: expectedVersion, observed } });
}
const nextVersion = (observed + 1) >>> 0;
await this.writeWithVersion(prefixed, value, options, nextVersion);
return { version: nextVersion };
}
```
This is **optimistic** CAS — there is no backend-level read-modify-write atomicity, so two concurrent CAS calls against the same key from different processes can both observe the same `version` and both write. For coherent CAS across instances callers need a backend that supports atomic check-and-set (e.g. Redis `WATCH`/`MULTI`); the spec text makes this explicit and the doc points at `cache-redis` for the strong variant.
`getWithVersion(key): Promise<{ value: T; version: number } | undefined>` returns the decoded value alongside the header version so callers can drive the CAS loop without re-reading.
## StructuredCloneCodec (reused from superseded change)
Lift table reused verbatim from `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-cache-storage-cbor-codec/design.md`:
| JS type | CBOR tag | Payload |
|---------------|----------|---------------------------------------------------------------|
| `Date` | `0` | RFC 3339 string |
| `bigint > 0` | `2` | byte string, big-endian magnitude |
| `bigint < 0` | `3` | byte string, big-endian magnitude of `-1 - n` |
| `URL` | `32` | `URL.href` string |
| `RegExp` | `35` | `regex.source` string (flags dropped — documented trade-off) |
| `Uint8Array` | `64` | byte string |
| `Set` | `258` | array of items |
| `Map` | `259` | array of `[k, v]` pairs |
Encode walks the input, lifts carriers to `Tagged(tag, walk(payload))`, then calls `encode` from `@suaveplan/cbor`. Decode calls `decode` with the registry; any leftover `Tagged` post-walk triggers `CODEC_UNKNOWN_TAG`. Unencodable types (`Function`, `Symbol`, `WeakMap`, `WeakSet`, `Promise`, unregistered class instances) trigger `CODEC_UNSUPPORTED_TYPE`.
## Conformance harnesses
Two harnesses ship inside the package:
1. **`runCodecConformance(codec, { name, profile })`** — `profile` is `"json" | "structured-clone" | "bytes"`. Each profile defines the round-trip cases the codec must pass: `json` is primitives + plain objects + arrays; `structured-clone` adds `Date`/`Map`/`Set`/`Uint8Array`/`bigint`/`RegExp`/`URL` + deep nesting; `bytes` is `Uint8Array` round-trip only. Self-test feeds a deliberately broken codec and asserts at least one harness check fails.
2. **`runStorageCacheConformance({ createStorage, name })`** — accepts a factory `() => AbstractStorage` so the same suite can run against `MemoryStorage`, a Redis-backed storage, an OPFS storage, etc. Covers the full API (`get`/`set`/`delete`/`has`/`clear`/`getStats`/`mget`/`mset`/`mdelete`/`wrap`/`getOrSet`/`compareAndSet`/`getWithVersion`), every eviction policy, stampede dedup, SWR, and the 12 error codes. Lives in `./testing/conformance` so it ships with the package but is tree-shakeable.
The conformance harness lives **in this package**, not in `@suaveplan/testing`, because it imports `StorageCache` directly — lifting it would invert the dep graph (rule 8).
## Observability
```ts
const tracer = trace.getTracer("@suaveplan/cache-storage");
const meter = metrics.getMeter("@suaveplan/cache-storage");
const hitsCtr = meter.createCounter("cache.hits");
const missesCtr = meter.createCounter("cache.misses");
const evictionsCtr = meter.createCounter("cache.evictions");
const stampedeCtr = meter.createCounter("cache.stampede_prevented");
const swrCtr = meter.createCounter("cache.swr_refreshes");
const rejectedCtr = meter.createCounter("cache.rejected");
const payloadHist = meter.createHistogram("cache.payload_bytes");
```
Every public method opens a span named `cache.<op>` with attributes `cache.namespace`, `cache.key` (when low-cardinality safe — caller opts in via `{ traceKey: true }`), `cache.hit`, `cache.size_bytes`. Span ends with `SpanStatusCode.OK` on success or `SpanStatusCode.ERROR` carrying the `SuaveplanError.code`.
No exporter registered ⇒ all of this is no-op per the `@opentelemetry/api` contract — zero allocations on the hot path beyond the constant `tracer`/`meter` lookups.
## Error registry — 12 codes
```ts
export const CACHE_STORAGE_CODES = defineCodes("CACHE_STORAGE", [
"ENTRY_CORRUPT", // existing
"CODEC_ERROR", // existing
"BACKEND_ERROR", // existing
"KEY_TOO_LONG", // existing
"VALUE_TOO_LARGE", // existing
"STAMPEDE_TIMEOUT", // new
"CAS_CONFLICT", // new
"SWEEP_FAILED", // new
"EVICTION_FAILED", // new
"CODEC_UNSUPPORTED_TYPE", // new
"CODEC_UNKNOWN_TAG", // new
"NAMESPACE_CONFLICT", // new — raised when a child namespace tries to register a name already in use
] as const);
```
Every code is reachable from at least one test call site — verified by the `errors.test.ts` reachability assertion.
## File layout
```text
packages/flow/cache-storage/src/
codec/
codec.types.ts # unchanged
identity.{ts,test.ts,md} # unchanged
json.{ts,test.ts,md} # extended with conformance harness call
structured-clone.{ts,test.ts,md} # NEW
conformance.{ts,test.ts,md} # NEW
index.ts
eviction/
policy.types.ts # NEW — interface only
lru/{lru,lru.test,lru.md}.ts # NEW
lfu/{lfu,lfu.test,lfu.md}.ts # NEW
fifo/{fifo,fifo.test,fifo.md}.ts # NEW
ttl/{ttl,ttl.test,ttl.md}.ts # NEW — TtlOnlyPolicy
index.ts # NEW
stampede/
inflight-map/{inflight-map,...}.ts # NEW
index.ts # NEW
sweep/
ttl-sweeper/{ttl-sweeper,...}.ts # NEW
index.ts # NEW
swr/
swr-scheduler/{swr-scheduler,...}.ts # NEW
index.ts # NEW
observability/
metrics/{metrics,...}.ts # NEW
trace/{trace,...}.ts # NEW
index.ts # NEW
stats/
namespace-stats/{namespace-stats,...}.ts # NEW
index.ts # NEW
header/
header.ts # MODIFIED — version field
header.test.ts # extended
header.md # extended
index.ts
errors/
errors.ts # MODIFIED — 5 → 12 codes
errors.test.ts # extended — reachability for all 12
errors.md # extended
index.ts
storage-cache/
storage-cache.ts # MODIFIED — composes new collaborators (~280 LoC)
storage-cache.types.ts # MODIFIED — new options
storage-cache.test.ts # extended
storage-cache.md # extended
index.ts
registry-augment/ # unchanged
testing/
conformance/{conformance,...}.ts # NEW — runStorageCacheConformance
index.ts # NEW
index.ts # MODIFIED — new exports
```
Every new file respects the 300-line soft cap. The `StorageCache` itself stays under 300 lines because every cross-cutting concern (eviction, sweep, stampede, SWR, metrics) lives in a co-located collaborator.
## Trade-offs
- **In-memory eviction index.** A `StorageCache` instance keeps the eviction policy's index in memory. Multiple instances pointed at the same backend do not coordinate — documented contract. Coordinated eviction belongs to the backend (e.g. Redis `maxmemory-policy lru`).
- **Optimistic CAS.** `compareAndSet` is not backend-atomic. The doc says so and points at backend-native primitives (`WATCH`/`MULTI` in Redis). Strong CAS requires backend support.
- **Sweep cost.** The background sweep reads every key's header in `batchSize` pages. For backends where `list` is cheap (memory, OPFS) this is negligible; for cloud backends (S3) callers should tune `intervalMs` and `batchSize` accordingly. Documented.
- **OTel overhead when no exporter registered.** Negligible (the no-op tracer is a singleton, span/counter calls are no-op functions) — verified by mitata baseline.
- **Header v2 reserved-byte reinterpretation.** Backwards-compatible because v1 reserved bytes were defined as zero; flag bit 2 (`FLAG_HAS_VERSION`) disambiguates v1-from-v2 unambiguously.
## Future work (explicitly deferred — out of scope)
- Backend-specific implementations (`cache-opfs`, `cache-s3`) — each is its own package.
- Public `StructuredCloneCodec` tag-registration API for caller-defined carriers.
- Mutation-resistant CAS via backend primitives (Redis `WATCH`/`MULTI`) — belongs in `cache-redis`.
- Distributed stampede dedup across cache instances (single-flight via Redis `SETNX` lock) — belongs in `cache-redis`.
- Tiered cache (L1 memory in front of L2 storage) — belongs in a new `cache-tiered` package.

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.

View File

@@ -0,0 +1,482 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: cache-storage (cache-storage)
**Package**: `@suaveplan/cache-storage`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: StructuredCloneCodec round-trips the structured-clone-ish JS subset
The package SHALL export `StructuredCloneCodec<T> implements Codec<T>` from `./codec/structured-clone`. The codec SHALL faithfully round-trip the following JS value types through `encode` then `decode`: `null`, `boolean`, finite `number`, `string`, plain object, array, `Date`, `Map`, `Set`, `Uint8Array`, `bigint` (positive and negative), `RegExp` (source only — flags excluded by the tag-35 wire format), `URL`, and arbitrary nestings of the above. Encoding SHALL delegate to `encode` from `@suaveplan/cbor`; decoding SHALL delegate to `decode` from `@suaveplan/cbor` with a tag-handler registry that reverses the lift table.
#### Scenario: round-trip preserves Date instances
- **WHEN** a caller calls `codec.decode(codec.encode(new Date("2026-05-17T12:00:00.000Z")))`
- **THEN** the result is `instanceof Date` AND its `.getTime()` equals the input's `.getTime()`
#### Scenario: round-trip preserves Map with object keys and nested Set values
- **WHEN** a caller round-trips `new Map<unknown, unknown>([[{ id: 1 }, "a"], [{ id: 2 }, new Set([1, 2])]])`
- **THEN** the result is `instanceof Map`, has `size === 2`, AND iterating yields keys/values deep-equal to the input (the second value being `instanceof Set` with members `1` and `2`)
#### Scenario: round-trip preserves Uint8Array byte identity
- **WHEN** a caller round-trips `new Uint8Array([0, 1, 127, 128, 255])`
- **THEN** the result is `instanceof Uint8Array` AND every byte is equal to the input at the same index
#### Scenario: round-trip preserves bigint magnitude and sign
- **WHEN** a caller round-trips both `2n ** 200n` and `-(2n ** 200n) - 7n`
- **THEN** each result is `typeof "bigint"` AND strictly equals the input
#### Scenario: deeply nested structures survive
- **WHEN** a caller round-trips `{ users: new Map([["alice", { tags: new Set(["admin"]), joined: new Date(0), avatar: new Uint8Array([1, 2, 3]) }]]) }`
- **THEN** the result deep-equals the input across `Map`, `Set`, `Date`, and `Uint8Array` boundaries
**Verified by:** `src/codec/structured-clone.test.ts::"round-trip preserves Date instances"` AND `src/codec/structured-clone.test.ts::"round-trip preserves Map with object keys and nested Set values"` AND `src/codec/structured-clone.test.ts::"round-trip preserves Uint8Array byte identity"` AND `src/codec/structured-clone.test.ts::"round-trip preserves bigint magnitude and sign"` AND `src/codec/structured-clone.test.ts::"deeply nested structures survive"`
---
### Requirement: StructuredCloneCodec uses IANA-registered CBOR tags
The codec SHALL use the following CBOR tag numbers from the IANA CBOR Tags registry: `0` (RFC 3339 date-time string) for `Date`; `2` (positive bignum) for `bigint > 0n`; `3` (negative bignum) for `bigint < 0n`; `32` (URI) for `URL`; `35` (PCRE regex) for `RegExp`; `64` (`Uint8` typed array) for `Uint8Array`; `258` (Set) for `Set`; `259` (Map with explicit key/value pairs) for `Map`. The codec SHALL NOT invent private tag numbers for any carrier listed in the round-trip requirement above.
#### Scenario: encoded Date emits CBOR tag 0
- **WHEN** a caller calls `codec.encode(new Date("2026-05-17T12:00:00.000Z"))` AND the result is fed back through `decode` from `@suaveplan/cbor` with default tag handling
- **THEN** the decoded top-level value is a `Tagged` instance with `.tag === 0` AND `.value` is the RFC 3339 string
#### Scenario: encoded Uint8Array emits CBOR tag 64
- **WHEN** a caller calls `codec.encode(new Uint8Array([1, 2, 3]))` AND the result is fed back through `decode` from `@suaveplan/cbor` with default tag handling
- **THEN** the decoded top-level value is a `Tagged` instance with `.tag === 64`
**Verified by:** `src/codec/structured-clone.test.ts::"Date encodes under CBOR tag 0"` AND `src/codec/structured-clone.test.ts::"Uint8Array encodes under CBOR tag 64"`
---
### Requirement: CODEC_UNSUPPORTED_TYPE on encode of unencodable carriers
`StructuredCloneCodec.encode` SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE"` when the input contains (directly or transitively) any of: `Function`, `Symbol`, `WeakMap`, `WeakSet`, `Promise`, or any class instance whose constructor is not one of the registered carriers (`Date`, `Map`, `Set`, `Uint8Array`, `RegExp`, `URL`). The error SHALL carry the failing JS-type name in `context.typeName`.
#### Scenario: encoding a function throws CODEC_UNSUPPORTED_TYPE
- **WHEN** a caller calls `codec.encode((() => 1) as unknown)`
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE"`
#### Scenario: encoding a Symbol nested in an object throws CODEC_UNSUPPORTED_TYPE
- **WHEN** a caller calls `codec.encode({ ok: 1, bad: Symbol("x") } as unknown)`
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE"`
**Verified by:** `src/codec/structured-clone.test.ts::"encode throws CODEC_UNSUPPORTED_TYPE on a function"` AND `src/codec/structured-clone.test.ts::"encode throws CODEC_UNSUPPORTED_TYPE on a nested Symbol"`
---
### Requirement: CODEC_UNKNOWN_TAG on decode of an unregistered CBOR tag
`StructuredCloneCodec.decode` SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNKNOWN_TAG"` when the input CBOR bytes contain a `Tagged(n, …)` value whose tag `n` is not one of the registered tags (`0`, `2`, `3`, `32`, `35`, `64`, `258`, `259`). The error `context` SHALL carry the offending tag number. The codec SHALL NOT silently return the value as a `Tagged` wrapper.
#### Scenario: decoding an unrecognised tag throws CODEC_UNKNOWN_TAG
- **WHEN** a caller encodes `new Tagged(9999, "x")` via `encode` from `@suaveplan/cbor` AND passes the resulting bytes to `codec.decode`
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNKNOWN_TAG"` AND `error.context.tag === 9999`
**Verified by:** `src/codec/structured-clone.test.ts::"decode throws CODEC_UNKNOWN_TAG on an unregistered tag"`
---
### Requirement: CODEC_ERROR on malformed CBOR bytes
`StructuredCloneCodec.decode` SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_ERROR"` when the input bytes are not valid CBOR (catching any error thrown by `decode` from `@suaveplan/cbor` and re-wrapping). The underlying error SHALL be attached as `cause`.
#### Scenario: decoding random bytes throws CODEC_ERROR with cbor cause
- **WHEN** a caller calls `codec.decode(new Uint8Array([0xff, 0xff, 0xff, 0xff]))` for bytes that do not form a valid CBOR item
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_ERROR"` AND `error.cause` is the underlying `Error`
**Verified by:** `src/codec/structured-clone.test.ts::"decode throws CODEC_ERROR with cbor cause on malformed bytes"`
---
### Requirement: Codec conformance harness with profile selector
The package SHALL export a `runCodecConformance(codec, options)` harness from `./codec/conformance` accepting `{ name: string; profile: "json" | "structured-clone" | "bytes" }`. The harness SHALL execute round-trip assertions whose coverage matches the requested profile: `"bytes"` covers `Uint8Array` only; `"json"` covers JSON primitives, plain objects, and arrays; `"structured-clone"` covers the union of `"json"` plus `Date`, `Map`, `Set`, `Uint8Array`, `bigint`, `RegExp`, `URL`, and deeply nested combinations. The harness SHALL fail loudly when given a deliberately broken codec.
#### Scenario: harness fails on a broken codec
- **WHEN** the test suite passes a codec whose `decode` always returns a fixed `null` sentinel
- **THEN** at least one harness assertion fails
#### Scenario: harness passes for JsonCodec at the JSON profile
- **WHEN** the test suite calls `runCodecConformance(new JsonCodec<unknown>(), { name: "JsonCodec", profile: "json" })`
- **THEN** every assertion passes
#### Scenario: harness passes for IdentityCodec at the bytes profile
- **WHEN** the test suite calls `runCodecConformance(new IdentityCodec(), { name: "IdentityCodec", profile: "bytes" })`
- **THEN** every assertion passes
**Verified by:** `src/codec/conformance.test.ts::"harness fails on a deliberately broken codec"` AND `src/codec/json.test.ts::"JsonCodec satisfies the JSON-profile conformance harness"` AND `src/codec/identity.test.ts::"IdentityCodec satisfies the bytes-profile conformance harness"`
---
### Requirement: Header v2 carries a u32 BE version field
The package SHALL extend the 32-byte fixed-width header so that bytes 24..27 carry `version: u32 BE` and bytes 28..31 remain reserved-zero. `flags` bit 2 (`FLAG_HAS_VERSION = 1 << 2`) SHALL be set when the writer is v2-aware. Legacy v1 entries (where `FLAG_HAS_VERSION` is clear and bytes 24..31 are all zero) SHALL decode as `version: 0` so the on-disk format remains forward-compatible. `HEADER_BYTES = 32` SHALL be unchanged. `encodeHeader(meta)` SHALL accept `meta.version`; `decodeHeader(bytes)` SHALL return `meta.version`.
#### Scenario: header round-trips a non-zero version
- **WHEN** a caller calls `decodeHeader(encodeHeader({ createdAt: 1, expiresAt: 2, flags: FLAG_HAS_METADATA | FLAG_HAS_VERSION, hitCount: 4, version: 42 }))`
- **THEN** the decoded `version` equals `42`
#### Scenario: legacy v1 header decodes as version 0
- **WHEN** a caller constructs a 32-byte buffer with `FLAG_HAS_VERSION` clear AND bytes 24..31 all zero AND calls `decodeHeader(buffer)`
- **THEN** the decoded `version` equals `0`
**Verified by:** `src/header/header.test.ts::"header round-trips a non-zero version field"` AND `src/header/header.test.ts::"legacy v1 header without FLAG_HAS_VERSION decodes as version 0"`
---
### Requirement: EvictionPolicy interface decouples eviction from StorageCache
The package SHALL export `EvictionPolicy` from `./eviction/policy` with the methods `onSet(key, sizeBytes, now): readonly string[]`, `onHit(key, now): void`, `onDelete(key): void`, `reset(): void`, and `snapshot(): { entries: number; bytes: number }`. `StorageCache` SHALL accept an `eviction?: { policy: EvictionPolicy; maxEntries?: number; maxBytes?: number }` constructor option. After every successful `set`, `StorageCache` SHALL invoke `policy.onSet`, delete every returned victim key via the backing storage, increment the `evictions` counter, and emit an `evict` event with the appropriate `reason`. Eviction-policy state SHALL be in-memory per `StorageCache` instance.
#### Scenario: no eviction policy preserves today's behaviour
- **WHEN** a caller constructs `new StorageCache({ storage })` (no `eviction` option) AND writes 1,000 entries
- **THEN** every entry remains readable via `cache.get` AND `getStats().evictions === 0`
#### Scenario: EVICTION_FAILED wraps a backend delete error during eviction
- **WHEN** a fake `AbstractStorage` whose `delete` rejects is wired into `StorageCache` with an `LruPolicy({ maxEntries: 1 })` AND a second `set` triggers an eviction
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.EVICTION_FAILED"`
**Verified by:** `src/storage-cache/storage-cache.test.ts::"no eviction policy preserves today's behaviour across 1000 writes"` AND `src/storage-cache/storage-cache.test.ts::"EVICTION_FAILED wraps a backend delete error during eviction"`
---
### Requirement: LruPolicy evicts the least-recently-touched key
`LruPolicy` SHALL maintain a doubly-linked list of keys ordered by last touch (`onSet` or `onHit`). When `entries > maxEntries` or `bytes > maxBytes`, `onSet` SHALL return the tail (oldest) keys until both bounds are satisfied. `onHit` SHALL move the touched key to the head. `reset()` SHALL clear the list.
#### Scenario: third write with maxEntries 2 evicts the first-written key
- **WHEN** an `LruPolicy({ maxEntries: 2 })` records `onSet("a")`, `onSet("b")`, `onSet("c")`
- **THEN** the victims returned by the third `onSet` include `"a"` AND do not include `"b"` or `"c"`
#### Scenario: onHit on the oldest key prevents its eviction on the next set
- **WHEN** the policy records `onSet("a")`, `onSet("b")`, `onHit("a")`, `onSet("c")`
- **THEN** the victims returned by the third `onSet` include `"b"` AND do not include `"a"`
**Verified by:** `src/eviction/lru/lru.test.ts::"third write with maxEntries 2 evicts the first-written key"` AND `src/eviction/lru/lru.test.ts::"onHit promotes a key so the next set evicts the next-oldest"`
---
### Requirement: LfuPolicy evicts the lowest-frequency key
`LfuPolicy` SHALL maintain per-key frequency counters (saturating at `u32`) and frequency buckets for O(1) eviction. When over-capacity, `onSet` SHALL return the lowest-frequency keys; ties SHALL be broken by oldest first-insertion.
#### Scenario: lowest-frequency key is evicted under capacity pressure
- **WHEN** an `LfuPolicy({ maxEntries: 2 })` records `onSet("a")`, `onSet("b")`, `onHit("a")`, `onHit("a")`, `onSet("c")`
- **THEN** the victims returned by the final `onSet` include `"b"` AND do not include `"a"`
**Verified by:** `src/eviction/lfu/lfu.test.ts::"lowest-frequency key is evicted under capacity pressure"`
---
### Requirement: FifoPolicy evicts the oldest-inserted key regardless of access
`FifoPolicy` SHALL evict in strict insertion order. `onHit` SHALL be a no-op for ordering purposes.
#### Scenario: onHit does not affect FIFO eviction order
- **WHEN** a `FifoPolicy({ maxEntries: 2 })` records `onSet("a")`, `onSet("b")`, `onHit("a")`, `onSet("c")`
- **THEN** the victims returned by the final `onSet` include `"a"`
**Verified by:** `src/eviction/fifo/fifo.test.ts::"onHit does not affect FIFO eviction order"`
---
### Requirement: TtlOnlyPolicy never evicts on count or size
`TtlOnlyPolicy` SHALL always return an empty victim list from `onSet`. It SHALL still be a valid `EvictionPolicy` so `StorageCache` can be configured without size-bounded eviction while still using the policy seam for stats.
#### Scenario: TtlOnlyPolicy returns no victims even at extreme cardinality
- **WHEN** a `TtlOnlyPolicy()` receives 10,000 `onSet` calls with arbitrary `sizeBytes`
- **THEN** every call returns an empty array
**Verified by:** `src/eviction/ttl/ttl.test.ts::"TtlOnlyPolicy never returns a victim regardless of cardinality"`
---
### Requirement: InflightMap deduplicates concurrent loaders (stampede protection)
The package SHALL export `InflightMap<T>` from `./stampede/inflight-map`. `run(key, loader, timeoutMs?)` SHALL return the existing in-flight promise when one exists for `key`. The map entry SHALL be deleted in a `finally` block so subsequent calls re-invoke the loader. On timeout, `run` SHALL reject with `StorageCacheError` `code === "CACHE_STORAGE.STAMPEDE_TIMEOUT"` AND remove the entry. The `pending` getter SHALL return the current entry count. `StorageCache.wrap` and `StorageCache.getOrSet` SHALL consult an `InflightMap` and increment a `stampedePrevented` counter on every dedup hit.
#### Scenario: concurrent wrap calls invoke the loader exactly once
- **WHEN** 10 concurrent `cache.wrap("k", loader, { stampede: { timeoutMs: 1000 } })` calls fire against a miss with a single loader
- **THEN** the loader is invoked exactly once AND every caller resolves with the same value AND `getStats().stampedePrevented === 9`
#### Scenario: loader exceeding the timeout rejects with STAMPEDE_TIMEOUT
- **WHEN** a loader that never resolves is driven against `wrap("k", loader, { stampede: { timeoutMs: 10 } })` and the injected `FakeTimers` advance past `10` ms
- **THEN** the call rejects with `StorageCacheError` `code === "CACHE_STORAGE.STAMPEDE_TIMEOUT"`
**Verified by:** `src/storage-cache/storage-cache.test.ts::"concurrent wrap calls invoke the loader exactly once and increment stampedePrevented"` AND `src/stampede/inflight-map/inflight-map.test.ts::"loader exceeding the timeout rejects with STAMPEDE_TIMEOUT"`
---
### Requirement: Background TTL sweep removes expired entries between reads
The package SHALL export `TtlSweeper` from `./sweep/ttl-sweeper`. When `StorageCache` is constructed with `sweep: { intervalMs, batchSize? }`, `start()` SHALL schedule recurring sweeps via the injected `TimersLike.setTimeout`; `stop()` SHALL cancel the next tick and await any in-flight page. Each sweep SHALL list keys via `storage.list({ prefix: namespace, limit: batchSize })`, read each header (HEADER_BYTES only), and `storage.delete` any entry whose `expiresAt !== 0` and `now >= expiresAt`, emitting an `expire` event and incrementing the `evictions` counter. On any per-key error the sweeper SHALL emit a `sweep-error` event carrying a `StorageCacheError` with `code === "CACHE_STORAGE.SWEEP_FAILED"` AND continue with the next key.
#### Scenario: sweep deletes expired entries without a prior read
- **WHEN** a `StorageCache` with `sweep: { intervalMs: 1000 }` has 3 entries (two expired, one fresh per a `FakeClock`), `start()` is called, AND the `FakeTimers` advance past `intervalMs`
- **THEN** `storage.exists(prefixedKey)` returns `false` for both expired keys AND `true` for the fresh key AND the `expire` listener was invoked twice
#### Scenario: sweep continues past a per-key backend error
- **WHEN** the sweeper encounters one key whose backend `delete` rejects AND additional expired keys after it
- **THEN** a `sweep-error` event fires for the failing key AND every remaining expired key is still processed
**Verified by:** `src/sweep/ttl-sweeper/ttl-sweeper.test.ts::"sweep deletes expired entries without a prior read"` AND `src/sweep/ttl-sweeper/ttl-sweeper.test.ts::"sweep continues past a per-key backend error"`
---
### Requirement: Stale-while-revalidate returns the stale value and refreshes in background
`StorageCache.wrap(key, loader, { staleWhileRevalidate: true, staleThresholdMs })` SHALL return the cached value immediately when `clock() >= expiresAt - staleThresholdMs && clock() < expiresAt`, AND SHALL schedule `loader` to refresh the entry via `TimersLike.queueMicrotask`. Refresh errors SHALL emit a `swr-refresh-failed` event AND increment a `swrRefreshFailures` counter, AND SHALL NOT reject the original caller. When `clock() >= expiresAt`, the call SHALL fall through to the stampede-protected miss path.
#### Scenario: stale entry is served immediately and refreshed in background
- **WHEN** a `StorageCache` with an injected `FakeClock` returns a cached value whose `expiresAt - clock() === 5` and the caller calls `wrap("k", loader, { staleWhileRevalidate: true, staleThresholdMs: 10 })`
- **THEN** the call resolves with the cached value before `loader` is awaited AND after draining the microtask queue `storage.get(prefixed)` returns the loader's new bytes
#### Scenario: refresh failure does not surface to the caller
- **WHEN** a loader that rejects is scheduled by a stale-while-revalidate `wrap` call
- **THEN** the original `wrap` call still resolves with the stale value AND a `swr-refresh-failed` event fires AND `getStats().swrRefreshFailures === 1`
**Verified by:** `src/storage-cache/storage-cache.test.ts::"stale entry is served immediately and refreshed in background"` AND `src/storage-cache/storage-cache.test.ts::"swr refresh failure does not surface to the caller"`
---
### Requirement: Bulk ops dispatch through the backend when available
The package SHALL extend `StorageCache` with `mget(keys): Promise<Map<string, T>>`, `mset(entries): Promise<void>`, and `mdelete(keys): Promise<number>`. When the underlying `AbstractStorage` exposes `getMultiple` and `setMultiple` (duck-typed via runtime check), `mget` and `mset` SHALL dispatch through the bulk methods in a single round-trip; otherwise they SHALL fall back to per-key calls via `Promise.all`. `mdelete` SHALL return the count of keys that actually existed.
#### Scenario: mget uses the backend bulk method when available
- **WHEN** a fake `AbstractStorage` whose `getMultiple` returns a `Map` with two entries (and whose per-key `get` throws if invoked) is wired into `StorageCache.mget(["a", "b"])`
- **THEN** the call returns a `Map` of size 2 AND the per-key `get` was never invoked
#### Scenario: mget falls back to per-key get when bulk is unavailable
- **WHEN** a fake `AbstractStorage` that lacks `getMultiple` is wired into `StorageCache.mget(["a", "b"])`
- **THEN** the call returns the merged result via two `get` calls AND no `TypeError` is thrown
**Verified by:** `src/storage-cache/storage-cache.test.ts::"mget uses the backend bulk method when available"` AND `src/storage-cache/storage-cache.test.ts::"mget falls back to per-key get when bulk is unavailable"`
---
### Requirement: getOrSet is wrap with stampede dedup enabled by default
`StorageCache.getOrSet(key, loader, options?)` SHALL return the cached value on hit; on miss it SHALL invoke `loader` exactly once across concurrent callers via the same `InflightMap` used by `wrap`, write the result with the given options, and return it. The default `stampede.timeoutMs` SHALL be `30000`.
#### Scenario: getOrSet dedups concurrent loaders by default
- **WHEN** 5 concurrent `cache.getOrSet("k", loader)` calls fire against a miss
- **THEN** the loader is invoked exactly once AND `getStats().stampedePrevented === 4`
**Verified by:** `src/storage-cache/storage-cache.test.ts::"getOrSet dedups concurrent loaders by default"`
---
### Requirement: compareAndSet enforces optimistic version-based concurrency
`StorageCache.compareAndSet(key, expectedVersion, value, options?)` SHALL read the stored header, compare its `version` field against `expectedVersion`, and write only when they are equal. On mismatch it SHALL throw `StorageCacheError` `code === "CACHE_STORAGE.CAS_CONFLICT"` whose `context` carries `{ expected, observed }`. On match it SHALL write the value with `version = (observed + 1) >>> 0` AND return `{ version: newVersion }`. `getWithVersion(key)` SHALL return `{ value, version } | undefined`. CAS SHALL be optimistic — atomicity across processes requires backend-specific support and is out of scope.
#### Scenario: compareAndSet succeeds when the expected version matches
- **WHEN** a caller `set`s `"k"` (which writes `version: 1`), then `compareAndSet("k", 1, "new")`
- **THEN** the call resolves with `{ version: 2 }` AND `getWithVersion("k")` returns `{ value: "new", version: 2 }`
#### Scenario: compareAndSet throws CAS_CONFLICT on version mismatch
- **WHEN** a caller `set`s `"k"` (version 1) AND calls `compareAndSet("k", 0, "new")`
- **THEN** the call throws `StorageCacheError` `code === "CACHE_STORAGE.CAS_CONFLICT"` AND `error.context.observed === 1` AND `error.context.expected === 0`
#### Scenario: compareAndSet on a legacy v1 entry treats version as 0
- **WHEN** a v1-shaped entry (FLAG_HAS_VERSION clear) is pre-seeded into the backend AND a caller calls `compareAndSet("k", 0, "new")`
- **THEN** the call resolves with `{ version: 1 }`
**Verified by:** `src/storage-cache/storage-cache.test.ts::"compareAndSet succeeds when the expected version matches"` AND `src/storage-cache/storage-cache.test.ts::"compareAndSet throws CAS_CONFLICT on version mismatch"` AND `src/storage-cache/storage-cache.test.ts::"compareAndSet on a legacy v1 entry treats version as 0"`
---
### Requirement: Configurable serialization size limits
`StorageCacheOptions` SHALL accept `limits?: { maxKeyBytes?: number; maxValueBytes?: number }` overriding the built-in defaults (`1024` and `10 * 1024 * 1024`). Overflow on `set` SHALL throw `KEY_TOO_LONG` or `VALUE_TOO_LARGE` AND emit a `rejected` event AND increment a `rejected` counter.
#### Scenario: a value larger than the configured limit throws VALUE_TOO_LARGE and emits rejected
- **WHEN** a `StorageCache` constructed with `limits: { maxValueBytes: 16 }` is asked to `set("k", "a".repeat(64))`
- **THEN** the call throws `StorageCacheError` `code === "CACHE_STORAGE.VALUE_TOO_LARGE"` AND the `rejected` listener was invoked once with `{ key: "k", reason: "value-too-large" }`
**Verified by:** `src/storage-cache/storage-cache.test.ts::"a value larger than the configured limit throws VALUE_TOO_LARGE and emits rejected"`
---
### Requirement: OTel spans and counters surface every public op
`StorageCache` SHALL open a span via `trace.getTracer("@suaveplan/cache-storage")` for every public method (`get`, `set`, `delete`, `has`, `clear`, `wrap`, `getOrSet`, `compareAndSet`, `getWithVersion`, `mget`, `mset`, `mdelete`). Each span SHALL set attributes `cache.namespace` and `cache.hit` where applicable; on error the span SHALL be set to `SpanStatusCode.ERROR` and carry `error.code`. The package SHALL register the counters `cache.hits`, `cache.misses`, `cache.evictions`, `cache.stampede_prevented`, `cache.swr_refreshes`, `cache.rejected` and the histogram `cache.payload_bytes` via `metrics.getMeter("@suaveplan/cache-storage")`. With no exporter registered, all instrumentation SHALL be no-op.
#### Scenario: get on a hit opens a span with cache.hit true and increments cache.hits
- **WHEN** an in-memory `SpanExporter` and `MetricReader` are attached, a key is set, and the caller calls `cache.get("k")`
- **THEN** exactly one span named `cache.get` is recorded with `attributes["cache.hit"] === true` AND the `cache.hits` counter is `1`
#### Scenario: a thrown StorageCacheError tags the span with the error code
- **WHEN** an in-memory `SpanExporter` is attached AND a `set` call rejects with `VALUE_TOO_LARGE`
- **THEN** exactly one span named `cache.set` is recorded with `status.code === SpanStatusCode.ERROR` AND `attributes["error.code"] === "CACHE_STORAGE.VALUE_TOO_LARGE"`
**Verified by:** `src/observability/trace/trace.test.ts::"get on a hit opens a span with cache.hit true and increments cache.hits"` AND `src/observability/trace/trace.test.ts::"a thrown StorageCacheError tags the span with the error code"`
---
### Requirement: Per-namespace stats via cache.namespace(sub)
`StorageCache.namespace(sub: string): StorageCache<T>` SHALL return a child view that prefixes every key with `${parentNamespace}:${sub}:` and shares the same backend storage, codec, eviction policy, in-flight map, and stats collector. `getStats({ namespace })` SHALL return counters scoped to the given namespace. Constructing two child views with the same `sub` against the same parent SHALL throw `StorageCacheError` `code === "CACHE_STORAGE.NAMESPACE_CONFLICT"`.
#### Scenario: child namespace stats are tracked independently from the parent
- **WHEN** a parent cache and `child = parent.namespace("users")` each record one hit
- **THEN** `parent.getStats({ namespace: "users" }).hits === 1` AND `parent.getStats({ namespace: "" }).hits === 1`
#### Scenario: duplicate child namespace throws NAMESPACE_CONFLICT
- **WHEN** a caller calls `parent.namespace("users")` twice without disposing the first
- **THEN** the second call throws `StorageCacheError` `code === "CACHE_STORAGE.NAMESPACE_CONFLICT"`
**Verified by:** `src/stats/namespace-stats/namespace-stats.test.ts::"child namespace stats are tracked independently from the parent"` AND `src/storage-cache/storage-cache.test.ts::"duplicate child namespace throws NAMESPACE_CONFLICT"`
---
### Requirement: Multi-backend conformance harness
The package SHALL export `runStorageCacheConformance({ createStorage, name })` from `./testing/conformance`. The harness SHALL invoke the full `StorageCache` public surface (every method, every eviction policy, stampede, SWR, CAS) against a caller-supplied `AbstractStorage` factory AND SHALL assert that every one of the 12 `CACHE_STORAGE_CODES` is reachable from at least one call site. The harness SHALL fail loudly when given a deliberately broken `AbstractStorage` factory.
#### Scenario: harness passes for MemoryStorage from @suaveplan/storage
- **WHEN** the test suite calls `runStorageCacheConformance({ createStorage: () => new MemoryStorage(), name: "memory" })`
- **THEN** every assertion passes
#### Scenario: harness fails on a deliberately broken storage factory
- **WHEN** the test suite supplies a fake `AbstractStorage` whose `set` is a no-op (drops every write)
- **THEN** at least one harness assertion fails
**Verified by:** `src/testing/conformance/conformance.test.ts::"harness passes for MemoryStorage from @suaveplan/storage"` AND `src/testing/conformance/conformance.test.ts::"harness fails on a deliberately broken storage factory"`
---
### Requirement: CACHE_STORAGE registry covers every failure path with 12 codes
`CACHE_STORAGE_CODES` SHALL be defined via `defineCodes("CACHE_STORAGE", [...])` with exactly these 12 codes: `ENTRY_CORRUPT`, `CODEC_ERROR`, `BACKEND_ERROR`, `KEY_TOO_LONG`, `VALUE_TOO_LARGE`, `STAMPEDE_TIMEOUT`, `CAS_CONFLICT`, `SWEEP_FAILED`, `EVICTION_FAILED`, `CODEC_UNSUPPORTED_TYPE`, `CODEC_UNKNOWN_TAG`, `NAMESPACE_CONFLICT`. Every code SHALL be reachable from at least one test in this package.
#### Scenario: every CACHE_STORAGE code is reachable
- **WHEN** the test suite runs
- **THEN** for every entry in `CACHE_STORAGE_CODES`, at least one assertion exercises a real call site that produces that code
**Verified by:** `src/errors/errors.test.ts::"every CACHE_STORAGE code is reachable from a real call site"`
---
### Requirement: Default behaviour preserved — zero-config StorageCache is unchanged
A zero-config `new StorageCache({ storage })` SHALL behave exactly as today: `JsonCodec` default; no eviction; no background sweep; no stampede dedup; no SWR; no namespace; existing `MAX_KEY_LENGTH` and `MAX_VALUE_BYTES` limits. The bytes written past `HEADER_BYTES` for a JSON-serialisable value SHALL equal `new TextEncoder().encode(JSON.stringify(value))` byte-for-byte.
#### Scenario: zero-config default codec is JsonCodec
- **WHEN** a caller constructs `new StorageCache({ storage: new MemoryStorage() })`, calls `set("k", { ok: 1 })`, and inspects the bytes written past `HEADER_BYTES`
- **THEN** those bytes equal `new TextEncoder().encode(JSON.stringify({ ok: 1 }))`
#### Scenario: zero-config wrap matches AbstractCache.wrap semantics
- **WHEN** a caller invokes `cache.wrap("k", loader)` without `stampede` or `staleWhileRevalidate` options
- **THEN** the call returns the same value as the parent `AbstractCache.wrap` would AND the loader is invoked once per miss (no dedup)
**Verified by:** `src/storage-cache/storage-cache.test.ts::"zero-config default codec is JsonCodec — bytes past the header are UTF-8 JSON"` AND `src/storage-cache/storage-cache.test.ts::"zero-config wrap matches AbstractCache.wrap semantics"`
---
### Requirement: Subpath exports cover every new feature module
`package.json` SHALL expose subpath exports for every new feature module so consumers can tree-shake: `./codec/structured-clone`, `./codec/conformance`, `./eviction`, `./eviction/policy`, `./eviction/lru`, `./eviction/lfu`, `./eviction/fifo`, `./eviction/ttl`, `./stampede/inflight-map`, `./sweep/ttl-sweeper`, `./swr/swr-scheduler`, `./observability/metrics`, `./observability/trace`, `./stats/namespace-stats`, `./testing/conformance`. Every new feature barrel SHALL use explicit named re-exports (no `export *`).
#### Scenario: every new subpath resolves to the documented class
- **WHEN** a consumer imports each of the new subpaths and references the documented export
- **THEN** every import resolves AND the referenced class/function exists
**Verified by:** `src/storage-cache/storage-cache.test.ts::"every new subpath export resolves to the documented class"`
---
### Requirement: Tier purity — universal preserved
The package SHALL NOT import from `node:*`, `bun:*`, or any DOM-only API. Every new file added by this change SHALL preserve this property. The package SHALL continue to run unmodified under Node 18+, Bun, Deno, modern browsers, and Cloudflare Workers.
#### Scenario: tier-purity grep finds no forbidden imports after the change
- **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/flow/cache-storage/src/` is run
- **THEN** the output is empty
**Verified by:** `src/storage-cache/storage-cache.test.ts::"src tree contains no node: or bun: imports"`
## MODIFIED Requirements
### Requirement: 32-byte fixed-width metadata header
`StorageCache` SHALL prepend a 32-byte fixed-width header to every stored value. The header layout SHALL be: bytes 0..7 `createdAt` u64 BE; bytes 8..15 `expiresAt` u64 BE (`0` means no expiry); bytes 16..19 `flags` u32 BE; bytes 20..23 `hitCount` u32 BE; bytes 24..27 `version` u32 BE (v2 — formerly reserved); bytes 28..31 reserved (zero). `flags` bit 2 (`FLAG_HAS_VERSION = 1 << 2`) SHALL be set when bytes 24..27 carry a meaningful version. The package SHALL export `HEADER_BYTES = 32`, `encodeHeader(meta) → Uint8Array`, `decodeHeader(bytes) → HeaderMeta`, and `FLAG_HAS_VERSION`.
#### Scenario: header round-trips every field including version
- **WHEN** a caller calls `decodeHeader(encodeHeader({ createdAt: 1, expiresAt: 2, flags: FLAG_HAS_METADATA | FLAG_HAS_VERSION, hitCount: 4, version: 7 }))`
- **THEN** the result is `{ createdAt: 1, expiresAt: 2, flags: FLAG_HAS_METADATA | FLAG_HAS_VERSION, hitCount: 4, version: 7 }`
#### Scenario: decodeHeader on a buffer shorter than 32 bytes throws ENTRY_CORRUPT
- **WHEN** a caller calls `decodeHeader(new Uint8Array(16))`
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.ENTRY_CORRUPT"`
**Verified by:** `src/header/header.test.ts::"header round-trips every field including the version field"` AND `src/header/header.test.ts::"decodeHeader throws ENTRY_CORRUPT on bytes shorter than HEADER_BYTES"`
---
### Requirement: StorageCache extends AbstractCache<T>
The package SHALL provide `StorageCache<T> extends AbstractCache<T>` (from `@suaveplan/cache`). The class SHALL implement every method on `AbstractCacheShape<T>`: `get`, `set`, `delete`, `clear`, `has`, `getStats`, `getMultiple`, `setMultiple`, `wrap`. The class SHALL additionally expose `mget`, `mset`, `mdelete`, `getOrSet`, `compareAndSet`, `getWithVersion`, `namespace`, `start`, and `stop`. The constructor SHALL accept `{ storage: AbstractStorage; codec?: Codec<T>; defaultTtl?: number; clock?: Clock; events?: EventBusLike<CacheEvents>; namespace?: string; eviction?: EvictionConfig; sweep?: { intervalMs: number; batchSize?: number }; limits?: { maxKeyBytes?: number; maxValueBytes?: number }; timers?: TimersLike; tracer?: Tracer; metrics?: Meter }`. Every option beyond `storage` SHALL default such that today's behaviour is preserved exactly.
#### Scenario: instance is an AbstractCache and exposes the new public surface
- **WHEN** a caller constructs `new StorageCache({ storage: new MemoryStorage() })`
- **THEN** the instance is `instanceof AbstractCache` AND every `AbstractCacheShape<T>` method exists AND the methods `mget`, `mset`, `mdelete`, `getOrSet`, `compareAndSet`, `getWithVersion`, `namespace`, `start`, `stop` all exist
**Verified by:** `src/storage-cache/storage-cache.test.ts::"instance is an AbstractCache and exposes both the AbstractCacheShape surface and the extended surface"`

View File

@@ -0,0 +1,141 @@
# Tasks — complete-cache-storage
> Every checkbox MUST reflect work actually done in this change. Mostly additive; one MODIFIED to header layout (forward-compatible) and one MODIFIED to `StorageCacheOptions` (additive, all defaults preserve current behaviour).
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Pre-flight
- [ ] 1.1 Re-read `openspec/AGENTS.md` §0c (delta format), §13 (gate order), §6a (barrels), §7 (test-runner imports)
- [ ] 1.2 Confirm `@suaveplan/cbor` ships on genesis at `packages/flow/cbor` with `encode`, `decode`, `Tagged`, `type TagHandler` in the root barrel
- [ ] 1.3 Confirm `@suaveplan/types` exposes `TimersLike` (with `setTimeout`/`clearTimeout`/`queueMicrotask`/`now`); add it there first if absent
- [ ] 1.4 Build a numbered checklist of every `### Requirement` and `#### Scenario` in `specs/cache-storage/spec.md` in working notes (AGENTS §0c step 6)
- [ ] 1.5 Read the superseded change `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-cache-storage-cbor-codec/{design,specs/cache-storage/spec}.md` — tag table and codec design reused verbatim
## 2. Package wiring
- [ ] 2.1 Add `@suaveplan/cbor` to `packages/flow/cache-storage/package.json` `dependencies` at `workspace:^`
- [ ] 2.2 Add `@opentelemetry/api` (catalog) to `dependencies`
- [ ] 2.3 Add subpath exports for every new feature module (`./codec/structured-clone`, `./codec/conformance`, `./eviction`, `./eviction/lru`, `./eviction/lfu`, `./eviction/fifo`, `./eviction/ttl`, `./eviction/policy`, `./stampede/inflight-map`, `./sweep/ttl-sweeper`, `./swr/swr-scheduler`, `./observability/metrics`, `./observability/trace`, `./stats/namespace-stats`, `./testing/conformance`)
- [ ] 2.4 Confirm `sideEffects` array still lists only the two real side-effect files (`./src/index.ts`, `./src/registry-augment/registry-augment.ts`) and their dist twins
## 3. Error registry extension (§spec REQ-3)
- [ ] 3.1 Extend `src/errors/errors.ts` `CACHE_STORAGE_CODES` from 5 codes to 12: add `STAMPEDE_TIMEOUT`, `CAS_CONFLICT`, `SWEEP_FAILED`, `EVICTION_FAILED`, `CODEC_UNSUPPORTED_TYPE`, `CODEC_UNKNOWN_TAG`, `NAMESPACE_CONFLICT`
- [ ] 3.2 Update `src/errors/errors.md` Features + API Reference to list every new code with its raising call site (doc-drift gate §4a)
- [ ] 3.3 Extend `src/errors/errors.test.ts` reachability assertion to walk all 12 codes
## 4. Header v2 (§spec REQ-2)
- [ ] 4.1 Modify `src/header/header.ts` — repurpose bytes 24..27 as `version: u32 BE`; bytes 28..31 stay reserved-zero; add `FLAG_HAS_VERSION = 1 << 2`
- [ ] 4.2 Update `HeaderMeta` to add `version: number`
- [ ] 4.3 Extend `encodeHeader` / `decodeHeader` to round-trip `version`; legacy headers (flag bit 2 clear) decode as `version: 0`
- [ ] 4.4 `src/header/header.test.ts` — add forward-compat test: a v1-shaped buffer (`FLAG_HAS_VERSION` clear, bytes 24..31 zero) decodes as `version: 0`
- [ ] 4.5 Update `src/header/header.md` to describe the v2 layout, the `FLAG_HAS_VERSION` bit, and the forward-compatibility contract
## 5. StructuredCloneCodec (§spec REQ-5..REQ-9)
- [ ] 5.1 `src/codec/structured-clone.ts` — `StructuredCloneCodec<T> implements Codec<T>`; lift table from `design.md`; encode via `@suaveplan/cbor` `encode`; decode via `@suaveplan/cbor` `decode` with frozen tag registry; post-walk to detect unhandled `Tagged` → throw `CODEC_UNKNOWN_TAG`
- [ ] 5.2 `src/codec/structured-clone.md` — sibling docs (≥200 words, basic + advanced examples, API reference, implementation notes including IANA tag references and the RegExp-flags trade-off)
- [ ] 5.3 `src/codec/structured-clone.test.ts` — every `**Verified by:**` line from spec REQ-5..REQ-9, plus direct tests for `CODEC_UNSUPPORTED_TYPE` (encode of `() => {}`, `Symbol`, `WeakMap`, `Promise`) and `CODEC_UNKNOWN_TAG` (decode of `Tagged(9999, "x")`) and `CODEC_ERROR` (malformed CBOR bytes)
- [ ] 5.4 If the file exceeds the 300-line soft cap, extract the lift table to `src/codec/structured-clone.tags.ts`
## 6. Codec conformance harness (§spec REQ-10)
- [ ] 6.1 `src/codec/conformance.ts` — `runCodecConformance(codec, { name: string; profile: "json" | "structured-clone" | "bytes" })`; cases per profile match the design doc
- [ ] 6.2 `src/codec/conformance.md` — sibling docs
- [ ] 6.3 `src/codec/conformance.test.ts` — self-test: pass a broken codec (decode returns a fixed `null` sentinel), assert at least one harness check fails
- [ ] 6.4 Extend `src/codec/json.test.ts` with `runCodecConformance(new JsonCodec<unknown>(), { name: "JsonCodec", profile: "json" })`
- [ ] 6.5 Extend `src/codec/identity.test.ts` with `runCodecConformance(new IdentityCodec(), { name: "IdentityCodec", profile: "bytes" })`
## 7. Eviction policies (§spec REQ-11..REQ-15)
- [ ] 7.1 `src/eviction/policy.types.ts` — `EvictionPolicy` interface, `EvictionConfig` type, `EvictionReason` union (`"lru" | "lfu" | "fifo" | "ttl" | "size"`)
- [ ] 7.2 `src/eviction/lru/lru.ts` — O(1) LRU via doubly-linked-list keyed by prefixed key; `onSet`/`onHit`/`onDelete`/`reset`/`snapshot`
- [ ] 7.3 `src/eviction/lfu/lfu.ts` — frequency-bucket LFU; tie-break by oldest first-insertion
- [ ] 7.4 `src/eviction/fifo/fifo.ts` — insertion-ordered `Map` FIFO
- [ ] 7.5 `src/eviction/ttl/ttl.ts` — `TtlOnlyPolicy` — never evicts on count/size; always returns `[]` from `onSet`
- [ ] 7.6 Each policy ships `<name>.test.ts` (property tests for ordering invariants, boundary `maxEntries` / `maxBytes`, `reset` + `snapshot` correctness) and `<name>.md` (≥200 words, basic + advanced examples, API reference, complexity notes)
- [ ] 7.7 `src/eviction/index.ts` — explicit named re-exports of every policy and the types
- [ ] 7.8 Files stay under the 300-line soft cap
## 8. Stampede protection (§spec REQ-16)
- [ ] 8.1 `src/stampede/inflight-map/inflight-map.ts` — `InflightMap<T>` with `run(key, loader, timeoutMs?)`; concurrent `run` of the same key returns the existing promise; timeout rejects with `CACHE_STORAGE.STAMPEDE_TIMEOUT`; map entry cleared in `finally`; `pending` counter
- [ ] 8.2 `src/stampede/inflight-map/inflight-map.test.ts` — concurrent-loader dedup test using an injected `FakeTimers`; timeout test; loader-error propagation test
- [ ] 8.3 `src/stampede/inflight-map/inflight-map.md` — sibling docs
## 9. Background sweep (§spec REQ-17)
- [ ] 9.1 `src/sweep/ttl-sweeper/ttl-sweeper.ts` — `TtlSweeper` class with `start()` / `stop()`; idempotent; pages via `storage.list({ prefix, limit, cursor })`; header-only probe per key; `onSweepError` callback feeds `SWEEP_FAILED`
- [ ] 9.2 `src/sweep/ttl-sweeper/ttl-sweeper.test.ts` — fixture-driven test: seed expired + fresh entries in a `MemoryStorage`, drive the `FakeTimers`, assert expired entries are deleted and `expire` events emit
- [ ] 9.3 `src/sweep/ttl-sweeper/ttl-sweeper.md` — sibling docs
## 10. Stale-while-revalidate (§spec REQ-18)
- [ ] 10.1 `src/swr/swr-scheduler/swr-scheduler.ts` — pure scheduler: `shouldServeStale(meta, now, staleThresholdMs)`, `scheduleRefresh(refresh: () => Promise<void>, timers)`; refresh failures invoke an error sink
- [ ] 10.2 `src/swr/swr-scheduler/swr-scheduler.test.ts` — table tests over (now, expiresAt, staleThresholdMs) tuples; refresh-failure path increments error sink
- [ ] 10.3 `src/swr/swr-scheduler/swr-scheduler.md` — sibling docs
## 11. Observability (§spec REQ-19..REQ-20)
- [ ] 11.1 `src/observability/metrics/metrics.ts` — `MetricsRecorder` builds counters/histogram from `@opentelemetry/api` `metrics.getMeter("@suaveplan/cache-storage")`; no-op safe
- [ ] 11.2 `src/observability/trace/trace.ts` — `TraceRecorder.span(name, attrs, fn)` opens/closes spans via `trace.getTracer("@suaveplan/cache-storage")`; sets `SpanStatusCode.ERROR` + `error.code` on `SuaveplanError`
- [ ] 11.3 Tests for both via an injected in-memory tracer/meter (no real exporter required); assert counter increments and span attributes
- [ ] 11.4 Sibling `.md` for each
## 12. Per-namespace stats (§spec REQ-21)
- [ ] 12.1 `src/stats/namespace-stats/namespace-stats.ts` — `NamespaceStats` keyed by namespace name; `record(name, kind: "hit" | "miss" | "evict" | "rejected" | "stampedePrevented" | "swrRefresh")`; `snapshot(name)` returns counters
- [ ] 12.2 Tests + sibling `.md`
- [ ] 12.3 Wire into `StorageCache.namespace(sub)` factory method which returns a sub-namespaced view that records to the same stats collector
## 13. StorageCache integration (§spec REQ-1, REQ-22..REQ-27)
- [ ] 13.1 Modify `src/storage-cache/storage-cache.ts` to compose every collaborator (codec, policy, sweeper, in-flight map, swr scheduler, metrics, trace, namespace-stats). Defaults preserve current behaviour: no policy ⇒ `TtlOnlyPolicy`, no sweep config ⇒ sweeper never starts, no `stampede` option ⇒ in-flight map unused, no SWR ⇒ `wrap` matches parent semantics.
- [ ] 13.2 Modify `src/storage-cache/storage-cache.types.ts` — extend `StorageCacheOptions` with `eviction?`, `sweep?`, `limits?`, `timers?`, `tracer?`, `metrics?`. Every field is optional; every default preserves today's behaviour.
- [ ] 13.3 Implement `mget`, `mset`, `mdelete` with bulk-aware dispatch (duck-typed `getMultiple`/`setMultiple` on `AbstractStorage`)
- [ ] 13.4 Implement `getOrSet(key, loader, options?)` — `wrap` with stampede on by default
- [ ] 13.5 Implement `compareAndSet(key, expectedVersion, value, options?)` and `getWithVersion(key)` — read header, compare, write with `version + 1`; mismatch ⇒ `CAS_CONFLICT`
- [ ] 13.6 Override `wrap` to add stampede + SWR + OTel span; observationally equivalent to parent when both opts are absent
- [ ] 13.7 Implement `start()` / `stop()` lifecycle that drives the optional `TtlSweeper`
- [ ] 13.8 Implement `namespace(sub: string): StorageCache<T>` — returns a child view sharing the same storage but with a deeper prefix; collision detection ⇒ `NAMESPACE_CONFLICT`
- [ ] 13.9 Keep `storage-cache.ts` under the 300-line soft cap by delegating to collaborators
- [ ] 13.10 Extend `src/storage-cache/storage-cache.test.ts` to cover every new path; add the conformance harness invocation against `MemoryStorage`
- [ ] 13.11 Update `src/storage-cache/storage-cache.md` to describe every new option, the `namespace(sub)` factory, CAS semantics, SWR semantics, the stampede contract, and the OTel surface
## 14. Multi-backend conformance harness (§spec REQ-28)
- [ ] 14.1 `src/testing/conformance/conformance.ts` — `runStorageCacheConformance({ createStorage, name })`; runs every public method, every eviction policy, stampede, SWR, CAS, and asserts every one of the 12 error codes is reachable
- [ ] 14.2 `src/testing/conformance/conformance.test.ts` — invoke against `MemoryStorage` (the only universal-tier storage available here) and assert it passes; a deliberately broken fake storage causes failure
- [ ] 14.3 `src/testing/conformance/conformance.md` — sibling docs explaining how a backend package (`cache-redis`, future `cache-opfs`) wires its own factory
## 15. Root barrel + README
- [ ] 15.1 Update `src/index.ts` with explicit named re-exports for every new public surface (codecs, eviction policies, in-flight map, sweeper, SWR scheduler, observability, namespace stats, conformance harness) — no `export *`
- [ ] 15.2 Update `packages/flow/cache-storage/README.md` Modules section with every new co-located `.md`
- [ ] 15.3 Update README Quick Start with a sample that exercises eviction + SWR + CAS
## 16. Tests + gates (§13 order)
- [ ] 16.1 Spec reconciliation — walk every `### Requirement` / `#### Scenario` in the delta against implementation; every named class/function/code exists in `src/` and is exercised by a test whose name matches the `**Verified by:**` line verbatim
- [ ] 16.2 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for async / sweep tests)
- [ ] 16.3 All test imports come from `@suaveplan/testing/runner` — zero direct `vitest` / `bun:test` imports (§7)
- [ ] 16.4 `bunx turbo lint typecheck test --filter=@suaveplan/cache-storage`
- [ ] 16.5 `bunx turbo test:coverage --filter=@suaveplan/cache-storage` — 100/100/100/100
- [ ] 16.6 `bunx turbo build --filter=@suaveplan/cache-storage`
- [ ] 16.7 Banned-pattern scan, barrel-shape scan, runner-import scan, missing-`.md` scan, bare-version scan (zero output each)
- [ ] 16.8 `bunx turbo test --filter=...@suaveplan/cache-storage` — dependent sweep; `@suaveplan/cache-redis` MUST stay green (header v2 is forward-compatible; bulk-op dispatch is duck-typed)
## 17. Root-doc refresh + archival
- [ ] 17.1 `bun scripts/folder-reorg/generate-manifest.ts` — refresh `MANIFEST.md`
- [ ] 17.2 `bun scripts/generate-missing.ts` — refresh `MISSING.yml`
- [ ] 17.3 Update `STATUS.md` and `SUMMARY.md` to reflect the new surface
- [ ] 17.4 `bunx markdownlint-cli2 --fix`
- [ ] 17.5 Fold `specs/cache-storage/spec.md` into `openspec/specs/cache-storage/spec.md` (merge `## ADDED` block into existing spec; apply `## MODIFIED` blocks)
- [ ] 17.6 Move change folder → `openspec/archive/<yyyy-mm-dd>-complete-cache-storage/`