@abide/abide 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/AGENTS.md +370 -320
  2. package/CHANGELOG.md +94 -0
  3. package/README.md +203 -168
  4. package/package.json +12 -8
  5. package/src/abideLsp.ts +11 -12
  6. package/src/abideResolverPlugin.ts +9 -4
  7. package/src/lib/bundle/disconnected.abide +3 -0
  8. package/src/lib/cli/printCommandHelp.ts +43 -0
  9. package/src/lib/cli/printSessionHelp.ts +2 -1
  10. package/src/lib/cli/{printHelp.ts → printTopLevelHelp.ts} +3 -40
  11. package/src/lib/cli/runCli.ts +2 -1
  12. package/src/lib/mcp/buildPrompts.ts +25 -0
  13. package/src/lib/mcp/dispatchMcpRequest.ts +8 -6
  14. package/src/lib/mcp/mcpResourceServerSlot.ts +5 -10
  15. package/src/lib/mcp/mcpSurface.ts +13 -252
  16. package/src/lib/mcp/mcpTools.ts +187 -0
  17. package/src/lib/mcp/renderPrompt.ts +25 -0
  18. package/src/lib/mcp/types/McpSurface.ts +15 -0
  19. package/src/lib/mcp/types/PromptDescriptor.ts +5 -0
  20. package/src/lib/mcp/types/PromptMessage.ts +2 -0
  21. package/src/lib/mcp/types/ToolDescriptor.ts +7 -0
  22. package/src/lib/mcp/types/ToolResult.ts +2 -0
  23. package/src/lib/server/agent.ts +1 -1
  24. package/src/lib/server/jsonl.ts +2 -2
  25. package/src/lib/server/rpc/defineRpc.ts +5 -0
  26. package/src/lib/server/runtime/buildCacheSnapshot.ts +6 -6
  27. package/src/lib/server/runtime/containsTraversal.ts +21 -17
  28. package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
  29. package/src/lib/server/runtime/createServer.ts +101 -71
  30. package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
  31. package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
  32. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
  33. package/src/lib/server/runtime/finalizeResponse.ts +32 -0
  34. package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
  35. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  36. package/src/lib/server/runtime/types/InspectorCacheSnapshot.ts +4 -4
  37. package/src/lib/server/runtime/types/InspectorContext.ts +1 -1
  38. package/src/lib/server/socket.ts +1 -1
  39. package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
  40. package/src/lib/server/sockets/defineSocket.ts +26 -7
  41. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
  42. package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
  43. package/src/lib/server/sse.ts +2 -2
  44. package/src/lib/shared/DEFER.ts +8 -0
  45. package/src/lib/shared/activeCacheStore.ts +2 -2
  46. package/src/lib/shared/activePage.ts +2 -2
  47. package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
  48. package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
  49. package/src/lib/shared/basePath.ts +2 -2
  50. package/src/lib/shared/baseSlot.ts +6 -5
  51. package/src/lib/shared/bodyValueForKind.ts +1 -2
  52. package/src/lib/shared/buildSocketOverChannel.ts +40 -4
  53. package/src/lib/shared/cache.ts +522 -117
  54. package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
  55. package/src/lib/shared/cacheStoreSlot.ts +9 -5
  56. package/src/lib/shared/cacheStores.ts +3 -3
  57. package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
  58. package/src/lib/shared/createRemoteFunction.ts +58 -9
  59. package/src/lib/shared/createResolverSlot.ts +24 -31
  60. package/src/lib/shared/createSocketSubRegistry.ts +2 -2
  61. package/src/lib/shared/decodeRefJson.ts +4 -2
  62. package/src/lib/shared/decodeResponse.ts +8 -6
  63. package/src/lib/shared/done.ts +14 -0
  64. package/src/lib/shared/encodeRefJson.ts +4 -2
  65. package/src/lib/shared/hydratingSlot.ts +12 -0
  66. package/src/lib/shared/isLayoutFile.ts +12 -0
  67. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  68. package/src/lib/shared/keyPrefixForRemote.ts +12 -0
  69. package/src/lib/shared/matchRoute.ts +175 -0
  70. package/src/lib/shared/normalizePathname.ts +11 -0
  71. package/src/lib/shared/pageSlot.ts +14 -5
  72. package/src/lib/shared/pageUrlForFile.ts +3 -3
  73. package/src/lib/shared/parseRouteSegments.ts +16 -7
  74. package/src/lib/shared/patch.ts +41 -0
  75. package/src/lib/shared/peek.ts +35 -0
  76. package/src/lib/shared/prepareRemoteExport.ts +40 -0
  77. package/src/lib/shared/prepareRpcModule.ts +98 -16
  78. package/src/lib/shared/prepareSocketModule.ts +11 -15
  79. package/src/lib/shared/queryStringFromArgs.ts +11 -0
  80. package/src/lib/shared/reachable.ts +102 -0
  81. package/src/lib/shared/refresh.ts +22 -0
  82. package/src/lib/shared/requestScopeSlot.ts +10 -7
  83. package/src/lib/shared/routeParamsShape.ts +9 -9
  84. package/src/lib/shared/rpcErrorRegistry.ts +69 -0
  85. package/src/lib/shared/selectorPrefix.ts +3 -10
  86. package/src/lib/shared/setOwnProperty.ts +19 -0
  87. package/src/lib/shared/sharedCacheStore.ts +14 -0
  88. package/src/lib/shared/sharedCacheStoreSlot.ts +10 -0
  89. package/src/lib/shared/snippet.ts +1 -1
  90. package/src/lib/shared/subscribableProbes.ts +111 -0
  91. package/src/lib/shared/tailProbeSlot.ts +8 -1
  92. package/src/lib/shared/types/CacheEntry.ts +9 -15
  93. package/src/lib/shared/types/CacheOptions.ts +23 -11
  94. package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
  95. package/src/lib/shared/types/CacheStats.ts +1 -1
  96. package/src/lib/shared/types/RemoteCallable.ts +25 -7
  97. package/src/lib/shared/types/RemoteFunction.ts +48 -13
  98. package/src/lib/shared/types/ResolverSlot.ts +7 -4
  99. package/src/lib/shared/types/RpcError.ts +26 -0
  100. package/src/lib/shared/types/SmartReadOptions.ts +36 -0
  101. package/src/lib/shared/types/Socket.ts +54 -0
  102. package/src/lib/shared/types/SsrBootState.ts +27 -0
  103. package/src/lib/shared/types/SsrPayload.ts +21 -0
  104. package/src/lib/shared/types/Subscribable.ts +13 -13
  105. package/src/lib/shared/types/TailHooks.ts +2 -1
  106. package/src/lib/shared/url.ts +29 -14
  107. package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
  108. package/src/lib/shared/writeTestSocketsDts.ts +1 -1
  109. package/src/lib/test/createScriptedSurface.ts +1 -1
  110. package/src/lib/test/createTestApp.ts +8 -8
  111. package/src/lib/test/createTestSocketChannel.ts +3 -3
  112. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
  113. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
  114. package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
  115. package/src/lib/ui/compile/analyzeComponent.ts +28 -6
  116. package/src/lib/ui/compile/compileModule.ts +137 -11
  117. package/src/lib/ui/compile/compileShadow.ts +101 -84
  118. package/src/lib/ui/compile/desugarSignals.ts +120 -107
  119. package/src/lib/ui/compile/generateBuild.ts +48 -16
  120. package/src/lib/ui/compile/generateSSR.ts +117 -14
  121. package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
  122. package/src/lib/ui/compile/lowerContext.ts +10 -4
  123. package/src/lib/ui/compile/lowerScript.ts +72 -5
  124. package/src/lib/ui/compile/parseTemplate.ts +1 -14
  125. package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
  126. package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
  127. package/src/lib/ui/compile/signalCallee.ts +24 -0
  128. package/src/lib/ui/compile/stripEffects.ts +14 -11
  129. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  130. package/src/lib/ui/compile/types/TemplateNode.ts +0 -5
  131. package/src/lib/ui/dom/appendStatic.ts +9 -1
  132. package/src/lib/ui/dom/appendText.ts +15 -3
  133. package/src/lib/ui/dom/assertClaimedText.ts +20 -0
  134. package/src/lib/ui/dom/awaitBlock.ts +19 -83
  135. package/src/lib/ui/dom/bindSelectValue.ts +48 -0
  136. package/src/lib/ui/dom/each.ts +12 -1
  137. package/src/lib/ui/dom/hydrate.ts +10 -0
  138. package/src/lib/ui/dom/mountChild.ts +2 -5
  139. package/src/lib/ui/dom/mountRange.ts +0 -75
  140. package/src/lib/ui/effect.ts +4 -3
  141. package/src/lib/ui/installHotBridge.ts +4 -2
  142. package/src/lib/ui/remoteProxy.ts +18 -2
  143. package/src/lib/ui/renderToStream.ts +9 -13
  144. package/src/lib/ui/resumeSeedScript.ts +2 -2
  145. package/src/lib/ui/router.ts +21 -2
  146. package/src/lib/ui/runtime/RESUME.ts +0 -6
  147. package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
  148. package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
  149. package/src/lib/ui/scope.ts +6 -3
  150. package/src/lib/ui/seedBootState.ts +53 -0
  151. package/src/lib/ui/socketChannel.ts +2 -2
  152. package/src/lib/ui/socketProxy.ts +9 -3
  153. package/src/lib/ui/startClient.ts +17 -31
  154. package/src/lib/ui/state.ts +44 -13
  155. package/src/lib/ui/sync.ts +11 -5
  156. package/src/lib/ui/tryEncodeResume.ts +2 -5
  157. package/src/lib/ui/types/Scope.ts +14 -10
  158. package/src/lib/ui/watch.ts +140 -0
  159. package/src/serverEntry.ts +13 -11
  160. package/template/CLAUDE.md +1 -1
  161. package/template/package.json +1 -1
  162. package/template/src/server/rpc/getHello.ts +3 -3
  163. package/template/src/ui/pages/page.abide +2 -2
  164. package/template/test/app.test.ts +1 -1
  165. package/src/lib/server/reachable.ts +0 -45
  166. package/src/lib/server/sockets/types/Socket.ts +0 -23
  167. package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
  168. package/src/lib/shared/DEFER_MIN_ARRAY_LENGTH.ts +0 -14
  169. package/src/lib/shared/baseResolver.ts +0 -10
  170. package/src/lib/shared/cacheKeyOf.ts +0 -7
  171. package/src/lib/shared/cacheKeyStore.ts +0 -8
  172. package/src/lib/shared/cacheStoreResolver.ts +0 -12
  173. package/src/lib/shared/globalCacheStore.ts +0 -15
  174. package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
  175. package/src/lib/shared/globalCacheStoreSlot.ts +0 -9
  176. package/src/lib/shared/pageResolver.ts +0 -16
  177. package/src/lib/shared/recordCacheKey.ts +0 -6
  178. package/src/lib/shared/requestScopeResolver.ts +0 -12
  179. package/src/lib/shared/setBaseResolver.ts +0 -4
  180. package/src/lib/shared/setCacheStoreResolver.ts +0 -4
  181. package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
  182. package/src/lib/shared/setPageResolver.ts +0 -4
  183. package/src/lib/shared/setRequestScopeResolver.ts +0 -4
  184. package/src/lib/shared/toBunRoutePattern.ts +0 -28
  185. package/src/lib/shared/types/TailOptions.ts +0 -10
  186. package/src/lib/ui/deferResume.ts +0 -36
  187. package/src/lib/ui/dom/firstElementBetween.ts +0 -14
  188. package/src/lib/ui/matchRoute.ts +0 -106
  189. package/src/lib/ui/runtime/scheduleWake.ts +0 -28
  190. package/src/lib/ui/runtime/whenIdle.ts +0 -21
  191. package/src/lib/ui/runtime/whenVisible.ts +0 -105
  192. package/src/lib/ui/tail.ts +0 -324
  193. /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
  194. /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
@@ -1,21 +1,22 @@
1
1
  import { abideLog } from './abideLog.ts'
2
2
  import { activeCacheStore } from './activeCacheStore.ts'
3
- import { CACHE_WRAPPED } from './CACHE_WRAPPED.ts'
4
3
  import { cacheStores } from './cacheStores.ts'
5
4
  import { decodeResponse } from './decodeResponse.ts'
6
5
  import { getRemoteMeta } from './getRemoteMeta.ts'
7
- import { globalCacheStore } from './globalCacheStore.ts'
8
6
  import { HttpError } from './HttpError.ts'
7
+ import { hydratingSlot } from './hydratingSlot.ts'
9
8
  import { invalidateEvent } from './invalidateEvent.ts'
10
9
  import { invalidateTripwire } from './invalidateTripwire.ts'
11
10
  import { keyForRemoteCall } from './keyForRemoteCall.ts'
12
11
  import { producerKey } from './producerKey.ts'
13
12
  import { REMOTE_FUNCTION } from './REMOTE_FUNCTION.ts'
14
13
  import { REPLAYABLE_METHODS } from './REPLAYABLE_METHODS.ts'
15
- import { recordCacheKey } from './recordCacheKey.ts'
14
+ import { rpcErrorRegistry } from './rpcErrorRegistry.ts'
16
15
  import { SocketDisconnectedError } from './SocketDisconnectedError.ts'
17
16
  import { selectorMatcher } from './selectorMatcher.ts'
18
17
  import { selectorPrefix } from './selectorPrefix.ts'
18
+ import { sharedCacheStore } from './sharedCacheStore.ts'
19
+ import { openStreamProbe } from './subscribableProbes.ts'
19
20
  import { toTagSet } from './toTagSet.ts'
20
21
  import type { CacheEntry } from './types/CacheEntry.ts'
21
22
  import type { CacheOnContext } from './types/CacheOnContext.ts'
@@ -35,7 +36,7 @@ const cacheLog = abideLog.channel('abide:cache')
35
36
 
