Initial Commit
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
# Spec-First Inventory — add-cache-storage-redis-backends
|
||||
|
||||
Per `openspec/AGENTS.md` §0c step 6 + AGENTS.md §2b. Implementation may not begin until this exists.
|
||||
|
||||
## cache-storage — Requirements
|
||||
|
||||
### R1. StorageCache extends AbstractCache<T>
|
||||
- [ ] R1.S1 instance is `instanceof AbstractCache` and has the full `AbstractCacheShape`
|
||||
- [ ] R1.S2 inherited wrap caches the loader result on a miss
|
||||
|
||||
### R2. 32-byte fixed-width metadata header
|
||||
- [ ] R2.S1 header.encode → header.decode round-trips every field
|
||||
- [ ] R2.S2 decodeHeader throws ENTRY_CORRUPT on bytes shorter than HEADER_BYTES
|
||||
|
||||
### R3. TTL expiration is lazy on read
|
||||
- [ ] R3.S1 get returns undefined and deletes when entry has expired
|
||||
- [ ] R3.S2 entry with no ttl never expires
|
||||
|
||||
### R4. ENTRY_CORRUPT on short blob from storage
|
||||
- [ ] R4.S1 short blob surfaces as ENTRY_CORRUPT with `error.key === originalKey`
|
||||
|
||||
### R5. Codec<T> decouples T from byte storage
|
||||
- [ ] R5.S1 JsonCodec round-trips structured value
|
||||
- [ ] R5.S2 JsonCodec.decode throws CODEC_ERROR on malformed bytes
|
||||
- [ ] R5.S3 IdentityCodec encode/decode preserves bytes
|
||||
|
||||
### R6. CACHE_STORAGE error registry
|
||||
- [ ] R6.S1 every CACHE_STORAGE_* code reachable
|
||||
|
||||
### R7. Module augmentation registers "storage" provider
|
||||
- [ ] R7.S1 hasCacheProvider("storage") true after import
|
||||
- [ ] R7.S2 createCache("storage", ...) returns a StorageCache
|
||||
|
||||
### R8. Namespacing prefixes every stored key
|
||||
- [ ] R8.S1 namespace prefixes keys before delegating to storage
|
||||
|
||||
### R9. Lifecycle events emitted when EventBusLike provided
|
||||
- [ ] R9.S1 emits hit on get after set
|
||||
- [ ] R9.S2 emits miss on get of unknown key
|
||||
|
||||
### R10. Stats counters via getStats
|
||||
- [ ] R10.S1 hitRate computes hits / (hits + misses)
|
||||
|
||||
### R11. Tier purity — universal
|
||||
- [ ] R11.S1 src contains no node:/bun: imports
|
||||
|
||||
## cache-storage — error-code reachability (R6)
|
||||
- [ ] ENTRY_CORRUPT — short blob OR decodeHeader on undersized buffer
|
||||
- [ ] CODEC_ERROR — JsonCodec.decode malformed bytes
|
||||
- [ ] BACKEND_ERROR — underlying storage throws non-cache error → wrapped
|
||||
- [ ] KEY_TOO_LONG — set with key longer than max allowed
|
||||
- [ ] VALUE_TOO_LARGE — set with value larger than max allowed
|
||||
|
||||
## cache-redis — Requirements
|
||||
|
||||
### R1. RedisCache extends AbstractCache<T>
|
||||
- [ ] R1.S1 instance is `instanceof AbstractCache` with full shape
|
||||
|
||||
### R2. RedisLike interface
|
||||
- [ ] R2.S1 FakeRedis stub satisfies the interface
|
||||
- [ ] R2.S2 cache round-trips set→get against FakeRedis
|
||||
|
||||
### R3. Native TTL via SET ... EX
|
||||
- [ ] R3.S1 positive ttl invokes SET with EX seconds
|
||||
- [ ] R3.S2 zero default ttl invokes SET without EX
|
||||
|
||||
### R4. INVALID_TTL on negative ttl
|
||||
- [ ] R4.S1 negative ttl rejected with INVALID_TTL
|
||||
|
||||
### R5. clear() refuses when no namespace
|
||||
- [ ] R5.S1 clear without namespace throws COMMAND_FAILED
|
||||
- [ ] R5.S2 clear with namespace deletes only namespaced keys
|
||||
|
||||
### R6. CACHE_REDIS error registry
|
||||
- [ ] R6.S1 every CACHE_REDIS_* code reachable
|
||||
|
||||
### R7. Codec layer shared with cache-storage
|
||||
- [ ] R7.S1 JsonCodec emits UTF-8 string into redis.set
|
||||
- [ ] R7.S2 IdentityCodec emits base64 round-trip
|
||||
|
||||
### R8. Module augmentation registers "redis" provider
|
||||
- [ ] R8.S1 hasCacheProvider("redis") true after import
|
||||
- [ ] R8.S2 createCache("redis", ...) returns a RedisCache
|
||||
|
||||
### R9. getStats client-side counters
|
||||
- [ ] R9.S1 reports hits/misses/hitRate, size 0
|
||||
|
||||
### R10. Tier — server
|
||||
- [ ] R10.S1 src contains no node:/bun: imports
|
||||
|
||||
## cache-redis — error-code reachability (R6)
|
||||
- [ ] CONNECTION_FAILED — wrap RedisLike error during set/get
|
||||
- [ ] COMMAND_FAILED — clear() without namespace
|
||||
- [ ] CODEC_ERROR — codec decode failure during get
|
||||
- [ ] KEY_TOO_LONG — set with oversized key
|
||||
- [ ] INVALID_TTL — negative ttl in set
|
||||
@@ -0,0 +1,203 @@
|
||||
# Design — @suaveplan/cache-storage + @suaveplan/cache-redis
|
||||
|
||||
Non-normative companion to `proposal.md` and the two spec deltas.
|
||||
|
||||
## 1. Role in the family
|
||||
|
||||
```text
|
||||
@suaveplan/cache AbstractCache<T>, MemoryCache, CacheFactory + registry
|
||||
├─ @suaveplan/cache-storage THIS PACKAGE — bridge to AbstractStorage
|
||||
│ └─ provider key "storage"
|
||||
└─ @suaveplan/cache-redis THIS PACKAGE — native Redis backend
|
||||
└─ provider key "redis"
|
||||
```
|
||||
|
||||
Both new packages extend `AbstractCache<T>` from `@suaveplan/cache` and register themselves through the module-augmented `CacheProviderRegistry`. The cache policy layer (`wrap`/SWR/`getMultiple`/`setMultiple`/namespacing/events) is inherited, not re-implemented.
|
||||
|
||||
## 2. `StorageCache<T>` design
|
||||
|
||||
```ts
|
||||
class StorageCache<T> extends AbstractCache<T> {
|
||||
constructor(opts: {
|
||||
storage: AbstractStorage;
|
||||
codec?: Codec<T>; // default JsonCodec<T>
|
||||
defaultTtl?: number; // ms; 0 = no expiry; default 0
|
||||
clock?: Clock; // default () => Date.now()
|
||||
events?: EventBusLike<CacheEvents>;
|
||||
namespace?: string; // prepended to every key with ':' separator
|
||||
});
|
||||
|
||||
// From AbstractCacheShape
|
||||
async get(key: string): Promise<T | undefined>;
|
||||
async set(key: string, value: T, options?: CacheSetOptions): Promise<void>;
|
||||
async delete(key: string): Promise<boolean>;
|
||||
async clear(): Promise<void>;
|
||||
async has(key: string): Promise<boolean>;
|
||||
getStats(): CacheStats;
|
||||
}
|
||||
```
|
||||
|
||||
### TTL header (32 bytes, big-endian)
|
||||
|
||||
```text
|
||||
bytes 0..7 createdAt u64 BE (ms since epoch)
|
||||
bytes 8..15 expiresAt u64 BE (ms since epoch; 0 = no expiry)
|
||||
bytes 16..19 flags u32 BE (bit 0: hasMetadata; bit 1: codecJson; rest reserved)
|
||||
bytes 20..23 hitCount u32 BE (best-effort; can saturate at 2^32-1)
|
||||
bytes 24..31 reserved 8 bytes (zero)
|
||||
```
|
||||
|
||||
On `set`: encode header + encoded value, single `storage.set(prefixedKey, headerPlusBytes)`.
|
||||
|
||||
On `get`: `storage.get(prefixedKey)` → if shorter than 32 bytes → `ENTRY_CORRUPT` → if `expiresAt !== 0 && now >= expiresAt` → lazy delete + `undefined` → else codec.decode(value slice).
|
||||
|
||||
`hitCount` is informational only and **not** rewritten on read — that would double every `get` to a write. A future change can add `getPartial`-aware backends that read just the header byte-range and skip the body when expired, but the simple full-blob read is correct and small for cache values.
|
||||
|
||||
### Key prefixing
|
||||
|
||||
`namespace` (if set) is prepended to every key with `:` separator. The key passed to `AbstractStorage` is `${namespace}:${key}` (or just `${key}` when no namespace). Empty namespace is treated as unset.
|
||||
|
||||
### Lifecycle event emission
|
||||
|
||||
When `events` is provided, the cache emits `hit`/`miss`/`set`/`delete`/`clear`/`expire` to match the existing `@suaveplan/cache`'s `MemoryCache` event topics. Listener exceptions are surfaced through the bus, not propagated up to the cache caller.
|
||||
|
||||
### Stats
|
||||
|
||||
In-memory counters (`hits`, `misses`, `evictions`, `size`). `size` is best-effort — `AbstractStorage` does not expose a fast count, so `getStats().size` is approximated by `hits + misses - misses` tracking inserts/deletes rather than by enumerating storage. (Practically: a `setCounter`/`deleteCounter` increment on each op.) Documented in the `.md`.
|
||||
|
||||
## 3. `RedisCache<T>` design
|
||||
|
||||
```ts
|
||||
class RedisCache<T> extends AbstractCache<T> {
|
||||
constructor(opts: {
|
||||
redis: RedisLike;
|
||||
codec?: Codec<T>; // default JsonCodec<T>
|
||||
defaultTtl?: number; // ms; 0 = no expiry; default 0
|
||||
clock?: Clock;
|
||||
events?: EventBusLike<CacheEvents>;
|
||||
namespace?: string;
|
||||
});
|
||||
}
|
||||
|
||||
interface RedisLike {
|
||||
set(key: string, value: string, opts?: { EX?: number; PX?: number }): Promise<unknown>;
|
||||
get(key: string): Promise<string | null>;
|
||||
del(...keys: string[]): Promise<number>;
|
||||
exists(...keys: string[]): Promise<number>;
|
||||
ttl(key: string): Promise<number>;
|
||||
expire(key: string, seconds: number): Promise<number>;
|
||||
mget?(...keys: string[]): Promise<(string | null)[]>;
|
||||
scan(cursor: string, opts?: { MATCH?: string; COUNT?: number }): Promise<RedisScanResult>;
|
||||
}
|
||||
|
||||
interface RedisScanResult {
|
||||
cursor: string;
|
||||
keys: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### Native TTL
|
||||
|
||||
`set(key, value, { ttl })` with `ttl > 0` → `redis.set(prefixed, encoded, { EX: Math.ceil(ttl / 1000) })`. `ttl === 0` or `undefined` (and `defaultTtl === 0`) → plain `redis.set(prefixed, encoded)` with no expiry.
|
||||
|
||||
`ttl < 0` rejected at the boundary with `CACHE_REDIS.INVALID_TTL`.
|
||||
|
||||
### `clear()` within a namespace
|
||||
|
||||
Namespaced `clear()` issues `SCAN 0 MATCH <ns>:* COUNT 1000` and `DEL`s each batch until cursor returns `"0"`. The global cache (no namespace) refuses `clear()` to avoid accidental `FLUSHDB`-equivalent — calling `clear()` without a namespace throws `CACHE_REDIS.COMMAND_FAILED`. Documented prominently.
|
||||
|
||||
### `size`
|
||||
|
||||
`getStats().size` is `0`. Counted hits/misses/evictions are tracked client-side.
|
||||
|
||||
### Codec sharing
|
||||
|
||||
`RedisCache` imports `JsonCodec`, `IdentityCodec`, and the `Codec<T>` type from `@suaveplan/cache-storage`. The encoded `Uint8Array` is converted to a base64 string for `set()` (Redis values are strings in the canonical RESP protocol; bytes round-trip safely through base64). `get()` reverses. UTF-8-only callers using `JsonCodec` benefit from no base64 overhead — the codec emits UTF-8 bytes directly and base64 is only applied to non-UTF-8 codecs. Detection via `flags & 0x02` flag emitted by JsonCodec.
|
||||
|
||||
Trade-off: base64 adds 33% overhead. Alternative would be `Buffer`-backed Redis clients (ioredis supports both string and Buffer values), but that forks the `RedisLike` interface. Sticking with strings keeps the contract minimal; high-volume binary callers can adopt a future `RedisCacheBinary` if real.
|
||||
|
||||
## 4. File layout — `@suaveplan/cache-storage`
|
||||
|
||||
```text
|
||||
src/
|
||||
index.ts # explicit named re-exports + side-effect import
|
||||
errors/
|
||||
errors.ts # CACHE_STORAGE_CODES + StorageCacheError
|
||||
errors.md
|
||||
errors.test.ts
|
||||
index.ts
|
||||
types/
|
||||
types.ts # HeaderMeta, StorageCacheOptions
|
||||
types.md
|
||||
index.ts
|
||||
codec/
|
||||
codec.ts # Codec<T> interface + barrel
|
||||
codec.md
|
||||
index.ts
|
||||
identity.ts # IdentityCodec
|
||||
identity.md
|
||||
identity.test.ts
|
||||
json.ts # JsonCodec<T>
|
||||
json.md
|
||||
json.test.ts
|
||||
header/
|
||||
header.ts # encode/decode + HEADER_BYTES
|
||||
header.md
|
||||
header.test.ts
|
||||
index.ts
|
||||
storage-cache/
|
||||
storage-cache.ts # StorageCache<T>
|
||||
storage-cache.md
|
||||
storage-cache.test.ts
|
||||
index.ts
|
||||
registry-augment/
|
||||
registry-augment.ts # declare module + register call
|
||||
registry-augment.md
|
||||
index.ts
|
||||
```
|
||||
|
||||
(`*.test.ts` files are excluded from coverage `.types` exclusion patterns; `index.ts` barrels are excluded.)
|
||||
|
||||
## 5. File layout — `@suaveplan/cache-redis`
|
||||
|
||||
```text
|
||||
src/
|
||||
index.ts
|
||||
errors/
|
||||
errors.ts # CACHE_REDIS_CODES + RedisCacheError
|
||||
errors.md
|
||||
errors.test.ts
|
||||
index.ts
|
||||
types/
|
||||
types.ts # RedisCacheOptions
|
||||
types.md
|
||||
index.ts
|
||||
redis-like/
|
||||
redis-like.ts # RedisLike + RedisScanResult interfaces
|
||||
redis-like.md
|
||||
redis-like.test.ts # fake stub conformance
|
||||
index.ts
|
||||
redis-cache/
|
||||
redis-cache.ts # RedisCache<T>
|
||||
redis-cache.md
|
||||
redis-cache.test.ts # via fake RedisLike
|
||||
index.ts
|
||||
registry-augment/
|
||||
registry-augment.ts
|
||||
registry-augment.md
|
||||
index.ts
|
||||
```
|
||||
|
||||
## 6. Tier purity
|
||||
|
||||
- `@suaveplan/cache-storage` — universal. `Uint8Array`, `TextEncoder`, `DataView` only. No `node:*` / `bun:*` / DOM.
|
||||
- `@suaveplan/cache-redis` — server. Pure TypeScript; the only platform requirement is a `RedisLike` implementation. The package itself imports nothing from `node:*`; the integration test consumer wires in `ioredis`.
|
||||
|
||||
## 7. Trade-offs
|
||||
|
||||
- **Two packages vs. one mega-package with adapters subfolder.** Chose two packages because the dep graphs are different (`cache-redis` is server-only because the only meaningful client is server-side; `cache-storage` is universal and bundles with browser apps). Splitting also lets the storage backend ship before any future Redis integration testing landed.
|
||||
- **JSON over base64 vs. binary-safe Redis.** Base64 keeps the `RedisLike` interface to string values, which every major Node Redis client supports without configuration. The 33% overhead is acceptable for the typical cache value (JSON payloads, profile objects). High-volume binary callers can compose `IdentityCodec` + raw `RedisLike.set` with binary in a follow-up.
|
||||
- **`clear()` refuses on the global namespace.** A namespace-less `clear()` would have to `FLUSHDB` or `SCAN MATCH *`. Both are foot-guns when the Redis instance is shared. Refusing at the boundary forces callers to be explicit, with a clear error code.
|
||||
- **`hitCount` not rewritten on `StorageCache.get`.** Bumping `hitCount` would turn every `get` into a `getThenSet`, halving throughput. The field is reserved for future `getPartial`-aware backends that can update the header in place. Documented.
|
||||
- **No `wrap()` override.** The inherited `AbstractCache.wrap` already handles SWR + AbortSignal + force-refresh. Both new backends use it verbatim.
|
||||
- **`StructuredCloneCodec` deferred.** JSON + Identity cover ≥95% of cache values. CBOR ships in a follow-up once the catalog entry exists. Shipping a placeholder would violate §2b.
|
||||
@@ -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.
|
||||
@@ -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"`
|
||||
@@ -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"`
|
||||
@@ -0,0 +1,100 @@
|
||||
# Tasks — add-cache-storage-redis-backends
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `openspec/specs/cache/spec.md` (existing `@suaveplan/cache`)
|
||||
- [x] 1.2 Read `openspec/specs/storage/spec.md` (`AbstractStorage`)
|
||||
- [x] 1.3 Read CLAUDE.md, AGENTS.md, openspec/AGENTS.md
|
||||
- [x] 1.4 Read existing `packages/flow/cache/src/` to confirm extension surface
|
||||
- [x] 1.5 Draft `proposal.md`, `design.md`, `specs/cache-storage/spec.md`, `specs/cache-redis/spec.md`, `tasks.md`
|
||||
- [x] 1.6 Spec-first inventory (`INVENTORY.md`)
|
||||
|
||||
## 2. Scaffolding — `@suaveplan/cache-storage`
|
||||
|
||||
- [ ] 2.1 Create `packages/flow/cache-storage/`
|
||||
- [ ] 2.2 `package.json` v0.0.0, `sideEffects: ["./src/registry-augment/registry-augment.ts", "./dist/registry-augment/registry-augment.js"]`, subpath exports for root + every feature
|
||||
- [ ] 2.3 Deps: `@suaveplan/cache`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/storage`, `@suaveplan/types`, `tslib`
|
||||
- [ ] 2.4 DevDeps: `@suaveplan/testing`, `@vitest/coverage-istanbul`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`
|
||||
- [ ] 2.5 `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`
|
||||
|
||||
## 3. Scaffolding — `@suaveplan/cache-redis`
|
||||
|
||||
- [ ] 3.1 Create `packages/flow/cache-redis/`
|
||||
- [ ] 3.2 `package.json` v0.0.0, `sideEffects` for the registry-augment file, `engines.node >=18`, no `browser` field
|
||||
- [ ] 3.3 Deps: `@suaveplan/cache`, `@suaveplan/cache-storage` (for codecs), `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`, `tslib`
|
||||
- [ ] 3.4 DevDeps: `@suaveplan/testing`, vitest toolchain
|
||||
- [ ] 3.5 `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`
|
||||
|
||||
## 4. Implementation — `@suaveplan/cache-storage`
|
||||
|
||||
- [ ] 4.1 `src/errors/errors.ts` — `CACHE_STORAGE_CODES` (5 codes) + `StorageCacheError`
|
||||
- [ ] 4.2 `src/types/types.ts` — `HeaderMeta`, `StorageCacheOptions`
|
||||
- [ ] 4.3 `src/header/header.ts` — `HEADER_BYTES`, `encodeHeader`, `decodeHeader` with bounds checks
|
||||
- [ ] 4.4 `src/codec/codec.ts` — `Codec<T>` interface barrel
|
||||
- [ ] 4.5 `src/codec/identity.ts` — `IdentityCodec`
|
||||
- [ ] 4.6 `src/codec/json.ts` — `JsonCodec<T>` with CODEC_ERROR mapping
|
||||
- [ ] 4.7 `src/storage-cache/storage-cache.ts` — `StorageCache<T>` extends `AbstractCache<T>`
|
||||
- [ ] 4.8 `src/registry-augment/registry-augment.ts` — `declare module` + idempotent register
|
||||
- [ ] 4.9 `src/index.ts` — explicit named re-exports + side-effect import of registry-augment
|
||||
|
||||
## 5. Implementation — `@suaveplan/cache-redis`
|
||||
|
||||
- [ ] 5.1 `src/errors/errors.ts` — `CACHE_REDIS_CODES` (5 codes) + `RedisCacheError`
|
||||
- [ ] 5.2 `src/types/types.ts` — `RedisCacheOptions`
|
||||
- [ ] 5.3 `src/redis-like/redis-like.ts` — `RedisLike` + `RedisScanResult` interfaces
|
||||
- [ ] 5.4 `src/redis-cache/redis-cache.ts` — `RedisCache<T>` extends `AbstractCache<T>`
|
||||
- [ ] 5.5 `src/registry-augment/registry-augment.ts` — `declare module` + idempotent register
|
||||
- [ ] 5.6 `src/index.ts` — explicit named re-exports + side-effect import
|
||||
|
||||
## 6. Tests — `@suaveplan/cache-storage`
|
||||
|
||||
- [ ] 6.1 `src/header/header.test.ts` — every Verified-by test
|
||||
- [ ] 6.2 `src/codec/identity.test.ts` — round-trip
|
||||
- [ ] 6.3 `src/codec/json.test.ts` — round-trip + CODEC_ERROR
|
||||
- [ ] 6.4 `src/storage-cache/storage-cache.test.ts` — every Verified-by test + lifecycle events + namespace + hitRate + tier-purity grep
|
||||
- [ ] 6.5 `src/registry-augment/registry-augment.test.ts` — both Verified-by tests
|
||||
- [ ] 6.6 `src/errors/errors.test.ts` — every CACHE_STORAGE_* code reachable
|
||||
- [ ] 6.7 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
- [ ] 6.8 All test imports via `@suaveplan/testing/runner`
|
||||
|
||||
## 7. Tests — `@suaveplan/cache-redis`
|
||||
|
||||
- [ ] 7.1 `src/redis-like/redis-like.test.ts` — FakeRedis conformance
|
||||
- [ ] 7.2 `src/redis-cache/redis-cache.test.ts` — every Verified-by test + tier-purity grep
|
||||
- [ ] 7.3 `src/registry-augment/registry-augment.test.ts` — both Verified-by tests
|
||||
- [ ] 7.4 `src/errors/errors.test.ts` — every CACHE_REDIS_* code reachable
|
||||
- [ ] 7.5 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
- [ ] 7.6 All test imports via `@suaveplan/testing/runner`
|
||||
|
||||
## 8. Documentation
|
||||
|
||||
- [ ] 8.1 Sibling `.md` for every implementation `.ts` (≥200 words, required sections)
|
||||
- [ ] 8.2 `packages/flow/cache-storage/README.md`
|
||||
- [ ] 8.3 `packages/flow/cache-redis/README.md`
|
||||
|
||||
## 9. Gates
|
||||
|
||||
- [ ] 9.1 `bunx turbo lint typecheck test --filter=@suaveplan/cache-storage`
|
||||
- [ ] 9.2 `bunx turbo lint typecheck test --filter=@suaveplan/cache-redis`
|
||||
- [ ] 9.3 `bunx turbo test:coverage --filter=@suaveplan/cache-storage` (100/100/100/100)
|
||||
- [ ] 9.4 `bunx turbo test:coverage --filter=@suaveplan/cache-redis` (100/100/100/100)
|
||||
- [ ] 9.5 `bunx turbo build --filter=@suaveplan/cache-storage`
|
||||
- [ ] 9.6 `bunx turbo build --filter=@suaveplan/cache-redis`
|
||||
- [ ] 9.7 Banned-pattern scan (zero output)
|
||||
- [ ] 9.8 Barrel-shape scan (zero output)
|
||||
- [ ] 9.9 Runner-import scan (zero output)
|
||||
- [ ] 9.10 Missing-.md scan (zero output)
|
||||
- [ ] 9.11 Bare-version scan (zero output)
|
||||
- [ ] 9.12 `bunx turbo test --filter=...@suaveplan/cache` (foundation sweep)
|
||||
|
||||
## 10. Manifest & archival
|
||||
|
||||
- [ ] 10.1 `bun scripts/folder-reorg/generate-manifest.ts` (refresh MANIFEST.md)
|
||||
- [ ] 10.2 Flip rows in STATUS.md: `cache-browser` ✅, `cache-memory` ✅, `cache-redis` ✅
|
||||
- [ ] 10.3 Update SUMMARY.md with both new packages
|
||||
- [ ] 10.4 `bunx markdownlint-cli2 --fix`
|
||||
- [ ] 10.5 Fold `specs/cache-storage/spec.md` → `openspec/specs/cache-storage/spec.md`
|
||||
- [ ] 10.6 Fold `specs/cache-redis/spec.md` → `openspec/specs/cache-redis/spec.md`
|
||||
- [ ] 10.7 Move change folder → `openspec/archive/<yyyy-mm-dd>-add-cache-storage-redis-backends/`
|
||||
Reference in New Issue
Block a user