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

204 lines
9.6 KiB
Markdown

# 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.