36
37
  /*
37
38
  Tallies one read and narrates it on the diagnostics channel. The sink is the
38
- request/tab store even when the data store is the process-level global one —
39
+ request/tab store even when the data store is the process-level shared one —
39
40
  attribution follows the asker, so a request's closing record reflects every
40
41
  read it made. A settled retained entry (including the warm SSR sync path) is
41
42
  a hit; an unsettled entry is a coalesced join of an in-flight call; no entry
@@ -57,16 +58,17 @@ function recordRead(sink: CacheStore, key: string, existing: CacheEntry | undefi
57
58
  }
58
59
 
59
60
  /*
60
- Curries a call against a cache store. `cache(fn, options?)` returns an invoker;
61
- calling that invoker with args checks the store for a prior entry and returns a
62
- shared promise on hit, or invokes `fn` once and stores its promise on miss.
63
- Splitting configuration (the outer call) from invocation (the inner call) keeps
64
- options anchored in a fixed position so they can't collide with arg shapes. TTL
65
- = undefined forever; ttl = 0 dedupe only; ttl > 0 entry expires `ttl` ms
66
- after the promise resolves.
61
+ Reads a call through a cache store. `cache(fn, args?, options?)` checks the store
62
+ for a prior entry and returns a shared promise on hit, or invokes `fn` once and
63
+ stores its promise on miss a direct read-through call, not a curried invoker.
64
+ Args lead (the common refinement); options trail in a fixed final position so
65
+ they can't collide with arg shapes. ttl = 0 → dedupe only; ttl > 0 → entry
66
+ expires `ttl` ms after the promise resolves. Omitted ttl → forever for a
67
+ producer and for a remote call on the client; a remote call on the server with
68
+ neither ttl nor swr stated defaults to 0 (coalesce-only, see CacheOptions).
67
69
 
68
70
  Coalescing is always on: identical in-flight calls share one flight, so
69
- `cache(createPost, { ttl: 0 })` is the mutation idiom — double-submit
71
+ `cache(createPost, args, { ttl: 0 })` is the mutation idiom — double-submit
70
72
  coalescing and pending() visibility with nothing retained beyond the store's
71
73
  atomic unit (the whole request on the server: one render, one effect; the
72
74
  in-flight window in the tab). Caching is the retention `ttl` adds on top.
@@ -74,9 +76,10 @@ in-flight window in the tab). Caching is the retention `ttl` adds on top.
74
76
  `fn` is either a remote function (a GET/POST/... helper) or a plain producer
75
77
  returning a Promise:
76
78
 
77
- cache(getPost)({ id }) // → Promise<Post> (decoded body)
78
- cache(getPost.raw)({ id }) // → Promise<Response> (raw escape hatch)
79
- cache(fetchRates)() // → Promise<Rates> (plain producer)
79
+ cache(getPost, { id }) // → Promise<Post> (decoded body)
80
+ cache(getPost.raw, { id }) // → Promise<Response> (raw escape hatch)
81
+ cache(fetchRates) // → Promise<Rates> (plain producer, no args)
82
+ cache(createPost, body, { ttl: 0 }) // options trail; no-arg-with-options: cache(fn, undefined, opts)
80
83
 
81
84
  Remote calls key on fn.method + fn.url + args and store the underlying Response
82
85
  (the decoded view is derived on the way out for the non-raw variant; both share
@@ -86,24 +89,24 @@ new reference every call and never does; a warning fires once per such call
86
89
  site), and the promise is stored and handed back as-is (no Response, no decode,
87
90
  no SSR snapshot).
88
91
 
89
- `options.global` puts the entry in the process-level store instead of the
92
+ `options.shared` puts the entry in the process-level store instead of the
90
93
  request-scoped one, so a value computed in one request is reused by later
91
94
  requests — the memoise-an-external-endpoint case. Default (omitted) is
92
95
  request-scoped on the server, which keeps per-user data from leaking across
93
96
  requests; on the client there is one tab store either way, so it is a no-op.
94
97
 
95
- Reactivity is implicit: the invoker calls `store.subscribe(key)`, which
96
- registers the surrounding scope().computed() / scope().effect() scope. Invalidating the key
97
- then re-runs that scope, which calls cache() again and gets a fresh entry.
98
- Outside a tracking scope subscribe() is a no-op, so cache() works the same
99
- in server code and plain client code.
98
+ Reactivity is implicit: the read calls `store.subscribe(key)`, which registers
99
+ the surrounding state.computed() / effect() scope. Invalidating the key then
100
+ re-runs that scope, which calls cache() again and gets a fresh entry. Outside a
101
+ tracking scope subscribe() is a no-op, so cache() works the same in server code
102
+ and plain client code.
100
103
 
101
104
  SSR: how you consume the call decides inline vs streaming (during SSR only the
102
105
  pending branch of a `<template await>` renders):
103
106
 
104
- const post = await cache(getPost)({ id }) // blocks render → baked into
107
+ const post = await cache(getPost, { id }) // blocks render → baked into
105
108
  // the initial SSR HTML
106
- <template await={cache(getPost)({ id })}> // renders pending → shell flushes
109
+ <template await={cache(getPost, { id })}> // renders pending → shell flushes
107
110
  // now, value streams in on the
108
111
  // same response when it resolves
109
112
 
@@ -119,32 +122,68 @@ await-everything mode is per component instance, so a child's await blocks only
119
122
  the child.
120
123
  */
121
124
  // @documentation cache
