Initial Commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user