feat(cache): add tag-based caching and revalidation helpers - #1964
feat(cache): add tag-based caching and revalidation helpers#1964dinwwwh wants to merge 23 commits into
Conversation
…implementation-09e313 # Conflicts: # README.md # apps/content/docs/procedure.mdx # packages/ai-sdk/README.md # packages/arktype/README.md # packages/bun/README.md # packages/client/README.md # packages/cloudflare/README.md # packages/contract/README.md # packages/effect/README.md # packages/evlog/README.md # packages/hibernation/README.md # packages/json-schema/README.md # packages/nest/README.md # packages/next/README.md # packages/node/README.md # packages/openapi/README.md # packages/opentelemetry/README.md # packages/pinia-colada/README.md # packages/pino/README.md # packages/publisher/README.md # packages/ratelimit/README.md # packages/server/README.md # packages/server/src/procedure-client.test.ts # packages/shared/README.md # packages/swr/README.md # packages/tanstack-query/README.md # packages/trpc/README.md # packages/valibot/README.md # packages/zod/README.md # pnpm-lock.yaml
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/experimental-cache
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/experimental-msw
@orpc/nest
@orpc/next
@orpc/node
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
orpc | 8a12845 | Commit Preview URL Branch Preview URL |
Sep 04 2026, 03:27 AM |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Important
One behavioral issue to resolve: a revalidation failure after a committed mutation surfaces as an error on a request whose write already succeeded. See the inline comment on revalidate.
Reviewed changes
@orpc/cache(new package) —cache()/revalidate()middlewares,CacheStorecontract, tag-version invalidation, stale-while-revalidate,CacheHandlerPluginheader reflection, andMemoryCacheStore/RedisCacheStore/VercelCacheStoreadapters.@orpc/cloudflare—KVCacheStore(real KV bindings) and purge-onlyWorkersCacheStore, plus workerd coverage.@orpc/shared— newdeepSortKeysutil and tests.- Docs/config — new
docs/helpers/cachepage, README/package-list updates, api-reference row, new packagepackage.jsonwith subpath exports, workspace wiring.
Overall this is a careful, well-tested addition. I verified the highest-risk semantics rather than taking them on faith: the tag-version technique errs on the safe side (a lost concurrency race produces a spurious miss and recompute, never a stale hit), the tag header encoding round-trips correctly under case-folding and stays consistent between the reflected cache-tag and WorkersCacheStore purge, blob/streaming outputs are guarded where they cannot be stored, and the docs call out the CDN/purge-store and per-request-shared-key caveats. Two non-blocking nits are inline.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| const resolvedTags = toArray(await value(tags, middlewareOptions, input)) | ||
|
|
||
| if (resolvedTags.length) { | ||
| await (middlewareOptions.context as CacheContext).cache.revalidateTag(resolvedTags as [string, ...string[]]) |
There was a problem hiding this comment.
revalidateTag is awaited with no guard, so when the store is unreachable (e.g. a transient Redis outage) a mutation whose handler already succeeded is reported to the client as a failed request. Clients that retry on error will re-run the mutation, risking a double write/commit. This contrasts with the stale-refresh path just above, which deliberately swallows its background failures (.catch(() => {})).
Consider treating revalidation as best-effort after a successful procedure — catch/log and still return result — so a cache outage can never turn a committed mutation into an error response. If a loud failure is deliberately wanted for observability, that's defensible too, but it should be a documented, conscious choice given the retry implications.
Technical details
# Revalidate errors flip committed mutations into failures
## Affected sites
- packages/cache/src/middleware.ts:167 — `await (…context as CacheContext).cache.revalidateTag(…)` has no try/catch; the error propagates to the caller after `next()` already committed the mutation.
## Required outcome
- A successful procedure must not surface a client-facing error when cache revalidation fails afterward.
## Suggested approach
- Wrap `revalidateTag` (and the tag/value resolution) so revalidation failures are logged/silently dropped and the committed `result` is still returned — mirroring the SWR refresh path's `.catch(() => {})`.
## Open questions
- Is fail-loud is the intended contract here? If so, document it, since the SWR refresh path deliberately does the opposite.| } | ||
| } | ||
|
|
||
| function isUncacheableOutput(output: unknown): boolean { |
There was a problem hiding this comment.
Nit: isUncacheableOutput only catches top-level async iterators and ReadableStream, so a top-level Blob output is still passed to store.set. MemoryCacheStore.set stores it (in-memory it works), but RedisCacheStore/KVCacheStore/VercelCacheStore drop it, so caching semantics silently differ per adapter. Consider also gating Blob (and FormData, which RPCSerializer emits for nested blobs) here so the behavior is uniform regardless of store.
There was a problem hiding this comment.
Important
One behavior to confirm from the delta's cleanup: dropping isUncacheableOutput from the middleware also dropped the "don't record a cache check for streaming outputs" guarantee, so streaming responses are now advertised as cache activity even though every adapter ignores them. Full context inline.
Reviewed changes
This run reviews the single-commit delta e93c93e5 since the last-reviewed head 17c31c21:
CacheStore.revalidateTags— replacedrevalidateTag(string | list)with a non-empty tag list across the interface, all four adapters, and their mocked/integration tests; stores dropped their empty-list branches and the middleware now guards withtags?.length.revalidate({ tags })API — options-object form whosetagsis aValueof a non-empty tuple orundefined; resolving toundefined(or[]) skips both the store call and the plugin-context recording.- Context keys —
CacheContextmoved to namespaced'cache/store'/'cache/waitUntil'; tests, type tests, docs, and e2e all updated, with nocontext.cache/context.waitUntilstragglers. cache()key semantics — any providedkey(string or object) is now used as given; only the omitted-key default derives[path, input]; documented as "procedures sharing a key share an entry".- Streaming-output drops moved into stores —
isUncacheableOutputdeleted;MemoryCacheStore.setnow ignoresReadableStream/async-iterator outputs, matching Redis/KV/Vercel.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| await store.set(key, result.output, { tags, ttl, swr }) | ||
|
|
||
| pluginContext?.caches.push({ | ||
| procedure: middlewareOptions.procedure, | ||
| path: middlewareOptions.path, | ||
| hit: false, | ||
| stale: false, | ||
| key, | ||
| tags, | ||
| ttl, | ||
| swr, | ||
| }) |
There was a problem hiding this comment.
With isUncacheableOutput removed, this miss path now records a cache check (pluginContext.caches.push) even when the store declined the write, and CacheHandlerPlugin reflects it: streaming responses (async iterators / ReadableStream) now emit orpc-cache-tag and, on GET/HEAD, cache-tag + cache-control — signaling front caches to cache stream bodies the store will never replay. The removed it.each(...)('never stores %s outputs and records no check') test enforced the opposite, so this surface changed silently.
Technical details
# Streaming responses are advertised as cached
## Affected sites
- packages/cache/src/middleware.ts:117-128 — the miss path calls `store.set` (a no-op for `ReadableStream`/async-iterator outputs in every adapter: MemoryCacheStore added in this delta, Redis/KV/Vercel via RPCSerializer) and then unconditionally pushes a cache check that describes cache activity that never happened.
- packages/cache/src/handler-plugin.ts:139-162 — reflects the root check's tags: `orpc-cache-tag` on every response, and `cache-tag` / `cache-control: public, s-maxage=…` on GET/HEAD, so a streaming response instructs a front cache (e.g. Cloudflare Workers Caching) to cache a stream body.
- packages/cache/src/middleware.test.ts — the streaming-output tests asserting `pluginContext.caches` stays empty were removed alongside the guard.
## Required outcome
- Streaming (async-iterator / `ReadableStream`) outputs must not be advertised as cacheable in reflected headers, matching store behavior and the docs ("every adapter ignores them").
## Suggested approach
- Make the middleware skip the recorded check for outputs it knows are never stored — e.g. have `CacheStore.set` return whether it stored the value and gate the push on it, or restore a narrow raw-output guard matching the stores' `ReadableStream`/`isAsyncIteratorObject` check before `set` + push.
## Open questions
- Is reflecting cache headers for streaming responses intentional? If so, the docs page should say so, since it currently states streaming outputs "cannot be replayed, so every adapter ignores them".`CacheStore.revalidateTag` becomes `revalidate({ tags })`, taking a non-empty
tag list, and every duration is now in seconds rather than milliseconds,
matching what Redis, Workers KV, the Vercel Runtime Cache, and `Cache-Control`
all accept. Entries without tags carry `undefined` instead of an empty array,
and stores no longer inspect output, passing it straight to their serializer.
The store and its background-work hook move to the namespaced `cache/store`
and `cache/waitUntil` context keys. A background refresh is handed over
uncaught so `cache/waitUntil` can report its failures. `revalidate` takes an
options object with a required `tags`, and a provided `key` is used as given.
The handler plugin takes header names as plain literals rather than exported
constants, sets them whatever the request method, and emits `max-age` instead
of `s-maxage`, which carries the `proxy-revalidate` semantics that would
forbid the stale reuse `stale-while-revalidate` grants.
Tag header encoding and `nowInSeconds` move to `@orpc/shared`, and each store
builds its key serializer once instead of per call.
…mplementation-09e313 # Conflicts: # README.md # packages/ai-sdk/README.md # packages/arktype/README.md # packages/bun/README.md # packages/client/README.md # packages/cloudflare/README.md # packages/cloudflare/package.json # packages/contract/README.md # packages/effect/README.md # packages/evlog/README.md # packages/hibernation/README.md # packages/json-schema/README.md # packages/nest/README.md # packages/next/README.md # packages/node/README.md # packages/openapi/README.md # packages/opentelemetry/README.md # packages/pinia-colada/README.md # packages/pino/README.md # packages/publisher/README.md # packages/ratelimit/README.md # packages/server/README.md # packages/shared/README.md # packages/swr/README.md # packages/tanstack-query/README.md # packages/trpc/README.md # packages/valibot/README.md # packages/zod/README.md # pnpm-lock.yaml
- RedisCacheStore and experimental_KVCacheStore take their client as the first argument - experimental_WorkersCacheStore defaults to the cache exported by cloudflare:workers - Redis, Upstash, and Bun stores share the shorter e:/t: key families and one envelope format - race-condition tests for every store and the cache middleware
e93c93e to
8a12845
Compare
There was a problem hiding this comment.
Important
Removing the output guards from every store turned "un-storable output → cache miss" into "un-storable output → stored and served as {}", so a procedure returning a Blob/File/FormData/ReadableStream/async iterator now serves a corrupted empty value on every within-ttl hit instead of recomputing. The tag-invalidation semantics themselves check out — a revalidation race can only produce a spurious miss, never a stale serve. Two nits inline (a runtime-only Upstash edge and a stale JSDoc).
Reviewed changes
This run reviews the PR-owned delta since the last-reviewed head e93c93e5. The branch was force-pushed — e93c93e5 was replaced by a rework commit, main was re-merged, and new work landed — so the substantive delta is commits c2356fbc, e9c80635, and 8a12845b.
- Store contract rework —
revalidateTag(list)becamerevalidate({ tags })with a non-empty tag list; every duration switched from milliseconds to seconds across adapters, envelopes, middleware, and docs; untagged entries now carryundefinedinstead of[]; key serializers are built once per store andencodeCacheKey/nowInSecondsmoved to@orpc/shared. - Output guards removed — every store now passes output straight to its serializer; the blob/stream/iterator drop logic and its tests were deleted, and the docs replaced the "every adapter ignores them" guarantee with a do-not-cache warning.
- Handler plugin rework —
headersis now a required list of plain literals (exported constant helpers removed);cache-tag/cache-controlare set on any request method and override existing headers;cache-controlswitched froms-maxagetomax-age(proxy-revalidate semantics), with stale hits reflectingmax-age=0. - Background refresh semantics — a stale-hit refresh is handed to
cache/waitUntiluncaught so the runtime can report failures, and only.catch(() => {})d when nothing owns it. - New adapters —
UpstashCacheStore(@orpc/experimental-cache/upstash) andBunRedisCacheStore(@orpc/bun), sharing the Redis key/envelope format, plus a cross-adapter compatibility suite; all store constructors now take their client positionally. @orpc/shared—nowInSecondsand case-safeencodeCacheTag/encodeCacheTagHeader/decodeCacheTagHeader, each with tests.- Testing — a shared
describeCacheStoreContractsuite now runs against Memory/Redis/Upstash/Vercel, withholdResultrace tests, concurrency suites for the middleware, and a merge of upstream main (all CI green on the head).
Inline threads
packages/cache/src/middleware.ts:120— un-storable outputs are persisted and served as corrupted{}(IMPORTANT).packages/cache/src/adapters/upstash.ts:136—revalidate({ tags: [] })throws on Upstash, no-ops elsewhere (nit).packages/cache/src/handler-plugin.ts:47—headersJSDoc still claims GET/HEAD-only and never-override (nit).
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
|
|
||
| const result = await middlewareOptions.next() | ||
|
|
||
| await store.set(key, result.output, { tags, ttl, swr }) |
There was a problem hiding this comment.
Since this delta removed the blob/stream guards from every store, an output RPCSerializer cannot represent — top-level or nested Blob/File/FormData, ReadableStream, or async iterators — is now persisted with the output silently flattened to {} (serialize returns those as-is, stringifyJSON renders them {}). Every later within-ttl hit serves that corrupted {} instead of missing and recomputing — a regression from the previous drop-the-entry behavior (the old ignores outputs containing blobs tests asserted get → undefined and were deleted with the guards). It also compounds the open streaming-outputs thread: orpc-cache-tag/cache-tag/cache-control now advertise a response the store will serve as {}.
| for (const tag of tags) { | ||
| multi.incr(this.tagKey(tag)) | ||
| } | ||
| await multi.exec() |
There was a problem hiding this comment.
At runtime, revalidate({ tags: [] }) throws here — Upstash's Pipeline.exec() rejects with Error: Pipeline is empty — while Redis, Memory, Bun, and KV treat an empty list as a no-op. The middleware only guards if (tags), so a resolver producing [] at runtime reaches the store; the type-level non-empty tuple cannot stop a dynamic resolver. A one-line if (!tags.length) return keeps the adapters consistent.
| * | ||
| * @default [] | ||
| */ | ||
| headers: readonly CacheHandlerPluginHeader[] |
There was a problem hiding this comment.
The headers option JSDoc still says cache-tag/cache-control "are only set on GET and HEAD responses and never override existing headers", but this delta removed the GET/HEAD gate and the interceptor now replaces any existing value (both behaviors are asserted in the updated tests). Updating the option doc to match the current behavior — as the docs page already does — would keep the API surface accurate.

Adds
@orpc/experimental-cache, a new package for tag-based caching and revalidation of procedure outputs, with stale-while-revalidate, five store adapters, and a handler plugin that reflects cache activity into response headers for client-side revalidation (e.g. TanStack Query auto-invalidation on mutation) or HTTP response caches.Features
cache()middleware caches procedure output in the context'scache/store(one store per router). Keys default to the procedure path and full input, canonically encoded so structurally equal keys always hit the same entry; a providedkeyis used as given.key,tags,ttl,swr, andenabledare all dynamic on middleware options and input.ttlbut withinswrare served immediately while the procedure re-executes in the background; acache/waitUntilcontext hook keeps refreshes alive on Workers-like runtimes.revalidate({ tags })middleware invalidates tags after successful mutations, with compile-time non-empty tags; resolvingtagstoundefinedskips it.CacheHandlerPluginis inert by default; aheadersallowlist enablesorpc-cache-tag/orpc-cache-tag-invalidation(client-facing, never consumed by CDNs) andcache-control/cache-tag(for response caches in front, GET/HEAD only, never overriding). Only the root procedure's checks are reflected, never nested calls, and only on successful responses. Tag encoding survives Cloudflare Workers Caching's strict rules: printable ASCII only, and uppercase percent-encoded so case-insensitive matching cannot collide distinct tags.MemoryCacheStore,RedisCacheStore,VercelCacheStore(@orpc/experimental-cache), plusexperimental_KVCacheStoreand the purge-onlyexperimental_WorkersCacheStorein@orpc/cloudflare, following theexperimental_prefix convention for experimental APIs inside stable packages (with anew-caplint exception to support it). All share theCacheStorecontract and a uniform options-object constructor;revalidateTagstakes a non-empty tag list so no store handles an empty or single-string case. Outputs serialize viaRPCSerializer(blob and streaming outputs ignored), keys via the sharedencodeCacheKey.Server
deepSortKeysutil in@orpc/shared.Testing
@orpc/experimental-cacheand the new@orpc/cloudflarestores: unit, type-level, handler, and e2e tests, mocked-client Redis suites plus env-gated Redis integration tests, and workerd tests against real KV bindings.Docs
docs/helpers/cachepage (usage, adapters, SWR, handler plugin, cross-origin notes) with JSDoc backlinks, api-reference row, and package lists updated.