125
+ /**
126
+ * @deprecated Streaming rpcs (jsonl/sse) aren't cacheable — a stream has no single
127
+ * value to retain. Consume it with `state(fn(args))` (reactive) or `for await (… of
128
+ * fn(args))` (imperative). This overload is selected first for a streaming rpc so the
129
+ * mistake is a red squiggle at the keyboard; the `decodeResponse` guard also throws.
130
+ */
131
+ export function cache<Args>(
132
+ fn: RemoteFunction<Args, AsyncIterable<unknown>>,
133
+ ...rest: never[]
134
+ ): never
122
135
  export function cache<Args, Return>(
123
136
  fn: RemoteFunction<Args, Return>,
137
+ args?: Args,
124
138
  options?: CacheOptions,
125
- ): (args?: Args) => Promise<Return>
139
+ ): Promise<Return>
126
140
  export function cache<Args>(
127
141
  fn: RawRemoteFunction<Args>,
142
+ args?: Args,
128
143
  options?: CacheOptions,
129
- ): (args?: Args) => Promise<Response>
144
+ ): Promise<Response>
130
145
  export function cache<Args, Return>(
131
146
  fn: Producer<Args, Return>,
147
+ args?: Args,
132
148
  options?: CacheOptions,
133
- ): (args?: Args) => Promise<Return>
149
+ ): Promise<Return>
134
150
  export function cache<Args, Return>(
135
151
  fn: AnyRemote<Args, Return> | Producer<Args, Return>,
152
+ args?: Args,
136
153
  options?: CacheOptions,
137
- ): (args?: Args) => Promise<Return | Response> {
138
- /*
139
- Re-wrapping loses the remote's identity (no url/method on the wrapper), so
140
- the inner remote would silently become an anonymous producer — no shared
141
- key, no SSR snapshot, no write-method guards. Throw where the mistake is.
142
- */
143
- if (CACHE_WRAPPED in fn) {
144
- throw new Error(
145
- '[abide] cache(): fn is already a cache() wrapper wrap the original function once',
146
- )
147
- }
154
+ ): Promise<Return | Response> {
155
+ return readThrough(fn, args, options, false)
156
+ }
157
+
158
+ /*
159
+ The smart bare rpc call routes here (createRemoteFunction.callable → cache.read):
160
+ identical to cache() except a replayable read gets unconditional SWR retention —
161
+ its value is kept for display regardless of ttl, and ttl marks a staleness
162
+ deadline (the next read past it revalidates in the background) instead of driving
163
+ eviction. Always a decoded RemoteFunction read (the callable carries .raw), so it
164
+ returns Promise<Return>.
165
+ */
166
+ function smartRead<Args, Return>(
167
+ fn: RemoteFunction<Args, Return>,
168
+ args?: Args,
169
+ options?: CacheOptions,
170
+ ): Promise<Return> {
171
+ return readThrough(fn, args, options, true) as Promise<Return>
172
+ }
173
+ cache.read = smartRead
174
+
175
+ /*
176
+ The shared read-through core. `smart` marks the smart bare call, which enables
177
+ unconditional SWR retention for a replayable read — but only on the client (see
178
+ smartRead / entry.retain and the `retain` computation below); the public cache()
179
+ passes false, keeping its explicit drop-on-ttl / drop-on-invalidate old surface.
180
+ */
181
+ function readThrough<Args, Return>(
182
+ fn: AnyRemote<Args, Return> | Producer<Args, Return>,
183
+ args: Args | undefined,
184
+ options: CacheOptions | undefined,
185
+ smart: boolean,
186
+ ): Promise<Return | Response> {
148
187
  /*
149
188
  A remote function carries the REMOTE_FUNCTION brand (set by
150
189
  createRemoteFunction on both variants); a plain producer never does — exact,
@@ -160,26 +199,67 @@ export function cache<Args, Return>(
160
199
  : isRaw
161
200
  ? (fn as RawRemoteFunction<Args>)
162
201
  : (fn as RemoteFunction<Args, Return>).raw
163
- validatePolicy(options, isRemote ? (rawFn as RawRemoteFunction<Args>).method : undefined)
202
+ const method = isRemote ? (rawFn as RawRemoteFunction<Args>).method : undefined
203
+ const replayable = method !== undefined && REPLAYABLE_METHODS.has(method.toUpperCase())
204
+ validatePolicy(options, method, smart)
205
+ /* SWR retention is a client concern (the tab store lives; revalidation is
206
+ visible). On the server there is no live UI to hold stale, so a smart read
207
+ never retains — it coalesces only. */
208
+ const retain = smart && replayable && options?.swr !== false && typeof window !== 'undefined'
209
+ /*
210
+ ttl defaults to 0 (coalesce-only) on the server for any remote read/write, and
211
+ on the client for a smart write — in both cases only when the caller stated
212
+ neither ttl nor swr. The server default makes retention opt-in (via an explicit
213
+ ttl, paired with `shared` to survive the request). An explicit swr opts out so
214
+ it does not trip validatePolicy's "swr + ttl:0" throw; server-side swr is a
215
+ client-only concept with no added server guarantee.
216
+ */
217
+ const serverTtlZero =
218
+ isRemote &&
219
+ typeof window === 'undefined' &&
220
+ options?.ttl === undefined &&
221
+ options?.swr === undefined
222
+ const smartWriteTtlZero = smart && isRemote && !replayable && options?.ttl === undefined
223
+ const effectiveOptions = serverTtlZero || smartWriteTtlZero ? { ...options, ttl: 0 } : options
164
224
  if (!isRemote) {
165
225
  warnAnonymousProducer(fn as Producer<Args, Return>)
166
226
  }
167
- const read = (args?: Args): Promise<Return | Response> => {
168
- const store = options?.global ? globalCacheStore() : activeCacheStore()
169
- if (!isRemote) {
170
- return invokeProducer(store, fn as Producer<Args, Return>, args, options)
171
- }
172
- const remote = rawFn as RawRemoteFunction<Args>
173
- const key = keyForRemoteCall(remote.method, remote.url, args)
174
- store.subscribe(key)
175
- const existing = store.entries.get(key)
176
- recordRead(options?.global ? activeCacheStore() : store, key, existing)
177
- if (existing) {
178
- tagEntry(existing, options?.tags)
179
- attachPolicy(existing, options, () => remote(args as Args))
180
- adoptTtl(store, existing, options)
227
+ const store = effectiveOptions?.shared ? sharedCacheStore() : activeCacheStore()
228
+ if (!isRemote) {
229
+ return invokeProducer(store, fn as Producer<Args, Return>, args, effectiveOptions)
230
+ }
231
+ const remote = rawFn as RawRemoteFunction<Args>
232
+ const key = keyForRemoteCall(remote.method, remote.url, args)
233
+ if (
234
+ !effectiveOptions?.shared &&
235
+ effectiveOptions?.ttl !== undefined &&
236
+ effectiveOptions.ttl > 0 &&
237
+ store !== sharedCacheStore()
238
+ ) {
239
+ warnEphemeralTtl(key)
240
+ }
241
+ store.subscribe(key)
242
+ const existing = store.entries.get(key)
243
+ recordRead(effectiveOptions?.shared ? activeCacheStore() : store, key, existing)
244
+ if (existing) {
245
+ tagEntry(existing, effectiveOptions?.tags)
246
+ attachPolicy(existing, effectiveOptions, () => remote(args as Args), retain)
247
+ adoptTtl(store, existing, effectiveOptions, retain)
248
+ /* Access-triggered staleness: reading a retained entry past its ttl deadline
249
+ kicks a background revalidation now (stale value stays visible below,
250
+ refreshing() true). Guarded on `!refreshing` so a read while one is already
251
+ in flight doesn't queue a redundant refetch. This is the whole staleness
252
+ mechanism — no timer polls an untouched entry. */
253
+ if (
254
+ existing.retain === true &&
255
+ existing.refreshing !== true &&
256
+ existing.expiresAt !== undefined &&
257
+ existing.expiresAt <= Date.now()
258
+ ) {
259
+ scheduleInvalidationRefetch(store, existing)
181
260
  }
182
- /*
261
+ }
262
+ /*
183
263
  Warm path: a value pre-decoded onto the entry — by the SSR cache
184
264
  snapshot the client seeds its store from — is served without a network
185
265
  round-trip. It resolves on a
@@ -199,43 +279,22 @@ export function cache<Args, Return>(
199
279
  reader of the key, so one mutating it would corrupt the others. A live
200
280
  fetch hands each reader a fresh object; cloning keeps warm reads the same.
201
281
  */
202
- if (
203
- !isRaw &&
204
- existing !== undefined &&
205
- (existing.value !== undefined || existing.warm !== undefined)
206
- ) {
207
- /* A deferred seed carries a lazy materializer instead of a decoded value: decode
208
- it here, on the first read that needs it (off the hydration path), and cache the
209
- result onto the entry so later reads skip the decode. Drop the materializer once
210
- used — its closure pins the raw body string, so keeping it alongside the decoded
211
- value would double memory for exactly the large payloads deferral targets. */
212
- const warmValue = existing.value !== undefined ? existing.value : existing.warm?.()
213
- existing.value = warmValue
214
- existing.warm = undefined
215
- const warmed = Promise.resolve(cloneWarmValue(warmValue)) as Promise<Return>
216
- recordCacheKey(warmed, key)
217
- return warmed
218
- }
219
- const responsePromise = invokeRemote(
220
- store,
221
- key,
222
- existing,
223
- rawFn as RawRemoteFunction<Args>,
224
- args,
225
- options,
226
- )
227
- if (isRaw) {
228
- return responsePromise
229
- }
230
- /* Tag the decoded value promise with its key so the SSR resume path can recognise a
231
- cache-backed await value and defer a large one to a `{ defer, key }` marker. */
232
- const decoded = responsePromise.then(decodeResponse) as Promise<Return>
233
- recordCacheKey(decoded, key)
234
- return decoded
282
+ if (!isRaw && existing !== undefined && existing.value !== undefined) {
283
+ return Promise.resolve(cloneWarmValue(existing.value)) as Promise<Return>
284
+ }
285
+ const responsePromise = invokeRemote(
286
+ store,
287
+ key,
288
+ existing,
289
+ rawFn as RawRemoteFunction<Args>,
290
+ args,
291
+ effectiveOptions,
292
+ retain,
293
+ )
294
+ if (isRaw) {
295
+ return responsePromise
235
296
  }
236
- /* Non-enumerable brand; selectorMatcher and the re-wrap guard read it. */
237
- Object.defineProperty(read, CACHE_WRAPPED, { value: fn })
238
- return read
297
+ return responsePromise.then(decodeResponse) as Promise<Return>
239
298
  }
240
299
 
241
300
  /*
@@ -259,20 +318,34 @@ function cloneWarmValue(value: unknown): unknown {
259
318
  }
260
319
 
261
320
  /*
262
- Normalises the `swr` option to its window, or undefined when off. `true` (or
263
- `{}`) is stale-while-revalidate with no window refetch immediately on every
264
- invalidate; an object carries the throttle/debounce window. `false`/omitted is
265
- off. Collapsing the boolean here lets every downstream site treat "is SWR on"
266
- as a single defined/undefined check.
321
+ Normalises the refetch window, or undefined when off. Precedence: an explicit
322
+ `swr` object carries its own throttle/debounce; `swr: true` (or `defaultOn`) uses
323
+ the root `throttle`/`debounce` window if present, else `{}` (fire immediately).
324
+ `swr: false` is an explicit opt-out and wins over `defaultOn`. `defaultOn` is the
325
+ smart bare call's unconditional SWR for replayable reads — it turns the window on
326
+ without an `swr` toggle. Collapsing all of this here lets every downstream site
327
+ treat "is SWR on" as a single defined/undefined check.
267
328
  */
268
329
  function swrWindow(
269
330
  options: CacheOptions | undefined,
331
+ defaultOn: boolean,
270
332
  ): { throttle?: number; debounce?: number } | undefined {
271
333
  const swr = options?.swr
272
- if (swr === undefined || swr === false) {
334
+ if (swr === false) {
273
335
  return undefined
274
336
  }
275
- return swr === true ? {} : swr
337
+ const root =
338
+ options?.throttle !== undefined || options?.debounce !== undefined
339
+ ? { throttle: options?.throttle, debounce: options?.debounce }
340
+ : undefined
341
+ /* Explicit window object (swr: { throttle | debounce }). */
342
+ if (swr !== undefined && swr !== true) {
343
+ return swr
344
+ }
345
+ if (swr === true || defaultOn) {
346
+ return root ?? {}
347
+ }
348
+ return undefined
276
349
  }
277
350
 
278
351
  /*
@@ -284,20 +357,29 @@ refresh. Producers are opaque (no method to check); the same contract is on
284
357
  the caller there. ttl: 0 retains nothing, so there is nothing to revalidate;
285
358
  and the two coalescing windows are exclusive by construction.
286
359
  */
287
- function validatePolicy(options: CacheOptions | undefined, method: string | undefined): void {
288
- const policy = swrWindow(options)
360
+ function validatePolicy(
361
+ options: CacheOptions | undefined,
362
+ method: string | undefined,
363
+ smart: boolean,
364
+ ): void {
365
+ const replayable = method !== undefined && REPLAYABLE_METHODS.has(method.toUpperCase())
366
+ const policy = swrWindow(options, smart && replayable)
289
367
  if (!policy) {
290
368
  return
291
369
  }
292
370
  if (policy.throttle !== undefined && policy.debounce !== undefined) {
293
- throw new Error('[abide] cache(): set swr.throttle or swr.debounce, not both')
371
+ throw new Error('[abide] cache(): set throttle or debounce, not both')
294
372
  }
295
- if (options?.ttl === 0) {
373
+ /* An EXPLICIT swr with ttl: 0 is the old error — nothing retained to revalidate.
374
+ The smart read's implicit SWR retains unconditionally, so ttl: 0 is fine there. */
375
+ const explicitSwr =
376
+ options?.swr === true || (typeof options?.swr === 'object' && options?.swr !== null)
377
+ if (explicitSwr && options?.ttl === 0) {
296
378
  throw new Error(
297
379
  '[abide] cache(): swr requires retention — ttl: 0 keeps nothing to revalidate',
298
380
  )
299
381
  }
300
- if (method !== undefined && !REPLAYABLE_METHODS.has(method.toUpperCase())) {
382
+ if (method !== undefined && !replayable) {
301
383
  throw new Error(
302
384
  `[abide] cache(): swr re-runs the call unprompted — ${method.toUpperCase()} is a write and must not be replayed`,
303
385
  )
@@ -325,6 +407,20 @@ function warnAnonymousProducer(producer: (args?: never) => unknown): void {
325
407
  )
326
408
  }
327
409
 
410
+ /* Warn once per key when a ttl>0 read lands in an ephemeral (request-scoped)
411
+ store — the ttl expires with the request. `shared` (the process store) or a
412
+ client tab store are the retention homes; a request-scoped ttl is dead config. */
413
+ const warnedEphemeralTtl = new Set<string>()
414
+ function warnEphemeralTtl(key: string): void {
415
+ if (warnedEphemeralTtl.has(key)) {
416
+ return
417
+ }
418
+ warnedEphemeralTtl.add(key)
419
+ abideLog.warn(
420
+ `cache(): a request-scoped ttl expires with the request — add \`shared\` to cache "${key}" across requests, or drop the ttl.`,
421
+ )
422
+ }
423
+
328
424
  /*
329
425
  Producer path: key on the producer's reference + args, share the
330
426
  in-flight/retained promise on hit, and store the value promise as-is on miss — no
@@ -339,10 +435,10 @@ function invokeProducer<Args, Return>(
339
435
  const key = producerKey(producer, args)
340
436
  store.subscribe(key)
341
437
  const existing = store.entries.get(key)
342
- recordRead(options?.global ? activeCacheStore() : store, key, existing)
438
+ recordRead(options?.shared ? activeCacheStore() : store, key, existing)
343
439
  if (existing) {
344
440
  tagEntry(existing, options?.tags)
345
- attachPolicy(existing, options, () => producer(args))
441
+ attachPolicy(existing, options, () => producer(args), false)
346
442
  const shared = existing.promise as Promise<Return>
347
443
  /* A coalesced join waits on the in-flight producer — time the block so the
348
444
  waterfall shows it; a settled hit returns immediately, so no span. */
@@ -358,7 +454,7 @@ function invokeProducer<Args, Return>(
358
454
  const promise = cacheLog.trace<Return>(`cache ${key}`, () =>
359
455
  withCacheManaged(() => producer(args)),
360
456
  )
361
- registerEntry(store, key, promise, options, undefined, () => producer(args))
457
+ registerEntry(store, key, promise, options, undefined, () => producer(args), false)
362
458
  return promise
363
459
  }
364
460
 
@@ -369,6 +465,7 @@ function invokeRemote<Args>(
369
465
  rawFn: RawRemoteFunction<Args>,
370
466
  args: Args | undefined,
371
467
  options: CacheOptions | undefined,
468
+ retain: boolean,
372
469
  ): Promise<Response> {
373
470
  if (existing) {
374
471
  return shareable(existing.promise as Promise<Response>)
@@ -382,7 +479,7 @@ function invokeRemote<Args>(
382
479
  '[abide] cache() received a function whose call did not record metadata — was it produced by a rpc helper?',
383
480
  )
384
481
  }
385
- registerEntry(store, key, promise, options, request, () => rawFn(args as Args))
482
+ registerEntry(store, key, promise, options, request, () => rawFn(args as Args), retain)
386
483
  return shareable(promise)
387
484
  }
388
485
 
@@ -398,10 +495,12 @@ function registerEntry(
398
495
  options: CacheOptions | undefined,
399
496
  request: Request | undefined,
400
497
  refetch: () => Promise<unknown>,
498
+ retain: boolean,
401
499
  ): CacheEntry {
402
500
  const ttl = options?.ttl
403
- /* Capture the refetch thunk + window only when swr was asked for. */
404
- const policy = swrWindow(options)
501
+ /* Capture the refetch thunk + window when swr was asked for OR this is a smart
502
+ retained read (SWR unconditional). */
503
+ const policy = swrWindow(options, retain)
405
504
  const invalidation = policy
406
505
  ? { refetch, throttle: policy.throttle, debounce: policy.debounce }
407
506
  : undefined
@@ -420,6 +519,7 @@ function registerEntry(
420
519
  tags: options?.tags === undefined ? undefined : toTagSet(options.tags),
421
520
  refreshing,
422
521
  invalidation,
522
+ retain: retain || undefined,
423
523
  }
424
524
  store.entries.set(key, entry)
425
525
  store.markLifecycle(key)
@@ -433,11 +533,18 @@ function registerEntry(
433
533
  own method filter; writes never ship). The keep never applies on the
434
534
  client (the tab store outlives any unit — a kept write would block every
435
535
  future re-submit, so entries evict the moment they settle), to producer
436
- entries (no request), or to the process-level `global` store (not
437
- request-scopedkeeping it would leak forever).
536
+ entries (no request), or when the resolved store is the process-level
537
+ `shared` store including a non-shared read made outside an inbound
538
+ request, where the resolver falls back to `sharedCacheStore()` (Decision
539
+ 1): that store is never request-scoped, so keeping it would leak forever.
540
+ The `store !== sharedCacheStore()` check is what tells a genuinely
541
+ request-scoped store apart from that fallback.
438
542
  */
439
543
  const keepZeroTtlForRequest =
440
- request !== undefined && !options?.global && typeof window === 'undefined'
544
+ request !== undefined &&
545
+ !options?.shared &&
546
+ typeof window === 'undefined' &&
547
+ store !== sharedCacheStore()
441
548
  function deleteIfCurrent() {
442
549
  evictIfCurrent(store, entry)
443
550
  }
@@ -463,6 +570,29 @@ function registerEntry(
463
570
  deleteIfCurrent()
464
571
  return
465
572
  }
573
+ /*
574
+ A post-invalidate reload registers with refreshing=true; an invalidation
575
+ firing while it was in flight parked its refetch on policy.pending
576
+ (fireRefetch bails when refreshing). This settle path — not fireRefetch —
577
+ cleared the flag, so drain the parked refetch here or it's lost and the
578
+ entry keeps data that predates the invalidation.
579
+ */
580
+ if (entry.invalidation?.pending) {
581
+ reschedulePendingRefetch(store, entry, entry.invalidation)
582
+ }
583
+ /*
584
+ Smart retained read: the display value is kept unconditionally — never
585
+ hard-evicted on settle. ttl marks a staleness deadline (the next read past
586
+ it revalidates in the background, stale stays visible, refreshing() true)
587
+ instead of eviction; ttl 0/undefined retain with no staleness clock.
588
+ */
589
+ if (entry.retain) {
590
+ materializeRetained(store, entry, result)
591
+ if (ttl !== undefined && ttl > 0) {
592
+ stampStaleDeadline(entry, ttl)
593
+ }
594
+ return
595
+ }
466
596
  if (ttl === 0) {
467
597
  if (!keepZeroTtlForRequest) {
468
598
  deleteIfCurrent()
@@ -492,11 +622,71 @@ function evictIfCurrent(store: CacheStore, entry: CacheEntry): void {
492
622
  /* Arms the ttl > 0 expiry sweep; `expiresAt` re-checks at fire time so a refreshed deadline survives. */
493
623
  function armTtlExpiry(store: CacheStore, entry: CacheEntry, ttl: number): void {
494
624
  entry.expiresAt = Date.now() + ttl
625
+ scheduleTtlSweep(store, entry)
626
+ }
627
+
628
+ /* setTimeout clamps a delay beyond 2^31-1 ms (~24.8 days) to ~1ms, which would
629
+ fire the sweep instantly, find the deadline unexpired, and never re-arm —
630
+ the entry would live forever. Chain capped hops until the deadline is in
631
+ reach; each hop bails once a newer entry owns the key. */
632
+ const MAX_TIMEOUT_DELAY = 2147483647
633
+
634
+ function scheduleTtlSweep(store: CacheStore, entry: CacheEntry): void {
635
+ if (store.entries.get(entry.key) !== entry) {
636
+ return
637
+ }
638
+ const remaining = (entry.expiresAt ?? 0) - Date.now()
639
+ if (remaining > MAX_TIMEOUT_DELAY) {
640
+ setTimeout(() => scheduleTtlSweep(store, entry), MAX_TIMEOUT_DELAY).unref?.()
641
+ return
642
+ }
495
643
  setTimeout(() => {
496
644
  if ((entry.expiresAt ?? 0) <= Date.now()) {
497
645
  evictIfCurrent(store, entry)
498
646
  }
499
- }, ttl).unref?.()
647
+ }, remaining).unref?.()
648
+ }
649
+
650
+ /*
651
+ Stamps the smart-read staleness deadline. Revalidation is access-triggered, NOT
652
+ timer-driven: once `expiresAt` has passed, the NEXT read of the entry (see the
653
+ staleness branch in readThrough) schedules a background revalidation — the stale
654
+ value stays visible, refreshing() flips true, and fireRefetch re-stamps a fresh
655
+ deadline on success. No `setTimeout`, so an entry no reader touches never refetches
656
+ on its own — no background polling accretes across a session.
657
+ */
658
+ function stampStaleDeadline(entry: CacheEntry, ttl: number): void {
659
+ entry.expiresAt = Date.now() + ttl
660
+ }
661
+
662
+ /*
663
+ Decodes a retained read's Response onto entry.value so peek() can read the current
664
+ value synchronously, and reads take the warm (cloned) path. Client-only: on the
665
+ server the SSR snapshot serializer owns value materialization (from the response
666
+ body it inlines), so leaving entry.value untouched there keeps the snapshot path
667
+ unchanged. Async decode (a microtask); markLifecycle re-runs a peek() scope once
668
+ the value lands. A non-Response (a producer retain — not currently reachable) is
669
+ stored as-is.
670
+ */
671
+ function materializeRetained(store: CacheStore, entry: CacheEntry, result: unknown): void {
672
+ if (typeof window === 'undefined') {
673
+ return
674
+ }
675
+ if (result instanceof Response) {
676
+ decodeResponse(result.clone()).then(
677
+ (value) => {
678
+ /* Only if this entry still owns the key and hasn't been re-mutated meanwhile. */
679
+ if (store.entries.get(entry.key) === entry) {
680
+ entry.value = value
681
+ store.markLifecycle(entry.key)
682
+ }
683
+ },
684
+ () => undefined,
685
+ )
686
+ return
687
+ }
688
+ entry.value = result
689
+ store.markLifecycle(entry.key)
500
690
  }
501
691
 
502
692
  /*
@@ -511,11 +701,27 @@ the already-painted DOM stays put) and the next read fetches live. The first
511
701
  reader consumes the flag, so its declaration wins; live entries never carry
512
702
  the flag and keep the ttl they registered with.
513
703
  */
514
- function adoptTtl(store: CacheStore, entry: CacheEntry, options: CacheOptions | undefined): void {
704
+ function adoptTtl(
705
+ store: CacheStore,
706
+ entry: CacheEntry,
707
+ options: CacheOptions | undefined,
708
+ retain: boolean,
709
+ ): void {
515
710
  if (entry.hydrated !== true) {
516
711
  return
517
712
  }
518
713
  entry.hydrated = false
714
+ /* A smart read adopting a hydrated entry retains it and stamps the staleness
715
+ deadline, mirroring registerEntry's retain branch — never the hard-evict path. */
716
+ if (retain) {
717
+ entry.retain = true
718
+ const ttl = options?.ttl
719
+ entry.ttl = ttl
720
+ if (ttl !== undefined && ttl > 0) {
721
+ stampStaleDeadline(entry, ttl)
722
+ }
723
+ return
724
+ }
519
725
  const ttl = options?.ttl
520
726
  if (ttl === undefined) {
521
727
  return
@@ -553,6 +759,11 @@ function invalidate<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args
553
759
  const prefix = selectorPrefix(arg, args)
554
760
  const matches = selectorMatcher(arg, args, prefix)
555
761
  invalidateTripwire(selectorLabel(arg, args, prefix))
762
+ /* Reset any recorded rpc errors for this selector too (independent of cache entries — a
763
+ bare call that errored never became one). Only fn selectors resolve a prefix. */
764
+ if (prefix !== undefined) {
765
+ rpcErrorRegistry.clearMatching(prefix)
766
+ }
556
767
  for (const store of cacheStores()) {
557
768
  const matched: string[] = []
558
769
  const affected: string[] = []
@@ -603,6 +814,169 @@ function selectorLabel<Args, Return>(
603
814
 
604
815
  cache.invalidate = invalidate
605
816
 
817
+ /*
818
+ The smart-call refetch: refetches every entry matching the selector, keeping the
819
+ stale value visible (refreshing() true) until the fresh value swaps in — never
820
+ blanks. This is the old invalidate reborn as *refetch*: because SWR retains the
821
+ value, there is no "invalidate that drops to pending" for the smart call. Follows
822
+ invalidate's exact selector grammar (fn / fn+args / { tags } / bare = all).
823
+
824
+ A smart-read match already carries an invalidation policy, so it routes straight
825
+ through scheduleInvalidationRefetch (throttle/debounce-honouring). A policy-less
826
+ remote match (an explicit cache() entry, or a hydrated one) gets a refetch armed
827
+ on the fly by replaying its stored Request. A policy-less producer match has no
828
+ way to re-run, so it drops (the next read reloads) — the invalidate fallback.
829
+ */
830
+ function refresh<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args): void {
831
+ const prefix = selectorPrefix(arg, args)
832
+ const matches = selectorMatcher(arg, args, prefix)
833
+ invalidateTripwire(selectorLabel(arg, args, prefix))
834
+ if (prefix !== undefined) {
835
+ rpcErrorRegistry.clearMatching(prefix)
836
+ }
837
+ for (const store of cacheStores()) {
838
+ const matched: string[] = []
839
+ const affected: string[] = []
840
+ for (const entry of store.entries.values()) {
841
+ if (!matches(entry)) {
842
+ continue
843
+ }
844
+ matched.push(entry.key)
845
+ /* Arm a refetch on the fly for a policy-less remote entry by replaying its
846
+ stored Request — so a refresh always refetches-and-swaps, never blanks. */
847
+ if (entry.invalidation === undefined && entry.request !== undefined) {
848
+ const request = entry.request
849
+ entry.invalidation = { refetch: () => fetch(request.clone()) }
850
+ }
851
+ if (entry.invalidation !== undefined) {
852
+ scheduleInvalidationRefetch(store, entry)
853
+ } else {
854
+ /* No policy, no request (a producer never cached with swr): drop so the
855
+ next read reloads, flagged a reload if a reader is holding it. */
856
+ store.entries.delete(entry.key)
857
+ if (store.hasReader(entry.key)) {
858
+ store.pendingRefresh.add(entry.key)
859
+ }
860
+ affected.push(entry.key)
861
+ }
862
+ }
863
+ /* Mark the whole match set (probes re-derive); emit only the dropped subset — the
864
+ refetched entries emit when their fresh value lands (fireRefetch). */
865
+ notify(store, matched, affected)
866
+ }
867
+ }
868
+
869
+ cache.refresh = refresh
870
+
871
+ /*
872
+ Local value mutation: replaces the retained value of every entry matching the
873
+ selector via `updater(current)`, re-renders readers, and fires NO network — the
874
+ optimistic-update / real-time primitive (feed a socket frame straight into a
875
+ cached list). The next value is stored onto `entry.value`, which the read path
876
+ serves warm (cloned per read), so it persists across reads until a refresh /
877
+ invalidate replaces the entry. `current` comes from `entry.value` when already
878
+ materialized, else decoded from the settled/in-flight promise (async — readers
879
+ re-render when the patch lands). Follows the selector grammar, but only fn / args
880
+ / tags select a value to mutate; a not-yet-read key has nothing to patch.
881
+ */
882
+ function patch<Args, Return>(
883
+ arg: CacheSelector<Args, Return>,
884
+ args: Args | undefined,
885
+ updater: (current: Return) => Return,
886
+ ): void {
887
+ const prefix = selectorPrefix(arg, args)
888
+ const matches = selectorMatcher(arg, args, prefix)
889
+ for (const store of cacheStores()) {
890
+ for (const entry of store.entries.values()) {
891
+ if (matches(entry)) {
892
+ applyPatch(store, entry, updater as (current: unknown) => unknown)
893
+ }
894
+ }
895
+ }
896
+ }
897
+
898
+ cache.patch = patch
899
+
900
+ /*
901
+ Synchronous, non-triggering value probe: returns the currently-retained value for
902
+ a call, or undefined when nothing is retained (no entry, or not yet settled). Never
903
+ invokes — reading it opens no fetch. Reactive: it subscribes the key AND taps the
904
+ key's lifecycle channel (the exact key is a one-entry prefix, same as
905
+ pending(fn, args)) so a state.computed / on / template scope re-runs both when the
906
+ value changes (invalidate, patch) and when a retained value lands —
907
+ materializeRetained's async decode signals only markLifecycle, no invalidate event,
908
+ so without the lifecycle tap a scope that read undefined would never see the value
909
+ arrive. Outside a tracking scope it is a one-shot snapshot. Returns a clone (like
910
+ the warm read path) so a caller mutating it can't corrupt the retained value.
911
+ `peek(sub)` for a subscribable is wired on the socket side.
912
+ */
913
+ function peek<Args, Return>(
914
+ fn: RemoteFunction<Args, Return> | RawRemoteFunction<Args> | Producer<Args, Return>,
915
+ args?: Args,
916
+ ): Return | undefined {
917
+ const isRemote = REMOTE_FUNCTION in fn
918
+ const rawFn = !isRemote
919
+ ? undefined
920
+ : 'raw' in fn
921
+ ? (fn as RemoteFunction<Args, Return>).raw
922
+ : (fn as RawRemoteFunction<Args>)
923
+ const key = isRemote
924
+ ? keyForRemoteCall(rawFn!.method, rawFn!.url, args)
925
+ : producerKey(fn as Producer<Args, Return>, args)
926
+ const active = activeCacheStore()
927
+ active.subscribe(key)
928
+ active.trackLifecycle(key)
929
+ let entry = active.entries.get(key)
930
+ if (entry === undefined) {
931
+ const shared = sharedCacheStore()
932
+ shared.subscribe(key)
933
+ shared.trackLifecycle(key)
934
+ entry = shared.entries.get(key)
935
+ }
936
+ if (entry === undefined || entry.settled !== true || entry.value === undefined) {
937
+ return undefined
938
+ }
939
+ /* SSR congruence: the server materializes no cache value, so server-side peek is
940
+ uniformly undefined and the SSR render shows the fallback. Returning a snapshot-
941
+ seeded warm value here — mid-hydration — would diverge from that server text and
942
+ corrupt the claimed text node. Withhold it until the pass ends; the trackLifecycle
943
+ tap above lets wakeHydrationPeeks re-run this scope on the congruent value. */
944
+ if (hydratingSlot.active) {
945
+ return undefined
946
+ }
947
+ return cloneWarmValue(entry.value) as Return
948
+ }
949
+
950
+ cache.peek = peek
951
+
952
+ /*
953
+ Applies one entry's patch: materialize the current decoded value (warm value if
954
+ present, else decode the promise — a Response for a remote entry, cloned so the
955
+ readers' own clones still succeed), run the updater, store it warm, and emit so
956
+ readers re-read. Fire-and-forget on the async branch: patch() stays sync-return.
957
+ */
958
+ function applyPatch(
959
+ store: CacheStore,
960
+ entry: CacheEntry,
961
+ updater: (current: unknown) => unknown,
962
+ ): void {
963
+ function apply(current: unknown): void {
964
+ entry.value = updater(current)
965
+ notify(store, [entry.key], [entry.key])
966
+ }
967
+ if (entry.value !== undefined) {
968
+ apply(entry.value)
969
+ return
970
+ }
971
+ const currentValue =
972
+ entry.request !== undefined
973
+ ? (entry.promise as Promise<Response>).then((response) =>
974
+ decodeResponse(response.clone()),
975
+ )
976
+ : (entry.promise as Promise<unknown>)
977
+ currentValue.then(apply, () => undefined)
978
+ }
979
+
606
980
  /*
607
981
  Event-driven cache maintenance: subscribes to a Subscribable (socket or rpc
608
982
  stream) and runs `handler` once per frame — the declarative home for "this
@@ -647,6 +1021,11 @@ function on<T>(
647
1021
  },
648
1022
  signal: controller.signal,
649
1023
  }
1024
+ /* Register-on-consume: consuming a named subscribable populates the probe registry so
1025
+ pending()/refreshing()/error()/done()(source) work — the stream-side analog of a call
1026
+ populating the cache store. Driven by this one loop, so watch(socket) / for-await all
1027
+ register through here. An unnamed source (rare) skips probe tracking. */
1028
+ const probe = source.name ? openStreamProbe(source.name) : undefined
650
1029
  /* `let`: the reconnect path swaps in a fresh iterator; dispose closes the current one. */
651
1030
  let iterator = source[Symbol.asyncIterator]()
652
1031
  ;(async () => {
@@ -659,18 +1038,25 @@ function on<T>(
659
1038
  return
660
1039
  }
661
1040
  if (error instanceof SocketDisconnectedError) {
1041
+ probe?.reconnecting()
662
1042
  coverage.forEach((replay) => {
663
1043
  replay()
664
1044
  })
665
1045
  iterator = source[Symbol.asyncIterator]()
666
1046
  continue
667
1047
  }
1048
+ probe?.errored(error instanceof Error ? error : new Error(String(error)))
668
1049
  abideLog.error(error)
669
1050
  return
670
1051
  }
671
- if (controller.signal.aborted || next.done === true) {
1052
+ if (controller.signal.aborted) {
672
1053
  return
673
1054
  }
1055
+ if (next.done === true) {
1056
+ probe?.done()
1057
+ return
1058
+ }
1059
+ probe?.frame()
674
1060
  try {
675
1061
  await handler(next.value, context)
676
1062
  } catch (error) {
@@ -680,6 +1066,7 @@ function on<T>(
680
1066
  })()
681
1067
  return () => {
682
1068
  controller.abort()
1069
+ probe?.close()
683
1070
  iterator.return?.(undefined)?.catch(() => undefined)
684
1071
  }
685
1072
  }
@@ -766,11 +1153,23 @@ function fireRefetch(store: CacheStore, entry: CacheEntry): void {
766
1153
  entry.promise = inflight
767
1154
  entry.value = undefined
768
1155
  entry.settled = true
1156
+ /* Re-materialize the retained value from the fresh Response so peek() stays
1157
+ current after a background revalidation. */
1158
+ if (entry.retain) {
1159
+ materializeRetained(store, entry, result)
1160
+ }
769
1161
  /* Restart the freshness clock from the revalidation — without this the
770
- entry keeps its original expiresAt and is evicted at the old deadline
771
- despite holding fresh data. Mirrors registerEntry's settle path. */
1162
+ entry keeps its original expiresAt and would read as stale (or be evicted
1163
+ at the old deadline) despite holding fresh data. Mirrors registerEntry's
1164
+ settle path: a retained smart read re-stamps the staleness deadline (the
1165
+ next read past it revalidates), an explicit-swr entry re-arms the eviction
1166
+ clock. */
772
1167
  if (entry.ttl !== undefined && entry.ttl !== 0) {
773
- armTtlExpiry(store, entry, entry.ttl)
1168
+ if (entry.retain) {
1169
+ stampStaleDeadline(entry, entry.ttl)
1170
+ } else {
1171
+ armTtlExpiry(store, entry, entry.ttl)
1172
+ }
774
1173
  }
775
1174
  /* Fresh value landed — mark and emit so readers re-read. */
776
1175
  notify(store, [entry.key], [entry.key])
@@ -863,8 +1262,14 @@ function attachPolicy(
863
1262
  entry: CacheEntry,
864
1263
  options: CacheOptions | undefined,
865
1264
  refetch: () => Promise<unknown>,
1265
+ defaultOn: boolean,
866
1266
  ): void {
867
- const policy = swrWindow(options)
1267
+ /* A smart read hitting a bare existing entry (e.g. one an explicit cache()
1268
+ created first) adopts unconditional retention too. */
1269
+ if (defaultOn) {
1270
+ entry.retain = true
1271
+ }
1272
+ const policy = swrWindow(options, defaultOn)
868
1273
  if (entry.invalidation || !policy) {
869
1274
  return
870
1275
  }