# cache _(no submodules)_ ## Purpose The `cache` module wraps any `TenantResolver` with a bounded, TTL-aware in-process cache that prevents lookup stampedes. `TenantCache.wrap(resolver, options)` returns a new `TenantResolver` that holds positive entries for `ttlMs`, negative entries (no-tenant) for `negativeTtlMs`, evicts the oldest entry when the bounded `maxEntries` cap is hit, and coalesces concurrent in-flight lookups for the same key into a single `Promise` so a burst of requests hitting an unmatched key fires `lookupFn` exactly once. The cache is intentionally in-process and bounded — the design trades a distributed-cache hit-rate for predictable memory + zero deployment state. Callers wanting Redis or another shared store can keep `TenantCache.wrap` in front of a custom resolver that reads from there; the cache wrapper is a separable concern from the persistence layer. ## Features - TTL for positive entries (`ttlMs`) and negative entries (`negativeTtlMs`, defaults to `ttlMs / 4`). - Bounded by `maxEntries` (default 1000), oldest-first eviction using the natural insertion-order semantics of `Map`. - In-flight request coalescing via an internal `Map` so 100 parallel hits for the same key fire `lookupFn` once. - Pluggable `keyFn(c)` — defaults to `host + path`, falling back to `x-tenant-id` header, finally to bare path. Override per app. - Injectable `clock` — `FakeClock` from `@suaveplan/testing` works. - LRU touch-on-hit — accessing a cached entry re-inserts it so the oldest-evicted entry is the genuinely least-recently used one. ## Usage: Basic ```ts import { TenantCache, headerResolver } from "@suaveplan/api-tenant"; const inner = headerResolver("x-tenant-id", (id) => db.tenants.get(id)); const cached = TenantCache.wrap(inner, { ttlMs: 30_000 }); ``` ## Usage: Advanced Pinning a deterministic clock and overriding the key derivation for a subdomain resolver: ```ts import { TenantCache, subdomainResolver } from "@suaveplan/api-tenant"; import { FakeClock } from "@suaveplan/testing"; const clock = new FakeClock(0); const cached = TenantCache.wrap( subdomainResolver("app.com", (slug) => db.tenants.bySlug(slug)), { ttlMs: 60_000, negativeTtlMs: 5_000, maxEntries: 5_000, keyFn: (c) => `sub:${c.req.header("host") ?? ""}`, clock: () => clock.now(), } ); ``` ## API Reference - `class TenantCache` — static `.wrap(resolver, options): TenantResolver`. - `class CachedTenantResolver` — concrete class, exposes `.size` and `.clear()` for tests; not normally constructed directly. - `interface TenantCacheOptions` — `ttlMs` (required), `negativeTtlMs?`, `maxEntries?`, `keyFn?`, `clock?`. - `type CacheClock = () => number`. ## Implementation Notes `Map` preserves insertion order in JavaScript, so the "oldest" entry during eviction is the first one yielded by `.keys().next()`. A cache hit deletes-and-reinserts the entry, promoting it to the most-recent position — this is full LRU behaviour without a doubly-linked list. The negative TTL is shorter than the positive TTL by default so a mistyped tenant slug self-corrects faster than a real one becomes stale.