feat(cache): add tag-based caching and revalidation helpers - #1964
feat(cache): add tag-based caching and revalidation helpers#1964dinwwwh wants to merge 54 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 | 84268ab | Commit Preview URL Branch Preview URL |
Sep 10 2026, 08:59 AM |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will degrade performance by 70.71%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | flat object from query params |
106 µs | 361.9 µs | -70.71% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/orpc-cache-implementation-09e313 (84268ab) with main (d6062a8)1
Footnotes
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) | 𝕏
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) | 𝕏
`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.
🟡 Changes recommended
Redis lock takeover can overwrite newer values, cache keys can collide, and the package lacks the repository-standard publishing hook.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
packages/shared/src/http.ts:162
- An unpaired UTF-16 surrogate matches this branch, but
encodeURIComponentthrowsURIErrorfor it. Such strings can arrive through JSON and are accepted by the tag APIs, so reflecting or purging one can turn an otherwise successful request into an error. Either validate tags before executing the procedure or use a reversible encoding that handles lone surrogates.
packages/cache/README.md:71 - This new package README omits the Cloudflare Traces guide, while every other synchronized package README includes it in the same row (for example
README.md:71andpackages/cloudflare/README.md:71). Keep the generated package listing consistent.
- Files reviewed: 77/78 changed files
- Comments generated: 4
- Review effort level: Balanced
There was a problem hiding this comment.
✅ No new issues in this delta — the NOSCRIPT retry is now bounded to exactly one reload, closing the last open thread.
Reviewed changes
This run reviews the delta since the prior pullfrog review at 5b6652c7 — the single commit 69f8cc89:
- Bounded the NOSCRIPT script reload to exactly once —
RedisCacheStore.run(packages/cache/src/adapters/redis.ts) andBunRedisCacheStore.run(packages/bun/src/redis-cache.ts) dropped thewhile (true)loop for a single retry: onNOSCRIPTthe cached sha is deleted andevalSharuns again (which reloads the script), and a secondNOSCRIPT— or any other error — propagates instead of hanging forever. This is precisely the reload-once-then-throw contract the open thread asked for; the JSDoc on both stores now matches. - Added real-server regression tests in both
packages/cache/src/adapters/redis.test.tsandpackages/bun/src/redis-cache.test.tsthat seed an unknown sha ('0'.repeat(40)) into the store'sscriptShasmap and assert the fetch still succeeds after the single reload, across both the read and store scripts. Verified passing against live Redis in the node-26 CI log.
Notes:
- The lingering RESP3
output !== nullmiss-guard is unchanged (correct on the default RESP2 config) — still worth hardening eventually, but not part of this delta and not blocking. test_matrix (26)is red, but only ontests/batch/batch-plugin.test.ts:128(expected 13 to be less than or equal to 10, aDate.now() - starttiming assertion in a file this PR does not touch) — a timing flake unrelated to the cache feature; every cache/bun/cloudflare suite passes on node 26 in the same run.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues in this delta — the fill-hook refactor is a clean internal simplification with no behavioral change.
Reviewed changes
This run reviews the delta since the prior review at 69f8cc8 — the single commit 468cd88:
- Replaced the key-value
snapshot+writehooks with onefillhook —BaseKeyValueCacheStoredropped itsTSnapshotgeneric; subclasses now own the full miss/refresh path viafill(encodedKey, fill, options).MemoryCacheStore.fillcaptures the tag versions at the top of the method, beforeawait fill(), preserving the fill-start snapshot timing the old split had (a revalidation landing mid-fill still invalidates what the fill stores); the mid-fill race tests inmemory.test.tsstill pass.VercelCacheStore.fillmirrors its old snapshot-lesswriteexactly. - Docs/JSDoc updated to match — the store class doc now says "read entries by their encoded key and fill the missing ones", and the entry-field comment notes the tag versions "each had when the fill started".
Verified: memory (18) and middleware (21) suites pass locally; head CI is green on every node plus lint/typecheck/builds.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
🟡 Changes recommended
Cache-key collisions, malformed-tag failures, and an incomplete Redis lock-takeover fence can produce incorrect or failed requests.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
- Files reviewed: 77/78 changed files
- Comments generated: 3
- Review effort level: Balanced
There was a problem hiding this comment.
ℹ️ No new issues in this delta — the
fetch→getOrSetrename, the key-collision hardening, and the lock-takeover fix read clean, with the memory, middleware, handler-plugin, and utils suites andtype:checkpassing locally. One new operational assumption is flagged in the body below for the docs.
Reviewed changes
This run reviews the delta since the prior pullfrog review at 468cd88 — commits 36eff4f8 and 9c0d0f46:
- Renamed
CacheStore.fetchtogetOrSet— applied consistently acrosstypes.ts, the middleware, every store adapter, the@orpc/bunand@orpc/cloudflarestores, tests, and the docs page; no strayfetch/CacheFetchOptionsreferences remain (grep-verified). - Hardened
encodeCacheKeyagainst string/serialized-key collisions — serialized keys moved from an array to a{ j, m }object form, and any string shaped like a serialized key (starts{, ends}) is serialized too, so a plain string key can never share an entry with a structural key. The object form is what makes the brace heuristic a reliable discriminator. Regression tests cover brace-shaped strings and the re-encode non-fixed-point. - Kept the later fill on lock takeover —
STORE_SCRIPTnow persistsstartedAtand skips the write when a later-started fill's entry is already present, so a holder whose lock expired can no longer clobber the takeover's fresher entry. Covered by new real-server takeover tests in both the Redis and Bun suites (each would fail without the guard). - Measured Workers-store expiry from fill completion —
experimental_WorkersCacheStore.getOrSetnow resolves expiry afterawait fill(), matching the Redis/memory/Vercel stores; the new test fails under the previous pre-fill timing. - Aligned the package publishing hook —
packages/cache/package.jsonbuild→prepack, matching the other packages; closes Copilot's missing-hook nit.
ℹ️ The startedAt guard leans on cross-process clock alignment
The new STORE_SCRIPT ordering arbitrates between two concurrent fills by wall-clock startedAt, so among processes sharing one Redis the fill that started latest (by clock) is kept. Under reasonably-synced clocks this is exactly the takeover semantics intended, and because the discard is always the globally-older fill, callers are never served stale data — a dropped fill still returns its fresh output to its own caller, it just isn't persisted, and the tag-version snapshot still invalidates a store that predates a mid-fill revalidation. If one process ever runs persistently behind the others' clocks, its fills for contended keys keep getting discarded (every request recomputes until the skew resolves); that self-heals and never corrupts, but it is a new assumption for a store whose own docs emphasize cross-process operation. A sentence in the Redis adapter docs noting that the store assumes roughly-aligned process clocks would save a future operator a confusing throughput regression.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…e surrogates in tags
…mplementation-09e313 # Conflicts: # packages/bun/package.json # packages/cloudflare/package.json
There was a problem hiding this comment.
✅ No new issues found in this delta — the Redis generation fence replaces the clock-based store guard cleanly (dropping the clock-alignment assumption the prior review flagged), and the surrogate fix closes the encode-time
URIErroron unrepresentable tags. Verified against live Redis 7.0.15: the full Redis integration suite (21 tests, including both lock-takeover races) passes.
Reviewed changes
This run reviews the single commit b7d077f2 since the prior pullfrog review at 9c0d0f4:
- Fenced Redis stores with a per-key lock generation —
GET_SCRIPTnowINCRs ag:<key>counter in the same atomic step that takes theSET NX PXlock;STORE_SCRIPTwrites only while the counter still equals the value handed out, then deletes it;RELEASE_SCRIPTclears lock and generation only while they are still the caller's. A holder whose lock expired and was taken over can no longer land its store even once the takeover entry is gone, and — because the fence is a monotonic counter rather than wall-clockstartedAt— it also removes the cross-process clock-alignment assumption the previous review flagged. The dropped fill is always the globally-older one, so callers keep their own fresh output and never see stale data; the generation key resetting to 1 after delete is benign since the fence is equality-based and exactly one fill is live per key at a time. - Made
encodeCacheTagsafe on lone surrogates — an unpaired surrogate (which has no UTF-8 form) is replaced with U+FFFD before percent-encoding, soencodeURIComponentno longer throws on tags arriving through JSON, closing the codecURIErrorrisk; well-formed astral pairs still encode as their real code points. - Added regression coverage — "stores nothing from a holder that lost its lock, even once the takeover entry is gone" in both the node-redis and Bun suites (plus
g:key-cleanliness asserts), and lone-surrogate assertions inhttp.test.tsthat would throw before the fix. Docs note the U+FFFD substitution.
Verified locally against a live Redis 7.0.15: all 21 Redis integration tests pass, the shared http.test.ts (95) and cache unit suites (72) pass, and type:check is clean for @orpc/experimental-cache and @orpc/shared. The head's own CI (beyond Workers Builds) had not reported at review time.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
ℹ️ The lone-surrogate revert reads clean — the collision rationale is sound — one documentation nit below. Verified: shared
http.test.tsand the cache suites pass on this head.
Reviewed changes
This run reviews the single commit 2d6aa9aa since the prior pullfrog review at b7d077f:
- Reverted the lone-surrogate handling in
encodeCacheTag— the U+FFFD substitution, its tests, and the docs sentence are removed from@orpc/shared, restoring the pre-b7d077f2behavior where a lone-surrogate tag makesencodeURIComponentthrow. The retreat is well-founded: substituting U+FFFD would have made two distinct tags (e.g.a\uD800anda\uFFFD) encode to the same header value, silently merging them in a case-insensitive cache — an error is more honest than that collision, and no lossless UTF-8 encoding of a lone surrogate exists to fall back on. The revert is limited tohttp.ts,http.test.ts, and the docs page; no dangling references remain.
Re-ran the affected suites on this head: shared http.test.ts and the cache unit suites all pass.
ℹ️ Nitpicks
packages/shared/src/http.ts—encodeCacheTagnow throws aURIErroron lone-surrogate tags again, but its JSDoc (and the "Every non-empty tag list round-trips exactly" note ondecodeCacheTagHeader) still reads as if every string encodes cleanly. This throw can also surface from the handler-plugin header reflection and the Workers purge path after the request already committed, so a sentence in the JSDoc noting the throwing contract would save a future operator from misreading a 500 as a plugin bug. Not asking to re-fix the behavior — the revert's rationale stands.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
🟡 Changes recommended
Nested cache middleware can emit inconsistent tags and TTL metadata, and the new adapter exports lack barrel-test coverage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
packages/bun/src/index.ts:1
- The existing Bun barrel test (
packages/bun/src/index.test.ts:3-7) enumerates public exports but was not updated forBunRedisCacheStore, so this new entry point is not covered. Add it to that assertion to catch packaging/export regressions.
packages/cloudflare/src/index.ts:6 - The existing Cloudflare barrel test (
packages/cloudflare/src/index.test.ts:3-9) enumerates public exports but omitsexperimental_WorkersCacheStore, leaving this new entry point unverified. Add the store to that assertion.
- Files reviewed: 77/78 changed files
- Comments generated: 1
- Review effort level: Balanced
…he new barrel exports
There was a problem hiding this comment.
ℹ️ No critical issues in this delta — the lookup-order fix and barrel coverage read clean, with CI green on the head. One consistency nit inline.
Reviewed changes
This run reviews the delta since the prior pullfrog review at 2d6aa9aa (commits e4667ae4 and 3a729227):
- Merged upstream main —
@orpc/bun,@orpc/experimental-cache, and@orpc/cloudflarebumped to2.0.0-beta.35to match main's release; no other net changes. - Recorded stacked cache lookups in lookup order — the
cache()middleware now captures its position in the plugin context before awaitingstore.getOrSetand splices its record there, so stacked caches land outermost-first and the handler plugin reflects the outer cache's tags on misses and hits alike (previously inner-first, flip-flopping between miss and hit). The new regression test inmiddleware.test.tsfails without the fix (verified). - Covered the new barrel exports —
BunRedisCacheStore(@orpc/bun) andexperimental_WorkersCacheStore(@orpc/cloudflare) are now asserted in their packages' export tests.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| await store.revalidate({ tags }) | ||
|
|
||
| const pluginContext = (middlewareOptions.context as CacheHandlerPluginContext)[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL] | ||
| pluginContext?.revalidations.push({ |
There was a problem hiding this comment.
The revalidate middleware still appends its record after next() completes, so a stacked pair records inner-first — the same reverse-order pattern this commit just fixed for caches. The revalidations JSDoc claims "in the order they ran", and the plugin's find(isRoot) will reflect the inner revalidation's tags for a stacked pair. Mirroring the lookupIndex splice would keep the two paths consistent.
There was a problem hiding this comment.
ℹ️ No new issues in this delta — the store rename and the memory-store sweep read clean, with head CI green on all nodes.
Reviewed changes
This run covers the delta since the prior pullfrog review at 3a729227: a1dd3785 and 59beee06.
- Renamed
BunRedisCacheStoretoexperimental_BunRedisCacheStore— applied consistently across the@orpc/bunexport, its integration tests, the cross-adapter compatibility suite, the barrel test, and the docs page. No dangling references remain, and theexperimental_prefix matches the convention already used forexperimental_WorkersCacheStore. - Swept expired and revalidated entries from the memory store on writes —
MemoryCacheStoretracks the earliest pending eviction innextSweepAtand runs a full-map sweep on the nextfillonce that time (or arevalidate) has passed, re-arming the schedule from the surviving entries. The read path keeps its prior eager eviction, so the sweep is pure garbage collection and can never serve stale data. The new regression test fails against the pre-change store (verified locally), and thenextSweepAtbookkeeping checks out under both the expiring and revalidated scenarios.
The only open thread from the prior review (the revalidate record-order consistency nit at middleware.ts:136) is untouched by this window and remains so.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found in the BentoCache delta. This review is non-approving only because the single open Pullfrog thread on the
revalidaterecord order (middleware.ts:136) remains open — untouched by this commit and not re-raised here.
Reviewed changes
This run reviews the delta since the prior pullfrog review at 59beee06 — the single commit 6ee2f55e:
- Added
BentoCacheStore(packages/cache/src/adapters/bentocache.ts, subpath@orpc/experimental-cache/bentocache) — drives any BentoCache setup through its owngetOrSet/getOrSetForeverunder BentoCache's lock, mapsttl/swrto BentoCache's TTL and grace period (milliseconds), serves stale entries within grace while BentoCache refreshes them in the background, and hands a failed refresh towaitUntil— without a waiter it surfaces as an unhandled rejection, matching the documented SWR contract across stores. - Mapped revalidation to
deleteByTag— tags delete natively, so withswra revalidated entry is served once more while a refresh runs; a mid-fill revalidation is documented as undetected. Fill errors are unwrapped viaE_FACTORY_ERROR, with non-backgroundonFactoryErrorinvocations guarded out so a sync miss failure isn't double-surfaced. - Wired the adapter in — optional peer dependency + dev dependency + lockfile for
bentocache@^1.6.1, subpath export entry, a new docs page section with a setup example, and the README package-table/description sync across the monorepo. - Adjusted the shared store contract and the memory suite — the contract's
keeps entries filled after a revalidationnowsleep(1)before refilling (BentoCache's tag invalidation is millisecond-granular), andmemory.test.tsfakes onlyDateso that sharedsleepisn't blocked by fully faked timers.
I verified the load-bearing BentoCache API assumptions against the installed bentocache@1.6.1 type declarations (grace/ttl/onFactoryError accepted by both getOrSet and getOrSetForever; FactoryError.cause + isBackgroundFactory; E_FACTORY_ERROR; deleteByTag) and ran the local suite: bentocache (16) and memory (19) pass, the full cache unit run (90) passes, and pnpm --filter @orpc/experimental-cache type:check is clean.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found in the TieredCacheStore delta. Non-approving for the same reason as the prior review: the single open Pullfrog thread on the
revalidaterecord order (middleware.ts:136) remains open.
Reviewed changes
This run reviews the delta since 6ee2f55 — the single commit 84268ab6:
- Added
TieredCacheStore(exported from the@orpc/experimental-cachebarrel) — layers two or more stores front to back; a miss in any tier fills from the next via a recursivegetOrSetFrom, so only the last tier runs the user's fill, andrevalidatefans out to every tier throughPromise.all. Each tier receives the shared options withttl/swrcapped against the tier's caps (Math.min, with anundefinedoption falling back to the tier cap), so a front tier's copy expires before the entry behind it can change. - Documented the tier semantics — the docs page gains a "Tiered Store" section (anchor
#tiered-store, matching the class JSDoc@see), covering a miss filling from the next tier, revalidation reaching every tier, and the "measurettlfrom when it stored its copy — cap it on front tiers" caveat. The barrel export test now assertsTieredCacheStore. - Tested with teeth — a shared store-contract run against two memory tiers, plus tier-specific tests: a front miss fills from a pre-populated back tier without invoking the user's fill; per-tier
ttl/swrcaps are honored on both capped and uncapped requests (expiry asserted under a frozen clock);waitUntilis handed out by every stale tier (two refresh promises for one two-tier stale request, with the front's refresh copying the back's stale value while the back's refresh runs the real fill); andrevalidatereaches all tiers.
I traced the layered-SWR flows (stale front + stale back, stale front + fresh back, hard-miss front + stale back) and verified the recursion, the cap fallback rules, and the next returns-.output-only delegation all behave as documented and as the assertions describe; the local suite passes (tiered 11, full cache unit run 101) and pnpm --filter @orpc/experimental-cache type:check is clean. The one prior thread (middleware.ts:136) is untouched by this commit.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Adds
@orpc/experimental-cache: tag-based caching and revalidation for procedure output, with stale-while-revalidate, aCacheStorecontract with memory, Redis, Upstash, Bun, Vercel, and BentoCache adapters plus a purge-only Cloudflare Workers Caching store, and a handler plugin that reflects cache tags into response headers for client-side revalidation or response caches in front. Concurrent callers of one key run the procedure once.Resolves #1262
Features
cache()middleware: a hit returns the cached output without running the handler; a miss runs it once, even across concurrent callers, and stores the result.keydefaults to the procedure path and input, canonically encoded so structurally equal keys share an entry;key,tags,ttl,swr, andenabledaccept static values or functions of the middleware options and input, andenabled: falseskips the store and the other resolvers.ttlbut withinswr, stale output is served at once while one caller refreshes in the background.cache/waitUntilhands the refresh to runtimes that stop pending work after the response; without it, refresh failures surface as unhandled rejections rather than being swallowed.revalidate({ tags })middleware invalidates tags after a successful mutation; tags are non-empty at compile time, and resolving tonullorundefinedskips it.CacheHandlerPluginsets only the headers listed:orpc-cache-tagandorpc-cache-tag-invalidationfor clients,cache-controlandcache-tagfor caches in front.cache-controlusesmax-agebecauses-maxagecarriesproxy-revalidatesemantics that forbid stale reuse. Only the root procedure's activity is reflected, only on successful responses, and tags are percent-encoded so case-insensitive caches cannot collide distinct tags.getOrSet(key, fill, options)andrevalidate({ tags }). The Redis, Upstash, and Bun stores keep entries as Redis hashes driven by Lua scripts: one round trip per hit, two per miss, with the lock taken in the same script that reads the entry andlockTtlfreeing waiters if a holder dies. They shareBaseRedisCacheStoreand one entry format, so any two can serve the same database. The memory and Vercel stores shareBaseKeyValueCacheStoreand coalesce within the process. The BentoCache store drives BentoCache's owngetOrSet, mappingswrto its grace period and tags todeleteByTag.TieredCacheStorelayers stores front to back, so a miss in one tier fills from the next and only the last runs the procedure, with per-tierttlandswrcaps. Every store carries oneRPCJsonSerializerfor keys and outputs.Shared
@orpc/sharedgainsnowInSeconds,deepSortKeys, and the cache tag header codec;@orpc/bunand@orpc/cloudflaregain their stores.Testing
@orpc/experimental-cache, with a store contract shared by every adapter, env-gated Redis and Upstash integration suites, a Bun suite, cross-adapter compatibility suites proving Redis, Upstash, and Bun share entries, counters, retention, and locks, workerd tests for the Workers store, and race and lock-takeover tests.Docs
docs/helpers/cachepage with per-adapter sections; API reference and package lists updated.