Files
skills/docs/api-stack/api/api-tenant/src/cache/cache.md
2026-05-29 08:20:56 +01:00

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 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<key, Promise> 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

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