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,158 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: cache-redis (cache-redis)
**Package**: `@suaveplan/cache-redis`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: RedisCache extends AbstractCache<T>
The package SHALL provide `RedisCache<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 constructor SHALL accept `{ redis: RedisLike; codec?: Codec<T>; defaultTtl?: number; events?: EventBusLike; namespace?: string }` with `codec` defaulting to `JsonCodec<T>` (from `@suaveplan/cache-storage`) and `defaultTtl` defaulting to `0`.
#### Scenario: instance is an AbstractCache and an AbstractCacheShape
- **WHEN** a caller constructs `new RedisCache({ redis: fakeRedis })`
- **THEN** the instance is `instanceof AbstractCache` AND every `AbstractCacheShape<T>` method exists on it
**Verified by:** `src/redis-cache/redis-cache.test.ts::"instance is an AbstractCache and exposes the AbstractCacheShape surface"`
---
### Requirement: RedisLike interface
The package SHALL declare a `RedisLike` interface with `set(key, value, opts?: { EX?: number; PX?: number }): Promise<unknown>`, `get(key): Promise<string | null>`, `del(...keys): Promise<number>`, `exists(...keys): Promise<number>`, `ttl(key): Promise<number>`, `expire(key, seconds): Promise<number>`, `scan(cursor, opts?: { MATCH?: string; COUNT?: number }): Promise<RedisScanResult>`, and optional `mget(...keys): Promise<(string | null)[]>`. `RedisScanResult` SHALL have `{ cursor: string; keys: string[] }`. Any client that conforms to the interface SHALL be a valid argument to `RedisCache`.
#### Scenario: a fake RedisLike stub satisfies the interface
- **WHEN** the test suite constructs an in-memory `FakeRedis` implementing every required method AND passes it to a `RedisCache`
- **THEN** the cache round-trips `set`/`get`/`delete`/`has`/`getMultiple`/`setMultiple`/`wrap` correctly
**Verified by:** `src/redis-like/redis-like.test.ts::"FakeRedis stub satisfies the RedisLike interface"` AND `src/redis-cache/redis-cache.test.ts::"round-trips set then get against a FakeRedis stub"`
---
### Requirement: Native TTL via SET ... EX
`RedisCache.set(key, value, opts)` SHALL invoke `redis.set(prefixed, encoded, { EX: Math.ceil(ttl / 1000) })` when `ttl > 0`. When `ttl === 0` or `ttl === undefined` and `defaultTtl === 0`, the call SHALL use a plain `redis.set(prefixed, encoded)`. When the computed `EX` would be `0`, the package SHALL upgrade to `EX: 1` to avoid Redis treating it as "no expiry" via its rounding behaviour, NOT silently drop the TTL.
#### Scenario: positive ttl invokes SET with EX seconds
- **WHEN** a caller calls `cache.set("k", "v", { ttl: 30_000 })`
- **THEN** the `FakeRedis` spy records a `set("ns:k", encoded, { EX: 30 })` invocation
#### Scenario: ttl === 0 invokes SET with no EX
- **WHEN** a caller calls `cache.set("k", "v")` AND `defaultTtl === 0`
- **THEN** the `FakeRedis` spy records a `set("ns:k", encoded)` invocation with no `EX` option
**Verified by:** `src/redis-cache/redis-cache.test.ts::"set with positive ttl issues SET with EX seconds"` AND `src/redis-cache/redis-cache.test.ts::"set with zero default ttl issues SET without EX"`
---
### Requirement: INVALID_TTL on negative ttl
`RedisCache.set` SHALL reject `ttl < 0` with `RedisCacheError` whose `code === "CACHE_REDIS.INVALID_TTL"`. The error's `key` discriminant SHALL be the unprefixed key.
#### Scenario: negative ttl is rejected with INVALID_TTL
- **WHEN** a caller calls `cache.set("k", "v", { ttl: -1 })`
- **THEN** the call rejects with `RedisCacheError` AND `error.code === "CACHE_REDIS.INVALID_TTL"` AND `error.key === "k"`
**Verified by:** `src/redis-cache/redis-cache.test.ts::"rejects negative ttl with INVALID_TTL"`
---
### Requirement: clear() refuses when no namespace is configured
`RedisCache.clear()` SHALL throw `RedisCacheError` with `code === "CACHE_REDIS.COMMAND_FAILED"` when no `namespace` was provided. When `namespace` is provided, `clear()` SHALL iterate `SCAN 0 MATCH <namespace>:* COUNT 1000` until the cursor returns `"0"`, deleting each batch of matching keys with `DEL`. The total deleted count SHALL be reflected in `getStats().evictions`.
#### Scenario: clear without namespace throws COMMAND_FAILED
- **WHEN** a caller calls `cache.clear()` on a `RedisCache` constructed without `namespace`
- **THEN** the call throws `RedisCacheError` with `code === "CACHE_REDIS.COMMAND_FAILED"`
#### Scenario: clear with namespace deletes only namespaced keys
- **WHEN** a `FakeRedis` contains keys `ns:a`, `ns:b`, `other:c` AND a namespaced cache calls `clear()`
- **THEN** keys `ns:a` and `ns:b` are deleted AND `other:c` remains AND `getStats().evictions` is incremented by `2`
**Verified by:** `src/redis-cache/redis-cache.test.ts::"clear() without namespace throws COMMAND_FAILED"` AND `src/redis-cache/redis-cache.test.ts::"clear() with namespace deletes only namespaced keys"`
---
### Requirement: CACHE_REDIS error registry
The package SHALL declare `CACHE_REDIS_CODES` via `defineCodes("CACHE_REDIS", [...])` with exactly these 5 codes: `CONNECTION_FAILED`, `COMMAND_FAILED`, `CODEC_ERROR`, `KEY_TOO_LONG`, `INVALID_TTL`. The package SHALL declare `RedisCacheError extends SuaveplanError<CacheRedisCode>` with optional `key` and `command` discriminants. Every code SHALL be reachable from at least one test in this package.
#### Scenario: every declared CACHE_REDIS_* code is reachable
- **WHEN** the test suite runs
- **THEN** for every entry in `CACHE_REDIS_CODES`, at least one assertion exercises a real call site that produces that code
**Verified by:** `src/errors/errors.test.ts::"every CACHE_REDIS code is reachable from a real call site"`
---
### Requirement: Codec layer is shared with @suaveplan/cache-storage
`RedisCache` SHALL import `Codec<T>`, `JsonCodec`, and `IdentityCodec` from `@suaveplan/cache-storage`. The encoded `Uint8Array` SHALL be converted to a UTF-8 string before passing to `redis.set` when the codec emits UTF-8-safe bytes (`JsonCodec`); otherwise base64 SHALL be used (`IdentityCodec`). On `get`, the inverse conversion SHALL be applied before invoking `codec.decode`.
#### Scenario: JsonCodec emits UTF-8 string into redis.set
- **WHEN** a caller calls `cache.set("k", { hello: "world" })`
- **THEN** the `FakeRedis` `set` value is a parseable JSON string `"{\"hello\":\"world\"}"`
#### Scenario: IdentityCodec emits base64 string into redis.set
- **WHEN** a `RedisCache<Uint8Array>` constructed with `codec: new IdentityCodec()` calls `set("k", new Uint8Array([1,2,3]))`
- **THEN** the `FakeRedis` `set` value is the base64 string `"AQID"` AND `get("k")` returns the original `Uint8Array`
**Verified by:** `src/redis-cache/redis-cache.test.ts::"JsonCodec encodes value as a UTF-8 string"` AND `src/redis-cache/redis-cache.test.ts::"IdentityCodec encodes value as a base64 string round-trip"`
---
### Requirement: Module augmentation registers "redis" provider
The package SHALL `declare module "@suaveplan/cache"` and augment `CacheProviderRegistry` with `redis: typeof RedisCache`. The package SHALL side-effect-import a `registry-augment` module that calls `registerCacheProvider("redis", RedisCache)` exactly once via an idempotent guard. After importing `@suaveplan/cache-redis`, `hasCacheProvider("redis")` SHALL return `true` AND `createCache("redis", { redis })` SHALL return a `RedisCache`.
#### Scenario: side-effect import registers the "redis" provider
- **WHEN** the consumer imports `@suaveplan/cache-redis` AND calls `hasCacheProvider("redis")`
- **THEN** the result is `true`
#### Scenario: createCache("redis", config) returns a RedisCache instance
- **WHEN** a caller calls `createCache("redis", { redis: fakeRedis })`
- **THEN** the result is `instanceof RedisCache` AND `instanceof AbstractCache`
**Verified by:** `src/registry-augment/registry-augment.test.ts::"hasCacheProvider returns true after import"` AND `src/registry-augment/registry-augment.test.ts::"createCache(\"redis\", ...) returns a RedisCache instance"`
---
### Requirement: getStats client-side counters
`RedisCache.getStats()` SHALL return `CacheStats` with `hits`, `misses`, `evictions` tracked client-side and `hitRate = hits / (hits + misses)`. `size` SHALL be `0` (documented limitation — Redis size enumeration is O(N) and not appropriate for `getStats`).
#### Scenario: getStats reports tracked counters and size 0
- **WHEN** a caller records 4 hits and 1 miss against the cache
- **THEN** `getStats()` returns `{ hits: 4, misses: 1, hitRate: 0.8, evictions: 0, size: 0 }`
**Verified by:** `src/redis-cache/redis-cache.test.ts::"getStats reports client-side counters and size 0"`
---
### Requirement: Tier — server
The package SHALL declare `"engines": { "node": ">=18" }` in `package.json` and SHALL NOT include a `"browser"` field. The runtime source SHALL NOT import from `node:*` directly (the `RedisLike` interface is platform-neutral); however the package is published with a server-tier marker because typical clients (`ioredis`, `node-redis`) are server-only.
#### Scenario: tier-purity grep finds no node: imports
- **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/flow/cache-redis/src/` is run
- **THEN** the output is empty
**Verified by:** `src/redis-cache/redis-cache.test.ts::"src tree contains no node: or bun: imports"`

