Skip to content

feat(cache): add tag-based caching and revalidation helpers - #1964

Open
dinwwwh wants to merge 19 commits into
mainfrom
claude/orpc-cache-implementation-09e313
Open

feat(cache): add tag-based caching and revalidation helpers#1964
dinwwwh wants to merge 19 commits into
mainfrom
claude/orpc-cache-implementation-09e313

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Aug 28, 2026

Copy link
Copy Markdown
Member

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 a context.cache 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; key, tags, ttl, swr, and enabled are all dynamic on middleware options and input.
  • Stale-while-revalidate: entries past ttl but within swr are served immediately while the procedure re-executes in the background; context.waitUntil keeps refreshes alive on Workers-like runtimes.
  • revalidate(tags) middleware invalidates tags after successful mutations, with compile-time non-empty tags.
  • CacheHandlerPlugin is inert by default; a headers allowlist enables orpc-cache-tag / orpc-cache-tag-invalidation (client-facing, never consumed by CDNs) and cache-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.
  • Stores: MemoryCacheStore, RedisCacheStore, VercelCacheStore (@orpc/experimental-cache), plus experimental_KVCacheStore and the purge-only experimental_WorkersCacheStore in @orpc/cloudflare, following the experimental_ prefix convention for experimental APIs inside stable packages (with a new-cap lint exception to support it). All share the CacheStore contract and a uniform options-object constructor; outputs serialize via RPCSerializer (blob outputs ignored), keys via the shared encodeCacheKey.

Server

  • Middlewares between stacked input schemas now receive the full input with the validated fragments merged over it, so whole-input middleware like caching works at any position (matches the behavior already on main; docs updated).
  • New deepSortKeys util in @orpc/shared.

Testing

  • 100% line/branch/function coverage on both @orpc/experimental-cache and the new @orpc/cloudflare stores: unit, type-level, handler, and e2e tests, mocked-client Redis suites plus env-gated Redis integration tests, and workerd tests against real KV bindings.
  • Full monorepo suite, type-check, lint, and docs validation pass.

Docs

  • New docs/helpers/cache page (usage, adapters, SWR, handler plugin, cross-origin notes) with JSDoc backlinks, api-reference row, and package lists updated.

dinwwwh added 18 commits August 27, 2026 14:53
…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
@pkg-pr-new

pkg-pr-new Bot commented Aug 28, 2026

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/middleapi/orpc/@orpc/ai-sdk@1964

@orpc/arktype

npm i https://pkg.pr.new/middleapi/orpc/@orpc/arktype@1964

@orpc/bun

npm i https://pkg.pr.new/middleapi/orpc/@orpc/bun@1964

@orpc/cache

npm i https://pkg.pr.new/middleapi/orpc/@orpc/cache@1964

@orpc/client

npm i https://pkg.pr.new/middleapi/orpc/@orpc/client@1964

@orpc/cloudflare

npm i https://pkg.pr.new/middleapi/orpc/@orpc/cloudflare@1964

@orpc/contract

npm i https://pkg.pr.new/middleapi/orpc/@orpc/contract@1964

@orpc/experimental-effect

npm i https://pkg.pr.new/middleapi/orpc/@orpc/experimental-effect@1964

@orpc/evlog

npm i https://pkg.pr.new/middleapi/orpc/@orpc/evlog@1964

@orpc/hibernation

npm i https://pkg.pr.new/middleapi/orpc/@orpc/hibernation@1964

@orpc/json-schema

npm i https://pkg.pr.new/middleapi/orpc/@orpc/json-schema@1964

@orpc/experimental-msw

npm i https://pkg.pr.new/middleapi/orpc/@orpc/experimental-msw@1964

@orpc/nest

npm i https://pkg.pr.new/middleapi/orpc/@orpc/nest@1964

@orpc/next

npm i https://pkg.pr.new/middleapi/orpc/@orpc/next@1964

@orpc/node

npm i https://pkg.pr.new/middleapi/orpc/@orpc/node@1964

@orpc/openapi

npm i https://pkg.pr.new/middleapi/orpc/@orpc/openapi@1964

@orpc/opentelemetry

npm i https://pkg.pr.new/middleapi/orpc/@orpc/opentelemetry@1964

@orpc/pinia-colada

npm i https://pkg.pr.new/middleapi/orpc/@orpc/pinia-colada@1964

@orpc/pino

npm i https://pkg.pr.new/middleapi/orpc/@orpc/pino@1964

@orpc/publisher

npm i https://pkg.pr.new/middleapi/orpc/@orpc/publisher@1964

@orpc/ratelimit

npm i https://pkg.pr.new/middleapi/orpc/@orpc/ratelimit@1964

@orpc/server

npm i https://pkg.pr.new/middleapi/orpc/@orpc/server@1964

@orpc/shared

npm i https://pkg.pr.new/middleapi/orpc/@orpc/shared@1964

@orpc/swr

npm i https://pkg.pr.new/middleapi/orpc/@orpc/swr@1964

@orpc/tanstack-query

npm i https://pkg.pr.new/middleapi/orpc/@orpc/tanstack-query@1964

@orpc/trpc

npm i https://pkg.pr.new/middleapi/orpc/@orpc/trpc@1964

@orpc/valibot

npm i https://pkg.pr.new/middleapi/orpc/@orpc/valibot@1964

@orpc/zod

npm i https://pkg.pr.new/middleapi/orpc/@orpc/zod@1964

commit: 8129954

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 28, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 30 untouched benchmarks


Comparing claude/orpc-cache-implementation-09e313 (8129954) with main (658f36b)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (dbb8c6a) during the generation of this report, so 658f36b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, CacheStore contract, tag-version invalidation, stale-while-revalidate, CacheHandlerPlugin header reflection, and MemoryCacheStore/RedisCacheStore/VercelCacheStore adapters.
  • @orpc/cloudflareKVCacheStore (real KV bindings) and purge-only WorkersCacheStore, plus workerd coverage.
  • @orpc/shared — new deepSortKeys util and tests.
  • Docs/config — new docs/helpers/cache page, README/package-list updates, api-reference row, new package package.json with 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using 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[]])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant