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,119 @@
# Add @suaveplan/cache-storage and @suaveplan/cache-redis — pluggable cache backends
**Packages**: `@suaveplan/cache-storage` (universal), `@suaveplan/cache-redis` (server)
**Status**: PROPOSED
**Type**: new-packages (genesis-original; subsume pending `cache-browser` + `cache-redis` rows)
- **Design**: ./design.md
- **Spec deltas**: ./specs/cache-storage/spec.md, ./specs/cache-redis/spec.md
## Why
`@suaveplan/cache` already ships on genesis as the singular cache abstraction: `AbstractCache<T>` with `MemoryCache`, `CacheFactory`, augment-friendly `CacheProviderRegistry`, four eviction policies, lifecycle events, `wrap()` with stale-while-revalidate, namespacing, structured `CACHE_*` codes. Three sibling rows remain pending in `STATUS.md` — `cache-browser`, `cache-memory`, `cache-redis` — each upstream-only and each a copy-paste of the same adapter logic around a different backing store.
Two of those collapse cleanly:
- `cache-memory` is **already covered** by the built-in `MemoryCache` shipped from `@suaveplan/cache/memory`. No new package required — the row retires in `STATUS.md` with a one-liner pointing at the built-in.
- `cache-browser` and `cache-redis` exist as separate packages because each speaks to a different store. The right abstraction is **one cache backend per store family**, with the cache policy layer (which already exists) reused. Browser stores fan out through the existing `@suaveplan/storage-browser-*` adapters; Redis stays direct because of native TTL.
This change introduces:
1. **`@suaveplan/cache-storage`** — a `StorageCache<T>` that extends `AbstractCache<T>` and wraps any `AbstractStorage` (from `@suaveplan/storage`). A 32-byte fixed-width metadata header carries TTL + access counters because `AbstractStorage` is byte-oriented and has no expiry. Pluggable `Codec<T>` converts `T` ↔ `Uint8Array` (default `JsonCodec`, opt-in `IdentityCodec` for `T = Uint8Array`). Module-augments `CacheProviderRegistry` with `"storage"` and auto-registers via side-effect import. **One package subsumes `cache-browser` entirely**: callers wire `StorageCache` over `storage-browser-localstorage`, `storage-browser-indexeddb`, or `storage-browser-opfs` per their need; the same package also serves server-tier file/S3/CDN backed caches.
2. **`@suaveplan/cache-redis`** — a `RedisCache<T>` that extends `AbstractCache<T>` and talks to a minimal `RedisLike` interface using native `SET ... EX`, `GET`, `DEL`, `EXISTS`, `TTL`, `SCAN`. No client-side eviction (Redis's `maxmemory-policy` owns that). No TTL header bytes (Redis owns expiry). Module-augments `CacheProviderRegistry` with `"redis"` and auto-registers on import. **Replaces `cache-redis`** with a small native adapter.
Net result: one cache surface (`createCache("memory" | "storage" | "redis", config)`), three rows retire from `STATUS.md`, and every future storage adapter (`storage-s3`, `storage-cdn-*`, future `storage-r2`) becomes a cache backend for free.
## What changes
### `@suaveplan/cache-storage` (new, universal)
- ADDED: `StorageCache<T> extends AbstractCache<T>` from `@suaveplan/cache`. Constructor accepts `{ storage: AbstractStorage; codec?: Codec<T>; defaultTtl?: number; clock?: Clock; events?: EventBusLike<CacheEvents>; namespace?: string }`. Implements every `AbstractCacheShape<T>` method.
- ADDED: 32-byte fixed-width metadata header prepended to every stored value: `createdAt` u64 BE (bytes 0..7), `expiresAt` u64 BE (bytes 8..15; `0` ⇒ no expiry), `flags` u32 BE (bytes 16..19; bit 0 = hasMetadata, bit 1 = codecJson, rest reserved), `hitCount` u32 BE (bytes 20..23), reserved 8 bytes (24..31).
- ADDED: `Codec<T>` interface: `encode(value: T): Uint8Array`, `decode(bytes: Uint8Array): T`.
- ADDED: `JsonCodec<T>` — `JSON.stringify` + `TextEncoder` / `TextDecoder`. Default.
- ADDED: `IdentityCodec` — `T = Uint8Array` pass-through.
- ADDED: `CACHE_STORAGE_CODES` via `defineCodes("CACHE_STORAGE", [...])` — 5 codes: `ENTRY_CORRUPT`, `CODEC_ERROR`, `BACKEND_ERROR`, `KEY_TOO_LONG`, `VALUE_TOO_LARGE`. `StorageCacheError extends SuaveplanError<CacheStorageCode>` with `key` discriminant.
- ADDED: `header` submodule — `encodeHeader(meta) → Uint8Array`, `decodeHeader(bytes) → HeaderMeta`, `HEADER_BYTES = 32`.
- ADDED: Module augmentation — `declare module "@suaveplan/cache"` adds `storage: typeof StorageCache` to `CacheProviderRegistry`.
- ADDED: Side-effect import (`./registry-augment.ts`) calls `registerCacheProvider("storage", StorageCache)` exactly once via idempotent guard.
### `@suaveplan/cache-redis` (new, server)
- ADDED: `RedisCache<T> extends AbstractCache<T>` from `@suaveplan/cache`. Constructor accepts `{ redis: RedisLike; codec?: Codec<T>; defaultTtl?: number; clock?: Clock; events?: EventBusLike<CacheEvents>; namespace?: string }`.
- ADDED: `RedisLike` interface — minimal native surface: `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>`; optional `mget(...keys): Promise<(string | null)[]>`; `scan(cursor, opts?: { MATCH?: string; COUNT?: number }): Promise<{ cursor: string; keys: string[] }>`. Adapter accepts any client conforming to it (`ioredis`, `node-redis`, custom).
- ADDED: `CACHE_REDIS_CODES` via `defineCodes("CACHE_REDIS", [...])` — 5 codes: `CONNECTION_FAILED`, `COMMAND_FAILED`, `CODEC_ERROR`, `KEY_TOO_LONG`, `INVALID_TTL`. `RedisCacheError extends SuaveplanError<CacheRedisCode>` with `key` and `command` discriminants.
- ADDED: Codec layer reused from `@suaveplan/cache-storage` — `RedisCache` imports `Codec<T>`, `JsonCodec`, `IdentityCodec` types **and** runtime values from `@suaveplan/cache-storage/codec`. This keeps the codec surface single-sourced.
- ADDED: Native TTL — `set(key, value, { ttl })` issues `SET <ns>:<key> <encoded> EX <ceil(ttl/1000)>` when `ttl > 0`; plain `SET` otherwise.
- ADDED: Stats via `getStats()` — `hits`/`misses`/`evictions`/`hitRate` are tracked client-side; `size` returns `0` (Redis size is not cheap to enumerate; documented limitation, not a defect).
- ADDED: Module augmentation — `declare module "@suaveplan/cache"` adds `redis: typeof RedisCache`.
- ADDED: Side-effect auto-registration.
### `STATUS.md` updates (the §13b refresh after archive)
- `cache-memory` row: flip `–` → ✅ with text "Built into `@suaveplan/cache` as `MemoryCache`. No separate package."
- `cache-browser` row: flip `–` → ✅ with text "Use `@suaveplan/cache-storage` over `@suaveplan/storage-browser-{localstorage,indexeddb,opfs}`."
- `cache-redis` row: flip `–` → ✅ with text from the new `@suaveplan/cache-redis` README.
## Out of scope
- **Rebuilding `@suaveplan/cache`.** It is already shipped with a complete typed `AbstractCache<T>`. No changes to its public surface in this proposal.
- **`StructuredCloneCodec` / CBOR codec.** Deferred. JSON + Identity cover the common case; CBOR-backed structured-clone (`Date`/`Map`/`Set` faithful round-trip) ships in a follow-up alongside the cbor catalog entry. No placeholder code lands in this change.
- **Distributed cache invalidation / pub-sub busting.** Application concern, not cache-backend concern.
- **Tiered (L1+L2) cache composition.** Easy to build on top of two cache instances; not a backend feature.
- **Cross-process stampede protection.** `wrap()` is in-process (inherited from `@suaveplan/cache`). Cross-process coalescing requires a Redis lock and is application-layer.
## Source mapping
- `../suaveplan/packages/browser/cache-browser/src/` — establishes the cache-as-storage-bridge pattern. `StorageCache` is the upstreamable form: instead of one package per browser store, one package over any `AbstractStorage`.
- `../suaveplan/packages/server/cache-redis/src/` — `RedisLike` shape and native command usage. The new package mirrors the public API while keeping the `AbstractCache<T>` policy layer from the genesis cache.
- `@suaveplan/cache` (genesis) — extension target. `AbstractCache<T>`, `CacheProviderRegistry`, `CacheEvents`, `CacheStats`, `CACHE_CODES` all consumed; nothing modified.
- `@suaveplan/storage` (genesis) — `AbstractStorage` consumed; `MemoryStorage` from `@suaveplan/storage/memory` consumed in tests.
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `StorageCache` extends the genesis `AbstractCache<T>` (not a fresh class) | Reuses every helper (`getMultiple`, `setMultiple`, `wrap` w/ SWR + AbortSignal) and integrates with the existing `CacheFactory`. Any other choice forks the cache surface. |
| 32-byte fixed-width metadata header (not protobuf/cbor) | `AbstractStorage` is byte-oriented and has no metadata side-channel. Fixed width keeps reads single-range and avoids a second round-trip. Negligible (32 B) overhead per value. |
| `RedisCache` reuses `@suaveplan/cache-storage`'s `Codec<T>` | Single-sourced codec contract. `JsonCodec` is the same class object whether backend is storage or redis — tests prove the round-trip parity. Forking the codec across backends would diverge the JSON dialect over time. |
| `cache-redis` `getStats().size` returns `0` (documented) | Redis `DBSIZE` covers the whole DB, not a namespace; `SCAN`-then-count is O(N) and not appropriate for a `getStats()` call. Documented limitation, not a defect; reachers needing exact size run `SCAN` directly. |
| Side-effect auto-registration with idempotent guard | Matches the existing `@suaveplan/cache/factory/registry/index.js` pattern. The guard ensures `import "@suaveplan/cache-storage"` twice does not warn or throw. |
| `INVALID_TTL` lives in `CACHE_REDIS_CODES` (and not `cache-storage`) | Redis's `EX` argument must be a positive integer; non-positive TTLs are rejected at the boundary. `cache-storage` stores 64-bit `expiresAt` and tolerates `0` (no expiry), so the same code does not apply. |
## Verification approach
### `@suaveplan/cache-storage`
- `src/header/header.test.ts` — encode/decode round-trip for every field (boundary values, max-int hitCount, zero expiresAt, set/unset flags). Rejects malformed input.
- `src/codec/json.test.ts` — round-trip structured values; `CODEC_ERROR` on malformed bytes; UTF-8 edge cases (multi-byte, surrogate pairs).
- `src/codec/identity.test.ts` — `Uint8Array` pass-through preserves byte identity.
- `src/storage-cache/storage-cache.test.ts` — runs against `MemoryStorage` from `@suaveplan/storage/memory`; covers `get`/`set`/`delete`/`clear`/`has`/`getMultiple`/`setMultiple`/`wrap`, TTL expiration (lazy on read), `ENTRY_CORRUPT` on short blob, codec swap, namespace prefixing, AbortSignal propagation through `wrap`, factory registration end-to-end.
- `src/error-codes/error-codes.test.ts` — every `CACHE_STORAGE_*` code reachable from a real call site.
- Tier-purity grep returns empty (no `node:` / `bun:` imports).
### `@suaveplan/cache-redis`
- `src/redis-like/redis-like.test.ts` — minimal interface conformance via a fake `RedisLike` stub.
- `src/redis-cache/redis-cache.test.ts` — covers every `AbstractCacheShape` method using a fake `RedisLike` stub. Native `EX` for TTL, `SCAN`-based `clear()` within namespace, `ttl()` reconstruction of `expiresAt`, `INVALID_TTL` rejection.
- `src/error-codes/error-codes.test.ts` — every `CACHE_REDIS_*` code reachable.
Integration testing against a real Redis (via `@suaveplan/testing-integration-redis`) is a follow-up. The fake `RedisLike` covers protocol conformance for this change.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/cache` to enumerate dependents of the existing cache package.
- No dependents currently exist for `@suaveplan/cache-storage` or `@suaveplan/cache-redis` — they are new.
- `@suaveplan/cache` itself is **not modified** (no augments needed; `CacheProviderRegistry` is already an open module-augmentation interface). `bunx turbo test --filter=...@suaveplan/cache` should remain green; the new sibling packages declare it as a workspace dep so dependent-sweep tests run them.
- Forward-declared dependents: future consumers of caching that prefer a Redis or browser-storage backend.
### Affected upstreams
- `@suaveplan/cache` — consumed via `workspace:^`. Module-augmented but not modified. [x] no action needed (additive module augmentation only).
- `@suaveplan/storage` — consumed by `cache-storage` for `AbstractStorage`. No changes. [x] no action needed.
- `@suaveplan/error` — `defineCodes` consumed by both new packages. No changes. [x] no action needed.
- `@suaveplan/types` — `Clock`. No changes. [x] no action needed.
- `@suaveplan/events` — `EventBusLike` consumed for lifecycle events. No changes. [x] no action needed.