Initial Commit
This commit is contained in:
@@ -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"`
|
||||
Reference in New Issue
Block a user