feat(cache): add tag-based caching and revalidation helpers - #1964
feat(cache): add tag-based caching and revalidation helpers#1964dinwwwh wants to merge 19 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/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 | 17c31c2 | Commit Preview URL Branch Preview URL |
Aug 28 2026, 08:53 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.
| return middlewareOptions.next() | ||
| } | ||
|
|
||
| const key = typeof keyMaterial === 'string' ? keyMaterial : [middlewareOptions.path, keyMaterial] |
There was a problem hiding this comment.
Nit: a verbatim string key is not scoped to the procedure path, so two procedures on the same router that both use key: 'k' deterministically collide in the store. This is documented ("Strings are used verbatim"), but the default path-scoping and the non-string material path both prefix with the path, so the asymmetry is easy to trip over. Consider a doc note that string keys skip the path prefix (or automatically prefix them).

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 acontext.cachestore (one store per router). Keys default to the procedure path and full input, canonically encoded so structurally equal keys always hit the same entry;key,tags,ttl,swr, andenabledare all dynamic on middleware options and input.ttlbut withinswrare served immediately while the procedure re-executes in the background;context.waitUntilkeeps refreshes alive on Workers-like runtimes.revalidate(tags)middleware invalidates tags after successful mutations, with compile-time non-empty tags.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; outputs serialize viaRPCSerializer(blob 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.