feat(cache): add tag-based caching and revalidation helpers - #1964
feat(cache): add tag-based caching and revalidation helpers#1964dinwwwh wants to merge 38 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 | 26f97f9 | Commit Preview URL Branch Preview URL |
Sep 08 2026, 02:17 PM |
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 12.9%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | octet stream |
645.9 µs | 741.6 µs | -12.9% |
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 (26f97f9) with main (03ac086)
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) | 𝕏
`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
- Redis and Upstash stores share one Upstash database over rediss:// and REST - Bun and Redis stores share REDIS_URL - race tests wait for the held read to complete before the racing revalidation
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) | 𝕏
| 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.
…/waitUntil Without a runtime to hand the refresh to, the request now completes it before returning the stale output, so it cannot be dropped when the process or invocation ends. Failed refreshes still serve the stale entry.
…l only The refresh is started regardless and handed to cache/waitUntil when one is provided; without it nothing owns the refresh, so a failed one surfaces as an unhandled rejection rather than being awaited or swallowed.
There was a problem hiding this comment.
Important
This delta removes the fallback .catch from the stale-while-revalidate refresh path, so on the common Node setup (no cache/waitUntil wired) a failed background refresh now becomes an unhandled rejection — which terminates the process by default. Prior reviews' IMPORTANT threads on revalidate fail-loud and un-storable outputs stored as {} also remain open.
Reviewed changes
This run reviews the PR-owned delta since the prior pullfrog review at 8a12845b:
- Removed
experimental_KVCacheStorefrom@orpc/cloudflare— breaking; only the purge-onlyWorkersCacheStoreremains. Clean: no dangling references in code, exports,tsconfig.json, README, wrangler KV binding, or the docs page. - Reworked SWR refresh ownership — the middleware now hands the refresh to
cache/waitUntilunconditionally with no fallback catch, so without a waiter a refresh failure surfaces as an unhandled rejection. - Updated the handler-plugin
headersJSDoc to match current behavior (any method, overriding), and dropped the now-orphaned "overrides existing headers" test, leaving thepresetHeadersPlugin/presettest scaffolding unused. - Hardened the shared
holdResultrace-test helper with areadpromise so the racing-revalidation tests sequence deterministically across Memory/Redis/Upstash/Bun. - Expanded cross-adapter compatibility suites (tag counters, retention staging) and gated the Upstash integration suite off Node 26 with a TODO.
- Synced the packages table across every package README (markdown-table restyle).
Inline feedback anchors on the SWR refresh change. The two prior IMPORTANT threads (revalidate fail-loud; un-storable outputs persisted and served as {}) are unchanged by this delta and remain to address. Two nits are inline.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| const refresh = Promise.resolve(middlewareOptions.next()) | ||
| .then(result => store.set(key, result.output, { tags, ttl, swr })) | ||
|
|
||
| middlewareOptions.context['cache/waitUntil']?.(refresh) |
There was a problem hiding this comment.
Without cache/waitUntil in the context — the default on runtimes other than Workers, since nothing auto-wires it — a failed stale-while-revalidate refresh is now an unhandled rejection, and Node terminates the process on an unhandled rejection by default. The previous delta deliberately .catch(() => {})d the refresh when nothing owned it; this delta drops that fallback. The new 'leaves refresh failures unhandled without waitUntil' test asserts the new contract, but outside the test runner a transient origin error during a background refresh would take the whole server down.
| } | ||
| } | ||
|
|
||
| function createTestingHandler(headers?: readonly CacheHandlerPluginHeader[], preset?: StandardHeaders) { |
There was a problem hiding this comment.
Removing the "sets its headers over existing ones" test left presetHeadersPlugin and the preset parameter of createTestingHandler with no callers, yet the docs/headers JSDoc still claim the plugin sets headers over anything already on the response — a documented behavior now without test coverage. Either restore the override test or drop the dead preset scaffolding.
CacheStore gains an optional lock(key, fn) that runs fn for one caller at a time per key, reporting whether the caller waited. The middleware fills a miss and refreshes a stale entry under it, re-reading only after waiting so hits never read twice. Redis, Upstash, and Bun hold the lock in Redis with SET NX and a token-checked release; memory and Vercel hold it in process.
|
Pullfrog stalled The agent stopped emitting events for 125s and was killed by the activity-timeout watchdog. The model produced no output at all before the stall — the request was sent but nothing came back. This is usually transient; re-running often succeeds.
|
There was a problem hiding this comment.
ℹ️ No new issues in this delta — the per-key locking rework and the
MemoryLockrelocation to@orpc/sharedreview clean.
Reviewed changes
This run covers the delta since the prior pullfrog review at 1c711593: commits 082d46f7 (per-key locks) and 685f61fa (MemoryLock → @orpc/shared).
- Added
CacheStore.lock(key, fn)— an optional per-key lock on the store contract; thecache()middleware fills a miss and refreshes a stale entry under it, re-readingstore.get(key)after waiting so concurrent callers fill/refresh once instead of once each (middleware.ts). - Implemented
lockin the Redis-family adapters — aSET NXlease with PXlockTtland a token-checkedEVALrelease inRedisCacheStore,UpstashCacheStore, andBunRedisCacheStore, so locks span processes; a crashed holder's lease expires and a taken-over lock is released only by its own token. Covered by mocked, integration, and cross-adapter (Redis↔Upstash↔Bun) tests. - Implemented
lockin-process viaMemoryLockforMemoryCacheStoreandVercelCacheStore, handing the turn on after failures. - Moved
MemoryLockto@orpc/shared(lock.ts, exported with tests) — clean move, no dangling references, and@orpc/experimental-cachealready depends on@orpc/shared. - Docs updated — lock semantics,
lockTtl, and "one fill / one refresh under lock" claims on the cache page.
I verified the highest-risk spots rather than taking them on faith: lock re-entry through serve() inside the miss-path lock does not deadlock on either lock kind (in-process callers queue behind the running callback; the Redis release fires as soon as serve returns, since the refresh isn't awaited), the waited re-read refuses a still-stale entry to the refresh path, and each open thread's code is untouched by this delta.
Carried forward from prior reviews and left open (unchanged by this delta): the revalidate fail-loud path, un-storable outputs persisted and served as {}, the SWR refresh unhandled-rejection default without cache/waitUntil, and the Upstash empty-tags / dead-preset scaffolding nits.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…mplementation-09e313 # Conflicts: # README.md # apps/content/docs/api-reference.mdx # packages/ai-sdk/README.md # packages/arktype/README.md # packages/bun/README.md # packages/bun/package.json # packages/client/README.md # packages/cloudflare/README.md # packages/cloudflare/package.json # packages/cloudflare/src/index.ts # packages/contract/README.md # packages/effect/README.md # packages/evlog/README.md # packages/hibernation/README.md # packages/json-schema/README.md # packages/msw/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/shared/src/index.ts # packages/swr/README.md # packages/tanstack-query/README.md # packages/trpc/README.md # packages/valibot/README.md # packages/zod/README.md
CacheStore is now fetch(key, fill, options) plus revalidate. Stores own coalescing, so concurrent callers of one key fill once and stale entries refresh once, with the refresh handed to options.waitUntil. The Redis, Upstash, and Bun stores keep entries as hashes and run two Lua scripts by sha: one reads the entry, validates its tags, and takes the lock in the same step when it is missing or stale; the other stores the entry with its tag versions snapshotted and releases the lock. A hit is one round trip and a miss two, down from two and five.
…Lock Stores with a native primitive keep their built-in lock. The Runtime Cache has none, so VercelCacheStore accepts a Lock from @orpc/shared and holds it within the process by default.
…o MemoryLock" This reverts commit cb97a43.
There was a problem hiding this comment.
Caution
The fetch-fold rewrite (e9f5f591) mis-decodes the cache-miss sentinel on RESP3 clients — the default for the pinned redis@^6.2.1 client and for Bun's built-in client — so every cache miss is treated as a hit and the first request for each key is answered with an empty output. The head's CI is red on the new cross-adapter cache suite, and the PR description's "full suite passes" claim no longer holds.
Reviewed changes
- Folded
get/set/lockintoCacheStore.fetch(key, fill, options)— stores now own entry coalescing and stale-refresh; a stale entry is served while one caller refreshes it in the background; the middleware became a thin passthrough. - Rewrote the Redis-family stores as prefix-scoped hashes driven by two EVALSHA Lua scripts — the read script validates tag versions and takes the
SET NX PXlock in one step; the store script snapshots the versions, writes the entry withPEXPIRE, and releases the lock by token, with per-store script-sha caching and NOSCRIPT reload. - Moved the SWR refresh-failure contract into
CacheFetchOptions.waitUntil— the option documents that without a waiter, failures surface as unhandled rejections, and the middleware test now asserts that contract. - Simplified the handler-plugin check recording — the middleware derives the reflected
ttlfromentry.expiresAtinstead of carrying per-path refresh logic. - Merged upstream main — README package tables and description renames across every package README.
🚨 The head's CI is red
Run 34232486693 on e9f5f591 fails every test_matrix job in the new cache redis adapters compatibility suite, and lint_and_typecheck cannot resolve @standardserver/core from packages/cache/src/handler-plugin.ts. The PR description's "Full monorepo suite, type-check, lint, and docs validation pass" no longer holds for this head and should be restored before merging. The test failures — TypeError: null is not an object while destructuring the EVALSHA reply, a stale entry served back as its refreshed value, 20 s timeouts, and ERR_REDIS_CONNECTION_CLOSED — all sit in the newly scripted fetch path and corroborate the inline finding at packages/cache/src/adapters/redis.ts:158.
Technical details
# Head CI is red (run 34232486693, head e9f5f591)
## Evidence
- test_matrix (node 20/22/24/26): `packages/bun` → `tests/cache-redis-adapters-compatibility.test.ts` — all eight cross-adapter scenarios fail in both directions (redis → bun redis and bun redis → redis). node 22 first error: `TypeError: null is not an object (evaluating '[output, tags, expiresAt, shouldFill]')` at `redis-cache.ts:152`; node 20/22: `Expected: "v" Received: "refilled"` (the stale-serve assertion in the "shares retention" scenario); node 24/26: 20 s timeouts plus `ERR_REDIS_CONNECTION_CLOSED` in the harness.
- The root-vitest `redis-adapters-compatibility.test.ts` is skipped in CI: it requires `UPSTASH_REDIS_REST_URL`/`_TOKEN` (`stores.length < 2` guard) while CI only exports `REDIS_URL`, so the node-redis `RedisCacheStore` has no CI-enforced real-client coverage.
- lint_and_typecheck: `packages/cache/src/handler-plugin.ts(3,38): error TS2307 Cannot find module '@standardserver/core'`. `packages/cache` declares `@standardserver/core@^0.8.2` (resolved 0.8.3 in the lockfile); the runner logged a "pnpm v10 installation layout" warning, so this could be a toolchain artifact rather than a packaging gap — not yet attributed.
## Required outcome
- The cross-adapter cache suite must pass on the head before the rewrite merges; re-run the full suite after fixing the RESP3 sentinel decoding (inline comment at `redis.ts:158`).
- Re-attribute the `@standardserver/core` lint failure on a clean install; if it reproduces, the new package's dependency resolution is incomplete.
- Consider dropping the Upstash-credential gate so the node-redis store gets real-client CI coverage.DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| [token, String(this.lockTtl * 1000), this.tagPrefix, String(nowInSeconds())], | ||
| ) as [string | null, string | null, string | null, number | null] | ||
|
|
||
| if (output !== null) { |
There was a problem hiding this comment.
On a miss the script's reply leads with the Lua false sentinel (return { output or false, ... } at line 40), and this output !== null guard only decodes it correctly under RESP2, where Lua false becomes a null bulk reply. The pinned redis@^6.2.1 client (@redis/client@6.2.1) enables RESP3 by default (DEFAULT_RESP = 3, handshake HELLO 3) and its decoder maps a RESP3 boolean back to JS false — so every miss is decoded as a hit, the store returns { output: undefined, tags: false, expiresAt: 0 }, and the middleware answers the request with an empty output. Redis v4/v5 (RESP2 default) and Upstash (RESP2-style REST) happen to be safe, which is why the defect is client-version-dependent.
Technical details
# `fetch` mis-decodes the miss sentinel under RESP3 clients
## Affected sites
- packages/cache/src/adapters/redis.ts:40 and :158 — `return { output or false, ... }` + `if (output !== null)`
- packages/bun/src/redis-cache.ts:40 and :158 — same
- packages/cache/src/adapters/upstash.ts:40 and :168 — same pattern; Upstash's REST gateway replies RESP2-style, so nil decodes to `null` and it is unaffected
- packages/cache/src/middleware.ts:82-97 — `done({ output: entry.output })` returns the corrupted entry on the miss path
## Root cause
Lua `false` inside a returned array becomes a RESP2 null-bulk but a RESP3 boolean. `redis@6.2.1` / `@redis/client@6.2.1` (resolved in pnpm-lock.yaml, pinned in the cache and bun package.json devDependencies) ships `DEFAULT_RESP = 3` (`dist/lib/RESP/types.js`), handshakes `HELLO 3` unless `RESP: 2` (`dist/lib/client/index.js`), and its RESP3 decoder returns a JS boolean for a boolean reply (`dist/lib/RESP/decoder.js` `#decodeBoolean`) — consistent with Redis's Lua→RESP3 conversion docs. Bun's built-in Redis client also negotiates RESP3. Under RESP3 a miss decodes to `[false, false, false, …]`, so `output !== null` is true, the entry built is `{ output: deserialize(JSON.parse(false).body) → undefined, tags: JSON.parse(false) → false, expiresAt: Number(false) → 0 }`, and the middleware responds with empty output. The fill still runs detached (no `waitUntil` on plain Node), so a later request finds the real entry and the failure looks transient.
## Required outcome
- A miss must never decode as a hit on any supported client/RESP version. Stop using Lua booleans as miss markers (return explicit `''`/integer markers), or normalize the reply on the JS side for all three stores (e.g. `typeof output === 'string'`).
- Add a CI-enforced real-client test of a miss-then-fill against the node-redis v6 client (and ideally the Bun client) so the reply shape cannot regress again; the current integration suites only run when env-gated credentials are present.
## Evidence
- CI run 34232486693 on head e9f5f591: test_matrix fails in `packages/bun` `cache redis adapters compatibility` in both directions — `TypeError: null is not an object (evaluating '[output, tags, expiresAt, shouldFill]')` at `redis-cache.ts:152`, `Expected: "v" Received: "refilled"` stale-serve failures, 20 s timeouts, and `ERR_REDIS_CONNECTION_CLOSED`. The single-store bun integration suite passes, so the shared script reply shape is the common defect.| [token, String(this.lockTtl * 1000), this.tagPrefix, String(nowInSeconds())], | ||
| ) as [string | null, string | null, string | null, number | null] | ||
|
|
||
| if (output !== null) { |
There was a problem hiding this comment.
Same guard and sentinel as redis.ts:158 — see that comment for the full analysis and fix. Bun's Redis client also negotiates RESP3, and this head's own CI is failing exactly here: TypeError: null is not an object (evaluating '[output, tags, expiresAt, shouldFill]') at redis-cache.ts:152 in the cross-adapter suite, followed by the stale-serve failures and timeouts.
The scripts, the fetch loop, and the store step live once in an abstract base; the Redis, Upstash, and Bun stores only run a script through their client. Revalidation is one script too.
There was a problem hiding this comment.
Important
The BaseRedisCacheStore extraction reads clean, but it consolidates the flagged miss-decode into a shared base the three Redis-family adapters now all depend on without fixing it, and the head's CI is still red on the cross-adapter cache suite plus the @standardserver/core type error. The two prior inline threads on the output !== null guard remain open.
Reviewed changes
This run reviews the delta since the prior pullfrog review at e9f5f591: cb97a43a + 10d3af0d (Vercel-lock feature added then reverted — net zero) and 26f97f95, the head commit.
BaseRedisCacheStoreextraction — the two Lua scripts, thefetchloop, thestorestep, andrevalidatenow live once in an abstract base inpackages/cache/src/adapters/redis.ts;RedisCacheStore,UpstashCacheStore, andBunRedisCacheStoreonly run a script through their client (EVALSHAwith per-script sha caching for node-redis/bun,createScriptfor Upstash).parseReplynormalization — replies now take the same path whether the client returns raw strings (node-redis, bun) or pre-parsed values (Upstash); the fetch entry is decoded fromparseReply(output).body.REVALIDATE_SCRIPT— tag revalidation is a singleINCRloop script shared by all three stores, replacing the per-storeincr/multipaths.- NOSCRIPT recursion guard — the reload path now takes a
reloadedflag so a persistent server-side script eviction cannot recurse forever (an improvement over the previous retry). - Test/doc churn — the bun integration suite drops its
afterAllclient close ("breaks the next file's client on Bun 1.4") and sets per-test timeouts; the cross-adapter suite hoists client setup to module scope with a combinedafterAll; the cache docs note the shared base.
⚠️ The refactor preserves the open miss-decode blocker and head CI is still red
The extraction keeps the exact if (output !== null) guard (packages/cache/src/adapters/redis.ts:164) and the return { output or false, ... } sentinel (:40) that the prior review flagged as mis-decoding every miss as a hit on RESP3 clients — it moved them into the base rather than fixing them, which widens the blast radius to all three adapters instead of narrowing it.
Head CI on 26f97f95 (run 34237043815) confirms the blocker is live: test_matrix fails on node 20/22/24/26 in packages/bun/tests/cache-redis-adapters-compatibility.test.ts (20 s timeouts on entries/tag-counters/locks, stale-serve Expected "v" Received "refilled" on retention, ERR_REDIS_CONNECTION_CLOSED), and lint_and_typecheck still fails packages/cache/src/handler-plugin.ts(3,38) TS2307 Cannot find module '@standardserver/core'. The PR description's "full suite passes" claim still does not hold.
Technical details
# Base extraction is clean but the RESP3 miss-decode is unaddressed
## Affected sites
- packages/cache/src/adapters/redis.ts:40 and :164 (base) — `false` miss sentinel + `output !== null` guard; unchanged from the flagged e9f5f591 version, now shared by Redis/Upstash/Bun
- packages/bun/src/redis-cache.ts — thin `run` override inherits the base decode
- packages/cache/src/handler-plugin.ts:3 — `@standardserver/core` (no hyphen, deprecated) vs `@standard-server/core` used elsewhere; `StandardHeaders` is re-exported by `@orpc/server` (packages/server/src/index.ts:130), so the import source could change
## Required outcome
- Fix the miss sentinel so a miss never decodes as a hit on any client/RESP version (stop using Lua booleans as miss markers, or normalize in JS, e.g. `typeof output === 'string'`), then get the cross-adapter suite green.
- Resolve the `@standardserver/core` import/dependency so `type:check` passes.
## Open questions for the human
- Is `@standardserver/core@0.8.x` intentionally pinned (legacy deprecated name), or should the import come from `@orpc/server` / `@standard-server/core`?DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

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.