@btravstack/cache
The cache port for
@btravstack/core: oneCachean application depends on, an in-memory and a Redis adapter behind it, and an opt-in composition that spans, counts and logs every call.
📖 Documentation · API Reference
pnpm add @btravstack/cache @btravstack/core @btravstack/config @btravstack/di unthrown
Four peer dependencies, plus redis — optional, and needed only if you
compose the Redis adapter. Instrumentation needs no peer of its own: the
Logger, Tracer and Meter it depends on are
the kernel's ports,
and @btravstack/observability is what an application composes to satisfy
them. Node >=22.
A worked example
A read-through, which is one call — the degradation policy lives in the port:
import { Cache } from "@btravstack/cache";
import { Port, Provider } from "@btravstack/di";
import type { AsyncResult } from "unthrown";
class Customers extends Port("ReadmeCustomers")<{
readonly find: (
tenantId: string,
id: string,
) => AsyncResult<CustomerView, CustomerNotFound>;
}> {}
const customers = Provider(Customers)({
inject: { find: FindCustomer, cache: Cache },
sync: ({ find, cache }) => ({
find: (tenantId, id) =>
cache.getOrSet<CustomerView, CustomerNotFound>(
// The key carries the tenant, because the port does not: `Cache` takes
// plain strings, and composing them is the application's job.
`customers:${tenantId}:${id}`,
() => find.execute(TenantId(tenantId), id).map(view),
{ ttlMs: 60_000 },
),
}),
});
And the composition — the adapter is the only decision at it:
import { cache } from "@btravstack/cache";
import { redisCache } from "@btravstack/cache/redis";
export const CustomersApp = Module("CustomersApp")({
imports: [
CustomerApplicationModule,
CustomerPersistenceModule,
// Every call is reported through `Observers`. Drop the two modules
// below and this composition is unchanged — it just reports to nobody.
cache({ adapter: redisCache() }),
observability(),
otel(),
],
provides: [customers],
exports: [Customers, Logger],
});
Options
| Option | Where | What it is |
|---|---|---|
adapter | cache({ adapter }) | the adapter module providing CacheBackend — required |
clock | memoryCache | what a ttl is measured against (default: the kernel's systemClock) |
REDIS_URL | environment, read by redisCache() | the connection URL — required, validated at graph build |
ttlMs | cache.set / cache.getOrSet | per-entry expiry, rounded to whole milliseconds (default: none — the entry stays until it is deleted or evicted; a value that does not round to a whole millisecond of at least 1 means the entry is not stored, so 0.6 is stored as 1 and 0.4 is not stored at all) |
The full table — defaults, semantics and the reasoning — lives on the reference page, which is this list's one detailed home.
What it decides, and what it does not
It decides that a miss is Ok(undefined) and not an error, that a failing
backend is a modeled CacheUnavailable rather than a swallowed one, that
values are unknown encoded by the adapter, and that keys are plain strings
the caller composes.
It decides one thing for you: that every call is reported through
Observers. Nothing is required to receive those reports — a graph composing
no observability compiles, starts and pays an inert call — so the decision
costs you a port list only if you want the spans and the instruments, and then
you compose observability() and otel() and change nothing here.
It does not decide whether an unreachable cache degrades a get — recover
CacheUnavailable where you call it — nor what your keys look like, nor when
to invalidate them. The one place it does decide is getOrSet, where an
unreachable cache runs your loader and a failed write is not your error. There
is no stampede protection, no eviction on the memory adapter, and no namespace
parameter — each reason is on
the reference page,
and the same reasoning is in AGENTS.md for contributors.
License
MIT