9.6 KiB
Design — @suaveplan/cache-storage + @suaveplan/cache-redis
Non-normative companion to proposal.md and the two spec deltas.
1. Role in the family
@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
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)
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
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 DELs 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
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
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,DataViewonly. Nonode:*/bun:*/ DOM.@suaveplan/cache-redis— server. Pure TypeScript; the only platform requirement is aRedisLikeimplementation. The package itself imports nothing fromnode:*; the integration test consumer wires inioredis.
7. Trade-offs
- Two packages vs. one mega-package with adapters subfolder. Chose two packages because the dep graphs are different (
cache-redisis server-only because the only meaningful client is server-side;cache-storageis 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
RedisLikeinterface 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 composeIdentityCodec+ rawRedisLike.setwith binary in a follow-up. clear()refuses on the global namespace. A namespace-lessclear()would have toFLUSHDBorSCAN 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.hitCountnot rewritten onStorageCache.get. BumpinghitCountwould turn everygetinto agetThenSet, halving throughput. The field is reserved for futuregetPartial-aware backends that can update the header in place. Documented.- No
wrap()override. The inheritedAbstractCache.wrapalready handles SWR + AbortSignal + force-refresh. Both new backends use it verbatim. StructuredCloneCodecdeferred. JSON + Identity cover ≥95% of cache values. CBOR ships in a follow-up once the catalog entry exists. Shipping a placeholder would violate §2b.