View File

@@ -0,0 +1,186 @@
<!-- 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: 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 constructor SHALL accept `{ storage: AbstractStorage; codec?: Codec<T>; defaultTtl?: number; clock?: Clock; events?: EventBusLike<CacheEvents>; namespace?: string }` with `codec` defaulting to `JsonCodec<T>` and `defaultTtl` defaulting to `0` (no expiry).
#### Scenario: instance is an AbstractCache and an AbstractCacheShape
- **WHEN** a caller constructs `new StorageCache({ storage: new MemoryStorage() })`
- **THEN** the instance is `instanceof AbstractCache` AND every `AbstractCacheShape<T>` method exists on it
#### Scenario: inherited wrap delegates to set on miss
- **WHEN** a caller invokes `cache.wrap("k", async () => 42)` on a miss
- **THEN** the loader is called exactly once AND a subsequent `cache.get("k")` returns `42`
**Verified by:** `src/storage-cache/storage-cache.test.ts::"instance is an AbstractCache and exposes the AbstractCacheShape surface"` AND `src/storage-cache/storage-cache.test.ts::"inherited wrap caches the loader result on a miss"`
---
### 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..31 reserved (zero). The package SHALL export `HEADER_BYTES = 32`, `encodeHeader(meta) → Uint8Array`, and `decodeHeader(bytes) → HeaderMeta`.
#### Scenario: header round-trips every field
- **WHEN** a caller calls `decodeHeader(encodeHeader({ createdAt: 1, expiresAt: 2, flags: 3, hitCount: 4 }))`
- **THEN** the result is `{ createdAt: 1, expiresAt: 2, flags: 3, hitCount: 4 }`
#### 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.encode then header.decode round-trips every field"` AND `src/header/header.test.ts::"decodeHeader throws ENTRY_CORRUPT on bytes shorter than HEADER_BYTES"`
---
### Requirement: TTL expiration is lazy on read
`StorageCache.get` SHALL parse the stored header and compare `expiresAt` against `clock()`. When `expiresAt !== 0 && clock() >= expiresAt`, `get` SHALL invoke `storage.delete(prefixedKey)` AND return `undefined`. When `expiresAt === 0`, the entry SHALL never expire. `set(key, value, { ttl })` with `ttl > 0` SHALL compute `expiresAt = clock() + ttl`. `set` with `ttl === undefined` SHALL use `defaultTtl`.
#### Scenario: get returns undefined and deletes when entry is expired
- **WHEN** a caller `set`s a key with `ttl: 10` (ms), advances an injected clock past expiry, then `get`s the key
- **THEN** the call returns `undefined` AND `storage.exists(prefixedKey)` returns `false`
#### Scenario: entry with expiresAt 0 never expires
- **WHEN** a caller `set`s a key with no `ttl` and `defaultTtl: 0`, then advances the clock by 10 days, then `get`s the key
- **THEN** the call returns the stored value
**Verified by:** `src/storage-cache/storage-cache.test.ts::"get returns undefined and deletes when entry has expired"` AND `src/storage-cache/storage-cache.test.ts::"entry with no ttl never expires"`
---
### Requirement: ENTRY_CORRUPT on short blob from underlying storage
When `storage.get(prefixedKey)` returns a `Uint8Array` shorter than `HEADER_BYTES`, `StorageCache.get` SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.ENTRY_CORRUPT"` and `key` set to the original (unprefixed) key.
#### Scenario: short blob surfaces as ENTRY_CORRUPT
- **WHEN** a fake `AbstractStorage` returns `new Uint8Array(16)` for `get(prefixedKey)` AND the caller calls `cache.get(originalKey)`
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.ENTRY_CORRUPT"` AND `error.key === originalKey`
**Verified by:** `src/storage-cache/storage-cache.test.ts::"get throws ENTRY_CORRUPT when underlying storage returns fewer than HEADER_BYTES bytes"`
---
### Requirement: Codec<T> decouples T from byte storage
The package SHALL declare `Codec<T>` with `encode(value: T): Uint8Array` and `decode(bytes: Uint8Array): T`. The package SHALL export two codecs: `IdentityCodec` (constrains `T = Uint8Array`; pass-through identity) and `JsonCodec<T>` (UTF-8 JSON via `TextEncoder`/`TextDecoder`). Decode failure SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_ERROR"`. `JsonCodec` SHALL be the default codec used by `StorageCache` when none is provided.
#### Scenario: JsonCodec round-trips a structured value
- **WHEN** a caller encodes `{ user: "alice", roles: ["admin"] }` AND then decodes the result
- **THEN** the decoded value deep-equals the input
#### Scenario: JsonCodec.decode throws CODEC_ERROR on malformed bytes
- **WHEN** a caller calls `new JsonCodec().decode(new Uint8Array([0xff, 0xfe, 0xfd]))`
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_ERROR"`
#### Scenario: IdentityCodec passes Uint8Array through unchanged
- **WHEN** a caller calls `new IdentityCodec().decode(new IdentityCodec().encode(bytes))`
- **THEN** the result is byte-identical to `bytes`
**Verified by:** `src/codec/json.test.ts::"round-trips a structured value through encode then decode"` AND `src/codec/json.test.ts::"decode throws CODEC_ERROR on malformed bytes"` AND `src/codec/identity.test.ts::"encode then decode preserves the byte sequence"`
---
### Requirement: CACHE_STORAGE error registry
The package SHALL declare `CACHE_STORAGE_CODES` via `defineCodes("CACHE_STORAGE", [...])` with exactly these 5 codes: `ENTRY_CORRUPT`, `CODEC_ERROR`, `BACKEND_ERROR`, `KEY_TOO_LONG`, `VALUE_TOO_LARGE`. The package SHALL declare `StorageCacheError extends SuaveplanError<CacheStorageCode>` with optional `key` discriminant. Every code SHALL be reachable from at least one test in this package.
#### Scenario: every declared 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: Module augmentation registers "storage" provider
The package SHALL `declare module "@suaveplan/cache"` and augment `CacheProviderRegistry` with `storage: typeof StorageCache`. The package SHALL side-effect-import a `registry-augment` module that calls `registerCacheProvider("storage", StorageCache)` exactly once via an idempotent guard. After importing `@suaveplan/cache-storage`, `hasCacheProvider("storage")` SHALL return `true` AND `createCache("storage", { storage })` SHALL return a `StorageCache`.
#### Scenario: side-effect import registers the "storage" provider
- **WHEN** the consumer imports `@suaveplan/cache-storage` AND calls `hasCacheProvider("storage")`
- **THEN** the result is `true`
#### Scenario: createCache("storage", config) returns a StorageCache instance
- **WHEN** a caller calls `createCache("storage", { storage: new MemoryStorage() })`
- **THEN** the result is `instanceof StorageCache` AND `instanceof AbstractCache`
**Verified by:** `src/registry-augment/registry-augment.test.ts::"hasCacheProvider returns true after import"` AND `src/registry-augment/registry-augment.test.ts::"createCache(\"storage\", ...) returns a StorageCache instance"`
---
### Requirement: Namespacing prefixes every stored key
When `namespace` is provided in the constructor and is non-empty, `StorageCache` SHALL prefix every key passed to the underlying `AbstractStorage` with `${namespace}:`. The unprefixed key SHALL be carried on any thrown `StorageCacheError` via the `key` discriminant.
#### Scenario: namespaced set writes to the prefixed key
- **WHEN** a `StorageCache` constructed with `namespace: "users"` calls `set("alice", value)`
- **THEN** the underlying `storage.set` is invoked with key `"users:alice"`
**Verified by:** `src/storage-cache/storage-cache.test.ts::"namespace prefixes keys before delegating to the underlying storage"`
---
### Requirement: Lifecycle events emitted when EventBusLike provided
When `events` is provided in the constructor, `StorageCache` SHALL emit `hit`, `miss`, `set`, `delete`, `clear`, and `expire` topics with payloads matching the `CacheEvents` map exported from `@suaveplan/cache`. When `events` is not provided, no event work SHALL be performed (zero-cost when unused).
#### Scenario: emits hit on get after set
- **WHEN** a caller subscribes to `hit`, `set`s a key, then `get`s it
- **THEN** the `hit` listener is invoked exactly once with `{ key }`
#### Scenario: emits miss on get of an unknown key
- **WHEN** a caller subscribes to `miss` and calls `get("missing")`
- **THEN** the `miss` listener is invoked exactly once with `{ key: "missing" }`
**Verified by:** `src/storage-cache/storage-cache.test.ts::"emits hit event when get returns a stored value"` AND `src/storage-cache/storage-cache.test.ts::"emits miss event when get returns undefined"`
---
### Requirement: Stats counters via getStats
`StorageCache.getStats()` SHALL return a `CacheStats` with `hits`, `misses`, `evictions`, and `hitRate` reflecting client-side counters; `size` SHALL be a best-effort count maintained via `set`/`delete`. Counters SHALL be monotonically non-decreasing across the cache's lifetime; `clear()` SHALL reset `size` to `0` but SHALL NOT reset `hits`/`misses`/`evictions`.
#### Scenario: hitRate reflects hits over hits-plus-misses
- **WHEN** a caller records 7 hits and 3 misses
- **THEN** `getStats().hitRate === 0.7`
**Verified by:** `src/storage-cache/storage-cache.test.ts::"hitRate computes hits over hits-plus-misses"`
---
### Requirement: Tier purity — universal
The package SHALL NOT import from `node:*`, `bun:*`, or any DOM-only API. The package SHALL run unmodified under Node 18+, Bun, Deno, modern browsers, and Cloudflare Workers.
#### Scenario: tier-purity grep finds no forbidden imports
- **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"`