This package provides a class, Cache, that implements caching using a model inspired by HTTP's. The HTTP model is already incredibly powerful, and this class generalizes and extends it further in some ways.
Even though this package borrows ideas from HTTP, it can be used to cache any kind of data from any source, not just HTTP responses. Its implementation assumes no HTTP particulars (like specific header formats).
Still, you must understand a number of concepts from HTTP's caching model in order to use this package effectively. Those are explained in "Caching Model". Please read that, or many of the names and APIs in this package won't make sense.
The Cache class can only function with a "backing store" that actually holds the cache's entries. There is a common Store interface (see the types file) that all stores must implement. We currently have three backing stores, one that holds items in memory; one that stores items in Postgres; and one that uses Sqlite.
Note that not all backing stores will be able to store all kinds of data, although it's recommended that general-purpose stores be able to store any data that's JSON-serializable. Store implementations can communicate the type of data they support by adding a constraint on their first type parameter, e.g., a store with the signature class MyStore<Spec extends CacheSpec<string, string[]>, ...> is indicating that it can only store string arrays. Trying to use a store with a Cache instance parameterized for entries of different types will yield a type error.
Stores never see resource-type names or classification (below); those are entirely a Cache concern, so any Store implementation works unchanged.
Every Cache is built over a resource-type registry (ResourceTypes): a record naming each kind of resource the cache can hold, pairing a runtime classifier for that kind's id sub-space (matches) with its content type (a type-level phantom). Cache takes a single options bag; store, name and resourceTypes are all required:
const storiesResourceTypes = {
story: resourceType<Story>()({ matches: idStartsWith("story:") }),
collection: resourceType<Story[]>()({ matches: idStartsWith("collection:") }),
} satisfies ResourceTypes;
const cache = new Cache({
store: store,
name: "stories", // names this cache instance in every diagnostics message
resourceTypes: storiesResourceTypes,
});The registry's matches guards must partition the id space: for every id the cache will ever see (requests, primary results, supplemental results, deletes), exactly one entry must match. cache.classify(id) evaluates every guard: zero matches throws UnclassifiableIdError, two or more throws AmbiguousResourceTypeError — fail loud over first-match-wins, so a registry overlap is caught the first time it occurs. Classification runs on every get/getMany request id, every stored entry id (primary and supplemental, all validated before anything persists — a producer minting a malformed id can't write a permanently unreadable row), and every delete id. Guards should be cheap (prefix checks preferred); ids must carry their type in-band: an id must be classifiable by inspection.
A cache with exactly one resource type doesn't have to name it — singleTypeCacheOptions builds the whole options bag, naming the type after the cache (override with resourceTypeName):
const cache = new Cache(
singleTypeCacheOptions<TicketSchema>()({ store, name: "zendesk_ticket_schemas" }),
);By default its sole type accepts every id, so classification never fails and the id space is string. Spread the result to set anything else CacheOptions accepts. The one thing it gives up is precision of the resource-type name: classify() returns string rather than a literal, which is the point of not having to pick one.
To narrow the id space below string — template-literal or branded ids flowing through to every request, producer, and entry type — pass a real guard as validateId:
const cache = new Cache(
singleTypeCacheOptions<TicketSchema>()({
store,
name: "zendesk_ticket_schemas",
validateId: idStartsWith("zendesk-ticket-schema:"),
}),
);validateId is required as soon as the id space is narrower than string, so a narrowed id type always has a runtime check behind it — naming a narrow Id by explicit type argument without supplying the guard is a compile error. The store needs no type argument to earn the narrowing: Id comes from the guard alone, and the store is only checked for coverage (see below).
Writing the one-entry registry out by hand stays equivalent, and is the better choice when you also want the literal resource-type name:
const cache = new Cache({
store,
name: "zendesk_ticket_schemas",
resourceTypes: {
ticket_schema: resourceType<TicketSchema>()({
matches: idStartsWith("zendesk-ticket-schema:"),
}),
},
});Store is invariant in its Spec — Spec appears both in store()/delete()'s parameters and in get()/getMany()'s return types — so a Store<Wide> is not assignable to a Store<Narrow>, even though for any id the narrow cache actually asks for, the wide store returns exactly the same thing. Since most stores are general-purpose, requiring an exact match would mean re-instantiating them with artificially narrowed type arguments.
So the cache doesn't require a match: it captures the store's own spec and only checks that it covers the registry. A general-purpose store just works, with no type arguments and no narrowing:
const store = new MemoryStore<SpecOf<typeof everyResourceTypeWeHave>>();
const cache = new Cache({ store, name: "stories", resourceTypes: storiesResourceTypes });A store that does not cover the registry is still rejected, so this convenience can't silently accept a store that would fail at runtime on an id it doesn't handle. The same rule applies to singleTypeCacheOptions, so one general-purpose store can back several single-type caches:
const store = new MemoryStore<SpecOf<typeof everyResourceTypeWeHave>>();
const tickets = new Cache(
singleTypeCacheOptions<TicketSchema>()({
store,
name: "zendesk_ticket_schemas",
validateId: idStartsWith("zendesk-ticket-schema:"),
}),
);Nothing about the wider store leaks into the narrow cache: tickets still accepts only zendesk-ticket-schema: ids and still returns TicketSchema, not the store's content union.
The cache's CacheSpec union — which pairs each id type with the corresponding content type — is derived from the registry via SpecOf, so the two can never drift:
type StoriesCacheSpec = SpecOf<typeof storiesResourceTypes>;
// = CacheSpec<`story:${string}`, Story> | CacheSpec<`collection:${string}`, Story[]>
// Per-id content narrowing on read:
const storyRes = await cache.get({ id: "story:1", params: {}, directives: {} });
// storyRes.usable?.content has type `Story | undefined`
const collRes = await cache.get({ id: "collection:top", params: {}, directives: {} });
// collRes.usable?.content has type `Story[] | undefined`
// The (id, content) pair is also enforced on write:
await cache.store([
{ id: "story:1", content: aStory, directives: { freshUntilAge: 60 } }, // ok
{ id: "collection:top", content: [story1, story2], directives: { freshUntilAge: 60 } }, // ok
// @ts-expect-error -- can't store a Story[] under a story:* id
{ id: "story:bad", content: [story1], directives: { freshUntilAge: 60 } },
]);This is particularly useful when a producer that fetches a collection wants to additionally cache each individual entry (via supplementalResources), so that point lookups for each entry by id can also be served by the same cache.
With batching disabled, each cache.get() makes its own store.get() call. For PostgresStore, that is one query, and one pool connection, per call. Code that fans out one lookup per id, such as a wrapProducer loader called for each id in a list, therefore runs N queries for N ids.
Reads are batched by default using DataLoader with cache: false. Configure its batch size and scheduler with batchReads:
const cache = new Cache({
store: new PostgresStore(pool, { schemaName: "cache", tableName: "visits" }),
name: "visits",
resourceTypes: visitsResourceTypes,
batchReads: { maxBatchSize: 50, batchScheduleFn: setImmediate },
});
// One batched SQL join for these requests:
await Promise.all(visitIds.map((id) => getVisit({ id })));singleTypeCacheOptions accepts the option too. Batching is enabled by default with maxBatchSize: 50 and DataLoader’s default scheduler. Set batchReads: false to disable it. DataLoader never caches results, even for identical requests.
- Requests share one query, never one result. Each
get()still classifies its own id, evaluates freshness at its ownnow, selects its own entry, and publishes its ownreadmessage. So a batchedget()that succeeds returns what an unbatched one returns. - Scheduling is configurable. By default, DataLoader dispatches after Promise continuations.
batchScheduleFnreceives a dispatch callback and must invoke it in the immediate future; for example, passsetImmediateto gather reads until the next check phase. - One
getManycall takes at most 50 requests by default. SetmaxBatchSizeto change this limit. Larger batches split into independent calls. - Aborts stay per request. The shared
getManycall runs without a signal. A request whose signal aborts, while it waits or while its batch's query runs, rejects at once with the signal's reason and publishes noreadmessage. - A store failure is shared. When a
getManycall fails, every request in it rejects with the error, and each publishes its ownread-failedmessage. The cause can be one request's input. For example, Postgres rejects a statement that binds an id with U+0000, so that id fails every request in itsgetManycall, including other callers' requests. close()starts the queued reads before it closes the store. It does not wait for them to settle, as it does not wait for unbatched reads. The store's own close handles reads in flight.- The query runs in the first caller's async context. The default scheduler captures the async context (for example,
AsyncLocalStoragestores and the active tracing span) of theget()that opened the batch; a custom scheduler controls the dispatch context. So the query's span appears in that caller's trace only. - The store answers with
getMany. Postgres uses the same SQL vary predicate for single and bulk reads, including null-as-absence. Empty storedvarymatches every request and takes an equality fast path that skips scanning vary fields; null-only vary objects still use the general predicate. Both reject params that JSONB cannot represent, such as strings containing U+0000 or lone surrogates. One invalid request can fail other requests in the batch.
Batching only saves round trips when the store's getMany reads all of its requests in one operation. PostgresStore and SqliteStore do. A custom store that implements getMany with naiveGetMany still runs one get per request; see "Guidelines for Authoring a Store". PostgresStore.getMany joins indexed request rows against stored entries and filters vary requirements in SQL, transferring only matching entries. Direct bulk calls split at 1,000 requests (3,000 bound parameters) per statement, with chunks run sequentially. Duplicate requests retain separate slots and may transfer duplicate matching payloads. SQL still evaluates candidate variants, so many variants per ID can increase database work. SQLite fetches candidate variants and filters them in its worker.
SQL text bindings replace lone UTF-16 surrogates in IDs with U+FFFD. Both SQL stores compare returned IDs exactly with the original request, so ill-formed IDs produce misses rather than hits for a different ID. SQLite also checks the ID preserved in entry JSON: an ill-formed stored ID cannot be served under its well-formed replacement. Such writes can collide at the SQL key and evict another entry. MemoryStore preserves IDs exactly. SQLite preserves string vary keys and values through JSON and compares them exactly; Postgres rejects ill-formed strings in persisted JSONB and in both single and bulk JSONB params. An unused request param does not prevent an otherwise valid match.
-
Cache.ts: this defines the basic cache class. Note that the class's job is just to return whether/which previously-stored responses are usable to satisfy an incoming request. It does not handle things like making requests to the producer for new responses when no cached response is usable. -
MemoryStore.ts: a store for retaining cached data in memory, with a TTL and optional LRU eviction to cap memory usage. -
PostgresStore.ts: a store for retaining cached data in Postgres.
The package provides four functions for wrapping producers with a cache. They split along two axes — single vs. bulk, and "lookup" vs. "compute". All four take exactly one producer, and in every case per-resource-type dispatch is opt-in sugar that builds that one producer from several: producerByIdType/bulkProducerByIdType route by the request's id, while the compute wrappers' hashedInputProducerByInputType/bulkHashedInputProducerByInputType route by input. None of those four sugar helpers needs a cache:
-
wrapProducer.ts—wrapProducer: the package's most important export, arguably. It takes an options bag holding theCacheinstance, plus a producer (a function that returns data to cache), and returns a function that will use a cached value when a suitable one is available, but otherwise call through to the producer and store its return value for future requests.// A bare function covers the WHOLE registry. Sole-type caches stop here: const getVisits = wrapProducer({ cache }, async (req) => ({ content: await fetchVisits(req.id), directives: { freshUntilAge: 60 }, })); // Per-resource-type dispatch (and/or partial coverage) is opt-in sugar. // Note it takes the REGISTRY, not the cache -- routing by id type needs // nothing else, so this is a value you can build and test on its own: const getStories = wrapProducer({ cache }, producerByIdType(storiesResourceTypes, { story: async (req) => ({ content: await fetchStory(req.id), directives: { freshUntilAge: 60 } }), collection: async (req) => { const collection = await fetchCollection(req.id); return { content: collection, directives: { freshUntilAge: 60 }, // supplementals may target any registry type, covered by this wrapper or not supplementalResources: collection.stories.map((s) => ({ id: `story:${s.id}`, content: s, directives: { freshUntilAge: 60 } })), }; }, }));
A bare function covers the whole registry, and the compiler makes it prove that: its parameter must accept every registry id. Partial coverage requires
producerByIdType(orbulkProducerByIdType), which turns a per-resource-type record into one function carrying its covered set in an optional symbol-keyed property (coveredTypes). That set is the wrapper's coverage, and it bounds the returned function's request type: requests for uncovered types are compile errors (and, if reached via casts, throwNoProducerForResourceTypeErrorbefore any cache read). A bare producer can't under-cover, so that error is unreachable for it.producerByIdTypethrows at construction on an empty record.Reaching for
producerByIdTypealso buys per-branch type checking: each key narrows its sub-producer'sreq.idand its result to that resource type's content, so TypeScript checks the (id, content) correlation per branch. A single function's result type is the union over its covered ids, so it is free to return one variant's content for every id.Both helpers take the resource-type registry (
cache.resourceTypes, or the literal you declared it from) rather than a cache: classifying an id to pick a sub-producer is all they do, and that needs the registry alone. So a by-id-type producer is a value in its own right — buildable, drivable and unit-testable before any cache exists, and reusable across caches over the same registry. It is also the inference site for the registry type, which is why it is a parameter rather than a type argument.When such a producer is driven directly and an id doesn't classify to exactly one covered type, it throws
UnroutableIdError— cache-free, carryingid,coveredResourceTypes, and adetaildiscriminated onreason("uncovered"/"unclassifiable"/"ambiguous"). Driven through a wrapper, the wrapper catches it and re-throws the equivalent cache-named error (NoProducerForResourceTypeError,UnclassifiableIdError,AmbiguousResourceTypeError), so a wrapped producer's observable errors are unchanged. Reaching it through a wrapper at all means the registry the producer was built from disagrees with the cache's own — the wrapper classifies first, against the cache's registry — so passingcache.resourceTypes(rather than another object of the same shape) is what keeps the two in step.Writing a multi-resource-type producer by hand instead?
classifyIdAgainst(resourceTypesEntries(resourceTypes), id)is the same total classification the cache and these helpers use, exported for exactly that, and it returns an outcome rather than throwing so you can render your own errors.A type with no producer in any wrapper is legal and normal: its entries are written as other producers' supplemental resources (or direct
store()calls) and read viaCache.get— the serve-if-present contract. Partial coverage also makes capability-scoped and split wrappers honest: a secondwrapProducercall can cover a different subset of the same cache.Producers must be side-effect-free reads of their resource type's origin: invocations may be collapsed (shared with concurrent logical callers) and their results stored, so producer calls are never 1:1 with callers. Consumers that must reach the origin send bypass directives (
maxAge: 0) — which skip the cache read entirely, guaranteeing producer contact (the result is still stored, and identical bypass requests still collapse). Producers whose response must not be stored returnstoreFor: 0.Reuse while writes are pending. Producer results return before cache writes finish. Identical overlapping calls can share production within
collapseOverlappingRequestsTime. Single and hashed-single wrappers also reuse eligible pending entries, including supplemental resources. Pending entries follow normal freshness policies and leave the overlay when their write settles, on success or failure. The overlay models a store that retains all eligible pending entries, including duplicate(id, vary)slots. Backing stores may retain fewer entries through capacity limits, eviction, or per-slot deduplication, so a pending lookup need not equal a lookup after persistence. The overlay is local to one wrapper instance and process; bulk wrappers do not yet use it.Observe persistence with
onStore. Await the promise passed to the hook when subsequent work requires persistence. A pending-overlay hit exposes the selected entry's original write, including for supplemental resources and stale fallbacks. Returning stale data excludes the background refresh write; returning a foreground refresh observes its write. Shutdown guarantees differ by store: SQLite drains its writer, while MemoryStore and PostgresStore do not drain outstanding writes. Postgres callers own the database connection and must await persistence before closing it. -
wrapBulkProducer.ts—wrapBulkProducer: the same idea for producers that resolve many requests at once. It looks each request up in the cache and calls the underlying producer only for the ones that missed (or need background revalidation).Because the producer is a single function, a bare one receives the full set of requests to produce — mixed resource types and all — in ONE call, so it can optimize across them (a single upstream call covering several types, cross-type dedup, a join). That means one producer invocation per collapse window, not one per resource type.
bulkProducerByIdTypeopts into per-type dispatch instead: it splits the batch by classified resource type, calls each sub-producer once with its own type-pure slice, reassembles the results positionally into the caller's order, and isolates a sub-producer's failure — a rejection or a synchronous throw — into that type's result slots asErrorelements, so it never discards a sibling type's results.wrapProducerandwrapBulkProducerboth treat the cacheidas a reference to a mutable entity: the caller already has the id, and the cached value is whatever that entity currently is — a function of theidand time (e.g. "the currentUserforuser:123"). The id is the natural cache key, so the producer receives it directly.Both per-resource-type helpers live in
producerByIdType.ts— they need neither wrapper at runtime, and noCache. The errors both wrappers raise are inproducer-errors.ts.Like the two "compute" wrappers below, both take one options bag followed by the producer: the cache is the bag's required
cachefield (wrapProducer({ cache, ...options }, producer)), not a separate positional argument, so all four wrappers are called the same way and no call site has to passundefinedin the middle to reach the producer.Upgrading from 1.6.0: the wrapped function returned by either wrapper is now a plain function. 1.6.0 attached the
Cacheinstance to it as acacheproperty ("for convenience, e.g. in closing it"); 2.0 does not, since the cache is a field of the options bag the caller already holds. Hold on to that instance yourself if you were reaching forwrapped.cache(e.g.wrapped.cache.close()→cache.close()). -
wrapHashedInputProducer.ts—wrapHashedInputProducerandwrapBulkHashedInputProducer: the "compute" counterparts to the two above, for when the cached value is not an entity looked up by id but an expensive-to-compute function of some input — value =f(input), reused whenever the same input recurs (e.g. an LLM extraction over a chunk of text). Here a hash of the input is the natural cache key, but the producer wants the original, un-hashed input to do the work.You pass one options bag. For a cache with one resource type, the two functions are the whole contract — no record, no map, no type arguments:
const extract = wrapHashedInputProducer({ cache, hashInput: (input: Chunk) => `extract:${sha256(canonicalize(input))}` as const, produce: async (input) => ({ content: await runLlm(input), directives: { freshUntilAge: Infinity } }), });
For several resource types,
hashedInputProducerByInputTypebuilds a hashed-input producer — one.whenper covered type, dispatching on the input. It takes no cache: a hashed-input producer is a value in its own right, buildable (and reusable) before any cache exists, and checked against a cache's registry only where the two are wired together.const hashedInputProducer = hashedInputProducerByInputType<{ story: HashedInputVariant<StoryInput, Story>; collection: HashedInputVariant<CollInput, Story[]>; }>() .when((i): i is StoryInput => i.kind === "story", { name: "story", hashInput: (input) => `extract:story:${input.id}`, // input: StoryInput produce: async (input) => ({ content: await extractStory(input), directives }), }) .when((i): i is CollInput => i.kind === "collection", { name: "collection", hashInput: (input) => `extract:collection:${input.ids.join(",")}`, produce: async (input) => ({ content: await extractAll(input), directives }), }) .build(); const extract = wrapHashedInputProducer({ cache, hashedInputProducer });
The variant map declares each resource type's input and the content computed from it, so a branch is validated where it is written:
producemust return that variant's output,inputis narrowed to that variant's input (no casts), and a second.whenfor an already-covered variant is rejected rather than silently shadowed.nameselects the variant and the guard is only the runtime dispatcher — which is why two variants may be computed from the same input type (a summary and a translation of one story) and why a guard may prove a subtype of the declared input.Wiring a producer to a cache is what brings the registry in: each branch's minted-id type is checked against its resource type's id space, each variant's declared output against that type's content, and every variant name against the registry — reported as named, per-branch problems.
hashInputmust mint ids the branch's own registry guard accepts, and that is also checked at runtime by classifying each minted id (naming the branch), which is the backstop for a mint arriving through a cast or an untyped boundary. Each way it can fail gets its own error: the mint matched no resource type (UnclassifiableIdError) or several (AmbiguousResourceTypeError), or exactly one that isn't the branch's own (MintedIdResourceTypeMismatchError, carryingbranchandclassifiedResourceType— classification succeeded there, so the mismatch indicts the branch'shashInputrather than the registry's guards). For accept-everything registries — asingleTypeCacheOptionscache with novalidateId— the runtime check is vacuous and the compile-time one carries the weight.The wrapper keeps each input around (reference-counted, so it survives request collapsing without leaking) just long enough to hand it to
produceon a miss, and otherwise behaves likewrapProducer/wrapBulkProducer— including call-time consumerdirectives(e.g.compute(input, { directives: { maxAge: 0 } })forces a recompute). A hashed-input producer may also returnsupplementalResourcesin two forms: input-keyed ({ input, … }— routed to a covered branch via the same guard selection as call-time inputs, hashed with that branch'shashInput, and mint-checked eagerly, so a latercompute(thatInput)is a hit; in a hashed-input producer theinputandcontentmust come from the SAME variant) or id-keyed ({ id, … }— a plain resource for any registry type, classified by its own id at store time, exactly like plain producers' supplementals; typing these needs the registry's id space, so a cache-free hashed-input producer must be declared with itsSpecOfto return them).wrapBulkHashedInputProduceris the same, withbulkHashedInputProducerByInputTypefor the multi-type form: each branch'sproducecomputes a batch of that branch's missed inputs and returns a result (or anError) per input, aligned by index.
The package publishes telemetry on five diagnostics_channel channels (see diagnostics.ts). Logical read, fetch, and store-entry messages carry { cache, resourceType } attribution — the cache instance's name and the classified resource-type name — so subscribers can build per-cache, per-resource-type metrics with no name threading. (produce covers a whole batch, so it carries cache at the top level and each element's resourceType inside requests[].) Batch-read messages carry only cache because one batch can mix resource types. Each channel exports its name constant, its message type, and a typed channel object (TypedChannel).
| Channel | Cardinality | Message highlights |
|---|---|---|
@zingage/cache:read |
One per cache lookup (Cache.get; per request for getMany) — including direct callers |
found: "usable" | "usable-while-revalidate" | "usable-if-error" | "none", evaluated against the request's directives; plus vary — identifying the selected entry's variant — on the three entry-selecting results, and omitted on the others. Bypass requests never appear (they skip the read). A read the store failed publishes found: "read-failed" with the error — one message per request in the failed call — and then the error propagates, which keeps the channel a total denominator; only an aborted read emits nothing. |
@zingage/cache:read-batch |
One actual Store read: automatic or direct | Cache name, Store method, success/failure/cancellation, dispatched and failed request counts, returned entry count, Store duration, and summed caller queue wait. No resource IDs or payloads. |
@zingage/cache:fetch |
One per call of a wrapped producer (per request element, for bulk), at settlement | disposition: served-from-cache, served-stale-while-revalidating, served-stale-after-error, served-from-producer, producer-error, or aborted; collapsed (the settlement rode an in-flight invocation; cache-served settlements report false even when they attached a background revalidation as a rider); cache-served dispositions carry vary (identifying the served entry's variant) and producer-path dispositions carry directivesImpliedBypass. |
@zingage/cache:produce |
One per actual producer invocation (foreground misses AND background revalidations) | trigger: "miss" | "revalidation" | "bypass" (the invocation's initiating cause; riders never re-label); requests[] ({resourceType, resourceId}, in the order the producer received them — a bulk batch MAY span resource types, so read each element's own resourceType); collapsedCallerCount; outcome; failedRequestCount; durationMs. failedRequestCount counts Error slots in a resolved bulk result, or every request if the invocation rejects or violates its contract. A valid result array still reports outcome: "success" when individual slots fail. |
@zingage/cache:store-entry |
One per entry passed to Cache.store() (supplementals attributed to their own type) |
resourceId, vary, validators, relationshipToExistingStoredData ("is-new" | "unchanged" | "changed" | undefined). |
A cache's unit of storage is the (id, vary) pair, where vary is the normalized paramName→value map that defines a variant: a producer that varies on request params writes one entry per variant under one id. So the three channels that can identify a variant all report the same normalized vary object — store-entry for the entry written, read/fetch for the entry a lookup selected — which is what lets a subscriber attribute serves to the exact stored variant (e.g. how many times one version was served before a newer one replaced it) instead of smearing every variant's serves onto whichever variant stored first. Where no variant was served the key is omitted, never published as {}, because {} identifies a real variant — the only one a non-varying producer ever writes. That covers found: "none"/"read-failed" on read, and producer-error/aborted on fetch — and also served-from-producer, which did hand the caller a value: that value came from the origin, not a stored variant, and the variant it lands in is reported on store-entry if and when the write succeeds.
Upgrading from 1.6.0, whose single channel reported a status string: hit → served-from-cache, stale_while_revalidate → served-stale-while-revalidating, and miss/bypass → served-from-producer (now distinguished by directivesImpliedBypass). served-stale-after-error, producer-error, and aborted are new dispositions that 1.6.0 could not report at all.
served-from-cache and the other cache-served fetch dispositions can select entries from the wrapper’s pending-write overlay. The read channel reports the Store lookup; fetch reports the combined result. A Store miss can therefore lead to a cache-served fetch.
fetch and produce are the two spans of one story with different subjects and cardinalities: a fetch is the consumer-side span (one per logical request); a produce is the origin-side span (one per invocation). N collapsed callers ride one invocation, one bulk invocation covers many requests, a stale-while-revalidate refresh settles after its triggering fetch already shipped, and an aborted fetch settles before its invocation does (the collapsed producer call keeps running and stores in the background).
Overlapping store() and delete() calls have no ordering guarantee, including calls on the same cache or store instance. Await a mutation before starting the next mutation when their order matters. MemoryStore writes also require awaiting completion before a caller can rely on their visibility. A completed delete does not prevent an in-flight producer from storing its result later. Cross-request invalidation needs a separate version or generation protocol; these APIs do not provide one.
MemoryStore close only stops periodic expiration cleanup; pending operations continue. PostgresStore disposal leaves operations and connection-pool shutdown to the caller. SqliteStore close drains accepted worker tasks unless shutdown fails or times out. Await operation promises when their completion or errors matter. Finish Postgres operations before shutting down the supplied pool. See store shutdown details, including timeout behavior and Cache.close().
Read messages include required durationMs. Cache lookups emit elapsed time from method entry through
classification. Bulk slots share one start time; each slot reports its own
classification completion. Pre-aborted calls still emit no read event.
cacheReadBatchChannel reports every actual Store read: automatic batches,
direct Cache.getMany, and unbatched Cache.get. operation identifies the
Store method. Empty direct calls and entirely cancelled queued batches emit
nothing. requestCount counts dispatched slots, including duplicates.
failedRequestCount counts Error slots or every request on whole-operation
failure. outcome is failure when any slot fails. A direct Store rejection after its
caller cancels reports cancelled with zero failed requests. entryCount counts entries
in successful slots, even when another slot fails; whole-operation failures
report zero entries. A direct bulk Cache call can reject all its callers for
one Store Error slot, so logical failure counts can exceed failed Store slots.
queueWaitTotalMs / requestCount gives mean dispatched caller queue wait;
direct calls report zero. durationMs covers the Store call and automatic
result-length validation, before Cache classification. Caller cancellation
after dispatch does not erase the Store operation's eventual result. Cancelled
logical reads retain their existing no-event behavior. This is not database
execution time or a physical query count: a Store may issue multiple queries
or use no database.
The package does not calculate returned payload bytes, serialize values for telemetry, choose metric tags, or create monitors. Subscribers own aggregation and telemetry costs. No resource IDs, payloads, or errors appear in batch events.
All four producer wrappers accept a per-call onStore(stored) callback.
The callback receives a Promise<void> for that call's foreground cache writes.
The wrapper returns produced results without awaiting persistence or the callback's
return value. Callers own their wait, timeout, retry, and failure policy.
The callback runs once before a successful call returns. Cache hits, empty bulk calls, and stale fallbacks with no foreground writes receive a fulfilled promise. Background revalidation is excluded. Collapsed callers observe the same underlying writes. For bulk calls, the promise settles every foreground store before rejecting with the first rejection in group order. Producer errors keep their existing throw, per-item error, and stale-fallback behavior; they are not store failures.
The package observes persistence rejections internally, so callers can ignore the promise or attach handlers later. A synchronous callback exception rejects the wrapper call. An async callback's returned promise is not awaited or observed; callers must handle their own async work. Caller cancellation does not cancel shared persistence. The hook is not delivered if the wrapper call rejects before it can return results.
let stored = Promise.resolve();
const result = await produce(request, {
onStore: (promise) => {
stored = promise;
},
});
await stored; // Optional: require persistence before starting subsequent work.