Files
suaveplan/openspec/archive/2026-05-16-add-cache-storage-redis-backends/design.md
2026-05-27 23:29:53 +01:00

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