3.2 KiB
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 tottlMs / 4). - Bounded by
maxEntries(default 1000), oldest-first eviction using the natural insertion-order semantics ofMap. - In-flight request coalescing via an internal
Map<key, Promise>so 100 parallel hits for the same key firelookupFnonce. - Pluggable
keyFn(c)— defaults tohost + path, falling back tox-tenant-idheader, finally to bare path. Override per app. - Injectable
clock—FakeClockfrom@suaveplan/testingworks. - 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
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:
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.sizeand.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.