Files
2026-05-27 23:29:53 +01:00

120 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.