@abide/abide 0.46.0 → 0.47.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 (185) hide show
  1. package/AGENTS.md +365 -320
  2. package/CHANGELOG.md +80 -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/containsTraversal.ts +21 -17
  27. package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
  28. package/src/lib/server/runtime/createServer.ts +101 -71
  29. package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
  30. package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
  31. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
  32. package/src/lib/server/runtime/finalizeResponse.ts +32 -0
  33. package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
  34. package/src/lib/server/socket.ts +1 -1
  35. package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
  36. package/src/lib/server/sockets/defineSocket.ts +26 -7
  37. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
  38. package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
  39. package/src/lib/server/sse.ts +2 -2
  40. package/src/lib/shared/DEFER.ts +8 -0
  41. package/src/lib/shared/activeCacheStore.ts +2 -2
  42. package/src/lib/shared/activePage.ts +2 -2
  43. package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
  44. package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
  45. package/src/lib/shared/basePath.ts +2 -2
  46. package/src/lib/shared/baseSlot.ts +6 -5
  47. package/src/lib/shared/bodyValueForKind.ts +1 -2
  48. package/src/lib/shared/buildSocketOverChannel.ts +40 -4
  49. package/src/lib/shared/cache.ts +477 -110
  50. package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
  51. package/src/lib/shared/cacheStoreSlot.ts +9 -5
  52. package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
  53. package/src/lib/shared/createRemoteFunction.ts +58 -9
  54. package/src/lib/shared/createResolverSlot.ts +24 -31
  55. package/src/lib/shared/createSocketSubRegistry.ts +2 -2
  56. package/src/lib/shared/decodeRefJson.ts +4 -2
  57. package/src/lib/shared/decodeResponse.ts +8 -6
  58. package/src/lib/shared/done.ts +14 -0
  59. package/src/lib/shared/encodeRefJson.ts +4 -2
  60. package/src/lib/shared/globalCacheStoreSlot.ts +9 -6
  61. package/src/lib/shared/hydratingSlot.ts +12 -0
  62. package/src/lib/shared/isLayoutFile.ts +12 -0
  63. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  64. package/src/lib/shared/keyPrefixForRemote.ts +12 -0
  65. package/src/lib/shared/matchRoute.ts +175 -0
  66. package/src/lib/shared/normalizePathname.ts +11 -0
  67. package/src/lib/shared/pageSlot.ts +14 -5
  68. package/src/lib/shared/pageUrlForFile.ts +3 -3
  69. package/src/lib/shared/parseRouteSegments.ts +16 -7
  70. package/src/lib/shared/patch.ts +41 -0
  71. package/src/lib/shared/peek.ts +35 -0
  72. package/src/lib/shared/prepareRemoteExport.ts +40 -0
  73. package/src/lib/shared/prepareRpcModule.ts +98 -16
  74. package/src/lib/shared/prepareSocketModule.ts +11 -15
  75. package/src/lib/shared/queryStringFromArgs.ts +11 -0
  76. package/src/lib/shared/reachable.ts +102 -0
  77. package/src/lib/shared/refresh.ts +22 -0
  78. package/src/lib/shared/requestScopeSlot.ts +10 -7
  79. package/src/lib/shared/routeParamsShape.ts +9 -9
  80. package/src/lib/shared/rpcErrorRegistry.ts +69 -0
  81. package/src/lib/shared/selectorPrefix.ts +3 -10
  82. package/src/lib/shared/setOwnProperty.ts +19 -0
  83. package/src/lib/shared/snippet.ts +1 -1
  84. package/src/lib/shared/subscribableProbes.ts +111 -0
  85. package/src/lib/shared/tailProbeSlot.ts +8 -1
  86. package/src/lib/shared/types/CacheEntry.ts +9 -15
  87. package/src/lib/shared/types/CacheOptions.ts +8 -0
  88. package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
  89. package/src/lib/shared/types/RemoteCallable.ts +25 -7
  90. package/src/lib/shared/types/RemoteFunction.ts +48 -13
  91. package/src/lib/shared/types/ResolverSlot.ts +7 -4
  92. package/src/lib/shared/types/RpcError.ts +26 -0
  93. package/src/lib/shared/types/SmartReadOptions.ts +32 -0
  94. package/src/lib/shared/types/Socket.ts +54 -0
  95. package/src/lib/shared/types/SsrBootState.ts +27 -0
  96. package/src/lib/shared/types/SsrPayload.ts +21 -0
  97. package/src/lib/shared/types/Subscribable.ts +13 -13
  98. package/src/lib/shared/types/TailHooks.ts +2 -1
  99. package/src/lib/shared/url.ts +29 -14
  100. package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
  101. package/src/lib/shared/writeTestSocketsDts.ts +1 -1
  102. package/src/lib/test/createScriptedSurface.ts +1 -1
  103. package/src/lib/test/createTestApp.ts +2 -2
  104. package/src/lib/test/createTestSocketChannel.ts +3 -3
  105. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
  106. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
  107. package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
  108. package/src/lib/ui/compile/analyzeComponent.ts +28 -6
  109. package/src/lib/ui/compile/compileModule.ts +137 -11
  110. package/src/lib/ui/compile/compileShadow.ts +101 -84
  111. package/src/lib/ui/compile/desugarSignals.ts +120 -107
  112. package/src/lib/ui/compile/generateBuild.ts +48 -16
  113. package/src/lib/ui/compile/generateSSR.ts +117 -14
  114. package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
  115. package/src/lib/ui/compile/lowerContext.ts +10 -4
  116. package/src/lib/ui/compile/lowerScript.ts +72 -5
  117. package/src/lib/ui/compile/parseTemplate.ts +1 -14
  118. package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
  119. package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
  120. package/src/lib/ui/compile/signalCallee.ts +24 -0
  121. package/src/lib/ui/compile/stripEffects.ts +14 -11
  122. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  123. package/src/lib/ui/compile/types/TemplateNode.ts +0 -5
  124. package/src/lib/ui/dom/appendStatic.ts +9 -1
  125. package/src/lib/ui/dom/appendText.ts +15 -3
  126. package/src/lib/ui/dom/assertClaimedText.ts +20 -0
  127. package/src/lib/ui/dom/awaitBlock.ts +19 -83
  128. package/src/lib/ui/dom/bindSelectValue.ts +48 -0
  129. package/src/lib/ui/dom/each.ts +12 -1
  130. package/src/lib/ui/dom/hydrate.ts +10 -0
  131. package/src/lib/ui/dom/mountChild.ts +2 -5
  132. package/src/lib/ui/dom/mountRange.ts +0 -75
  133. package/src/lib/ui/effect.ts +4 -3
  134. package/src/lib/ui/installHotBridge.ts +4 -2
  135. package/src/lib/ui/remoteProxy.ts +18 -2
  136. package/src/lib/ui/renderToStream.ts +9 -13
  137. package/src/lib/ui/resumeSeedScript.ts +2 -2
  138. package/src/lib/ui/router.ts +21 -2
  139. package/src/lib/ui/runtime/RESUME.ts +0 -6
  140. package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
  141. package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
  142. package/src/lib/ui/scope.ts +6 -3
  143. package/src/lib/ui/seedBootState.ts +53 -0
  144. package/src/lib/ui/socketChannel.ts +2 -2
  145. package/src/lib/ui/socketProxy.ts +9 -3
  146. package/src/lib/ui/startClient.ts +16 -30
  147. package/src/lib/ui/state.ts +44 -13
  148. package/src/lib/ui/sync.ts +11 -5
  149. package/src/lib/ui/tryEncodeResume.ts +2 -5
  150. package/src/lib/ui/types/Scope.ts +14 -10
  151. package/src/lib/ui/watch.ts +140 -0
  152. package/src/serverEntry.ts +6 -6
  153. package/template/CLAUDE.md +1 -1
  154. package/template/package.json +1 -1
  155. package/template/src/server/rpc/getHello.ts +3 -3
  156. package/template/src/ui/pages/page.abide +2 -2
  157. package/template/test/app.test.ts +1 -1
  158. package/src/lib/server/reachable.ts +0 -45
  159. package/src/lib/server/sockets/types/Socket.ts +0 -23
  160. package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
  161. package/src/lib/shared/DEFER_MIN_ARRAY_LENGTH.ts +0 -14
  162. package/src/lib/shared/baseResolver.ts +0 -10
  163. package/src/lib/shared/cacheKeyOf.ts +0 -7
  164. package/src/lib/shared/cacheKeyStore.ts +0 -8
  165. package/src/lib/shared/cacheStoreResolver.ts +0 -12
  166. package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
  167. package/src/lib/shared/pageResolver.ts +0 -16
  168. package/src/lib/shared/recordCacheKey.ts +0 -6
  169. package/src/lib/shared/requestScopeResolver.ts +0 -12
  170. package/src/lib/shared/setBaseResolver.ts +0 -4
  171. package/src/lib/shared/setCacheStoreResolver.ts +0 -4
  172. package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
  173. package/src/lib/shared/setPageResolver.ts +0 -4
  174. package/src/lib/shared/setRequestScopeResolver.ts +0 -4
  175. package/src/lib/shared/toBunRoutePattern.ts +0 -28
  176. package/src/lib/shared/types/TailOptions.ts +0 -10
  177. package/src/lib/ui/deferResume.ts +0 -36
  178. package/src/lib/ui/dom/firstElementBetween.ts +0 -14
  179. package/src/lib/ui/matchRoute.ts +0 -106
  180. package/src/lib/ui/runtime/scheduleWake.ts +0 -28
  181. package/src/lib/ui/runtime/whenIdle.ts +0 -21
  182. package/src/lib/ui/runtime/whenVisible.ts +0 -105
  183. package/src/lib/ui/tail.ts +0 -324
  184. /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
  185. /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
6
  import { globalCacheStore } from './globalCacheStore.ts'
8
7
  import { HttpError } from './HttpError.ts'
8
+ import { hydratingSlot } from './hydratingSlot.ts'
9
9
  import { invalidateEvent } from './invalidateEvent.ts'
10
10
  import { invalidateTripwire } from './invalidateTripwire.ts'
11
11
  import { keyForRemoteCall } from './keyForRemoteCall.ts'
12
12
  import { producerKey } from './producerKey.ts'
13
13
  import { REMOTE_FUNCTION } from './REMOTE_FUNCTION.ts'
14
14
  import { REPLAYABLE_METHODS } from './REPLAYABLE_METHODS.ts'
15
- import { recordCacheKey } from './recordCacheKey.ts'
15
+ import { rpcErrorRegistry } from './rpcErrorRegistry.ts'
16
16
  import { SocketDisconnectedError } from './SocketDisconnectedError.ts'
17
17
  import { selectorMatcher } from './selectorMatcher.ts'
18
18
  import { selectorPrefix } from './selectorPrefix.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'
@@ -57,16 +58,15 @@ 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 = undefined → forever; ttl = 0 → dedupe
66
+ only; ttl > 0 → entry expires `ttl` ms after the promise resolves.
67
67
 
68
68
  Coalescing is always on: identical in-flight calls share one flight, so
69
- `cache(createPost, { ttl: 0 })` is the mutation idiom — double-submit
69
+ `cache(createPost, args, { ttl: 0 })` is the mutation idiom — double-submit
70
70
  coalescing and pending() visibility with nothing retained beyond the store's
71
71
  atomic unit (the whole request on the server: one render, one effect; the
72
72
  in-flight window in the tab). Caching is the retention `ttl` adds on top.
@@ -74,9 +74,10 @@ in-flight window in the tab). Caching is the retention `ttl` adds on top.
74
74
  `fn` is either a remote function (a GET/POST/... helper) or a plain producer
75
75
  returning a Promise:
76
76
 
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)
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, no args)
80
+ cache(createPost, body, { ttl: 0 }) // options trail; no-arg-with-options: cache(fn, undefined, opts)
80
81
 
81
82
  Remote calls key on fn.method + fn.url + args and store the underlying Response
82
83
  (the decoded view is derived on the way out for the non-raw variant; both share
@@ -92,18 +93,18 @@ requests — the memoise-an-external-endpoint case. Default (omitted) is
92
93
  request-scoped on the server, which keeps per-user data from leaking across
93
94
  requests; on the client there is one tab store either way, so it is a no-op.
94
95
 
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.
96
+ Reactivity is implicit: the read calls `store.subscribe(key)`, which registers
97
+ the surrounding state.computed() / effect() scope. Invalidating the key then
98
+ re-runs that scope, which calls cache() again and gets a fresh entry. Outside a
99
+ tracking scope subscribe() is a no-op, so cache() works the same in server code
100
+ and plain client code.
100
101
 
101
102
  SSR: how you consume the call decides inline vs streaming (during SSR only the
102
103
  pending branch of a `<template await>` renders):
103
104
 
104
- const post = await cache(getPost)({ id }) // blocks render → baked into
105
+ const post = await cache(getPost, { id }) // blocks render → baked into
105
106
  // the initial SSR HTML
106
- <template await={cache(getPost)({ id })}> // renders pending → shell flushes
107
+ <template await={cache(getPost, { id })}> // renders pending → shell flushes
107
108
  // now, value streams in on the
108
109
  // same response when it resolves
109
110
 
@@ -119,32 +120,68 @@ await-everything mode is per component instance, so a child's await blocks only
119
120
  the child.
120
121
  */
121
122
  // @documentation cache
123
+ /**
124
+ * @deprecated Streaming rpcs (jsonl/sse) aren't cacheable — a stream has no single
125
+ * value to retain. Consume it with `state(fn(args))` (reactive) or `for await (… of
126
+ * fn(args))` (imperative). This overload is selected first for a streaming rpc so the
127
+ * mistake is a red squiggle at the keyboard; the `decodeResponse` guard also throws.
128
+ */
129
+ export function cache<Args>(
130
+ fn: RemoteFunction<Args, AsyncIterable<unknown>>,
131
+ ...rest: never[]
132
+ ): never
122
133
  export function cache<Args, Return>(
123
134
  fn: RemoteFunction<Args, Return>,
135
+ args?: Args,
124
136
  options?: CacheOptions,
125
- ): (args?: Args) => Promise<Return>
137
+ ): Promise<Return>
126
138
  export function cache<Args>(
127
139
  fn: RawRemoteFunction<Args>,
140
+ args?: Args,
128
141
  options?: CacheOptions,
129
- ): (args?: Args) => Promise<Response>
142
+ ): Promise<Response>
130
143
  export function cache<Args, Return>(
131
144
  fn: Producer<Args, Return>,
145
+ args?: Args,
132
146
  options?: CacheOptions,
133
- ): (args?: Args) => Promise<Return>
147
+ ): Promise<Return>
134
148
  export function cache<Args, Return>(
135
149
  fn: AnyRemote<Args, Return> | Producer<Args, Return>,
150
+ args?: Args,
136
151
  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
- }
152
+ ): Promise<Return | Response> {
153
+ return readThrough(fn, args, options, false)
154
+ }
155
+
156
+ /*
157
+ The smart bare rpc call routes here (createRemoteFunction.callable → cache.read):
158
+ identical to cache() except a replayable read gets unconditional SWR retention —
159
+ its value is kept for display regardless of ttl, and ttl marks a staleness
160
+ deadline (the next read past it revalidates in the background) instead of driving
161
+ eviction. Always a decoded RemoteFunction read (the callable carries .raw), so it
162
+ returns Promise<Return>.
163
+ */
164
+ function smartRead<Args, Return>(
165
+ fn: RemoteFunction<Args, Return>,
166
+ args?: Args,
167
+ options?: CacheOptions,
168
+ ): Promise<Return> {
169
+ return readThrough(fn, args, options, true) as Promise<Return>
170
+ }
171
+ cache.read = smartRead
172
+
173
+ /*
174
+ The shared read-through core. `smart` marks the smart bare call, which enables
175
+ unconditional SWR retention for replayable reads (see smartRead / entry.retain);
176
+ the public cache() passes false, keeping its explicit drop-on-ttl /
177
+ drop-on-invalidate old surface.
178
+ */
179
+ function readThrough<Args, Return>(
180
+ fn: AnyRemote<Args, Return> | Producer<Args, Return>,
181
+ args: Args | undefined,
182
+ options: CacheOptions | undefined,
183
+ smart: boolean,
184
+ ): Promise<Return | Response> {
148
185
  /*
149
186
  A remote function carries the REMOTE_FUNCTION brand (set by
150
187
  createRemoteFunction on both variants); a plain producer never does — exact,
@@ -160,26 +197,52 @@ export function cache<Args, Return>(
160
197
  : isRaw
161
198
  ? (fn as RawRemoteFunction<Args>)
162
199
  : (fn as RemoteFunction<Args, Return>).raw
163
- validatePolicy(options, isRemote ? (rawFn as RawRemoteFunction<Args>).method : undefined)
200
+ const method = isRemote ? (rawFn as RawRemoteFunction<Args>).method : undefined
201
+ const replayable = method !== undefined && REPLAYABLE_METHODS.has(method.toUpperCase())
202
+ validatePolicy(options, method, smart)
203
+ /* Unconditional SWR retention for a smart replayable read (unless opted out). */
204
+ const retain = smart && replayable && options?.swr !== false
205
+ /*
206
+ A smart write is coalesce-only, not retained (design: same as cache(fn, body,
207
+ { ttl: 0 })) — a second identical submit must re-fire, never replay the first
208
+ result. Default a smart write's ttl to 0 when the caller left it open. Reads and
209
+ explicit cache() keep the caller's ttl (undefined = forever).
210
+ */
211
+ const effectiveOptions =
212
+ smart && isRemote && !replayable && options?.ttl === undefined
213
+ ? { ...options, ttl: 0 }
214
+ : options
164
215
  if (!isRemote) {
165
216
  warnAnonymousProducer(fn as Producer<Args, Return>)
166
217
  }
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)
218
+ const store = effectiveOptions?.global ? globalCacheStore() : activeCacheStore()
219
+ if (!isRemote) {
220
+ return invokeProducer(store, fn as Producer<Args, Return>, args, effectiveOptions)
221
+ }
222
+ const remote = rawFn as RawRemoteFunction<Args>
223
+ const key = keyForRemoteCall(remote.method, remote.url, args)
224
+ store.subscribe(key)
225
+ const existing = store.entries.get(key)
226
+ recordRead(effectiveOptions?.global ? activeCacheStore() : store, key, existing)
227
+ if (existing) {
228
+ tagEntry(existing, effectiveOptions?.tags)
229
+ attachPolicy(existing, effectiveOptions, () => remote(args as Args), retain)
230
+ adoptTtl(store, existing, effectiveOptions, retain)
231
+ /* Access-triggered staleness: reading a retained entry past its ttl deadline
232
+ kicks a background revalidation now (stale value stays visible below,
233
+ refreshing() true). Guarded on `!refreshing` so a read while one is already
234
+ in flight doesn't queue a redundant refetch. This is the whole staleness
235
+ mechanism — no timer polls an untouched entry. */
236
+ if (
237
+ existing.retain === true &&
238
+ existing.refreshing !== true &&
239
+ existing.expiresAt !== undefined &&
240
+ existing.expiresAt <= Date.now()
241
+ ) {
242
+ scheduleInvalidationRefetch(store, existing)
181
243
  }
182
- /*
244
+ }
245
+ /*
183
246
  Warm path: a value pre-decoded onto the entry — by the SSR cache
184
247
  snapshot the client seeds its store from — is served without a network
185
248
  round-trip. It resolves on a
@@ -199,43 +262,22 @@ export function cache<Args, Return>(
199
262
  reader of the key, so one mutating it would corrupt the others. A live
200
263
  fetch hands each reader a fresh object; cloning keeps warm reads the same.
201
264
  */
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
265
+ if (!isRaw && existing !== undefined && existing.value !== undefined) {
266
+ return Promise.resolve(cloneWarmValue(existing.value)) as Promise<Return>
267
+ }
268
+ const responsePromise = invokeRemote(
269
+ store,
270
+ key,
271
+ existing,
272
+ rawFn as RawRemoteFunction<Args>,
273
+ args,
274
+ effectiveOptions,
275
+ retain,
276
+ )
277
+ if (isRaw) {
278
+ return responsePromise
235
279
  }
236
- /* Non-enumerable brand; selectorMatcher and the re-wrap guard read it. */
237
- Object.defineProperty(read, CACHE_WRAPPED, { value: fn })
238
- return read
280
+ return responsePromise.then(decodeResponse) as Promise<Return>
239
281
  }
240
282
 
241
283
  /*
@@ -259,20 +301,34 @@ function cloneWarmValue(value: unknown): unknown {
259
301
  }
260
302
 
261
303
  /*
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.
304
+ Normalises the refetch window, or undefined when off. Precedence: an explicit
305
+ `swr` object carries its own throttle/debounce; `swr: true` (or `defaultOn`) uses
306
+ the root `throttle`/`debounce` window if present, else `{}` (fire immediately).
307
+ `swr: false` is an explicit opt-out and wins over `defaultOn`. `defaultOn` is the
308
+ smart bare call's unconditional SWR for replayable reads — it turns the window on
309
+ without an `swr` toggle. Collapsing all of this here lets every downstream site
310
+ treat "is SWR on" as a single defined/undefined check.
267
311
  */
268
312
  function swrWindow(
269
313
  options: CacheOptions | undefined,
314
+ defaultOn: boolean,
270
315
  ): { throttle?: number; debounce?: number } | undefined {
271
316
  const swr = options?.swr
272
- if (swr === undefined || swr === false) {
317
+ if (swr === false) {
273
318
  return undefined
274
319
  }
275
- return swr === true ? {} : swr
320
+ const root =
321
+ options?.throttle !== undefined || options?.debounce !== undefined
322
+ ? { throttle: options?.throttle, debounce: options?.debounce }
323
+ : undefined
324
+ /* Explicit window object (swr: { throttle | debounce }). */
325
+ if (swr !== undefined && swr !== true) {
326
+ return swr
327
+ }
328
+ if (swr === true || defaultOn) {
329
+ return root ?? {}
330
+ }
331
+ return undefined
276
332
  }
277
333
 
278
334
  /*
@@ -284,20 +340,29 @@ refresh. Producers are opaque (no method to check); the same contract is on
284
340
  the caller there. ttl: 0 retains nothing, so there is nothing to revalidate;
285
341
  and the two coalescing windows are exclusive by construction.
286
342
  */
287
- function validatePolicy(options: CacheOptions | undefined, method: string | undefined): void {
288
- const policy = swrWindow(options)
343
+ function validatePolicy(
344
+ options: CacheOptions | undefined,
345
+ method: string | undefined,
346
+ smart: boolean,
347
+ ): void {
348
+ const replayable = method !== undefined && REPLAYABLE_METHODS.has(method.toUpperCase())
349
+ const policy = swrWindow(options, smart && replayable)
289
350
  if (!policy) {
290
351
  return
291
352
  }
292
353
  if (policy.throttle !== undefined && policy.debounce !== undefined) {
293
- throw new Error('[abide] cache(): set swr.throttle or swr.debounce, not both')
354
+ throw new Error('[abide] cache(): set throttle or debounce, not both')
294
355
  }
295
- if (options?.ttl === 0) {
356
+ /* An EXPLICIT swr with ttl: 0 is the old error — nothing retained to revalidate.
357
+ The smart read's implicit SWR retains unconditionally, so ttl: 0 is fine there. */
358
+ const explicitSwr =
359
+ options?.swr === true || (typeof options?.swr === 'object' && options?.swr !== null)
360
+ if (explicitSwr && options?.ttl === 0) {
296
361
  throw new Error(
297
362
  '[abide] cache(): swr requires retention — ttl: 0 keeps nothing to revalidate',
298
363
  )
299
364
  }
300
- if (method !== undefined && !REPLAYABLE_METHODS.has(method.toUpperCase())) {
365
+ if (method !== undefined && !replayable) {
301
366
  throw new Error(
302
367
  `[abide] cache(): swr re-runs the call unprompted — ${method.toUpperCase()} is a write and must not be replayed`,
303
368
  )
@@ -342,7 +407,7 @@ function invokeProducer<Args, Return>(
342
407
  recordRead(options?.global ? activeCacheStore() : store, key, existing)
343
408
  if (existing) {
344
409
  tagEntry(existing, options?.tags)
345
- attachPolicy(existing, options, () => producer(args))
410
+ attachPolicy(existing, options, () => producer(args), false)
346
411
  const shared = existing.promise as Promise<Return>
347
412
  /* A coalesced join waits on the in-flight producer — time the block so the
348
413
  waterfall shows it; a settled hit returns immediately, so no span. */
@@ -358,7 +423,7 @@ function invokeProducer<Args, Return>(
358
423
  const promise = cacheLog.trace<Return>(`cache ${key}`, () =>
359
424
  withCacheManaged(() => producer(args)),
360
425
  )
361
- registerEntry(store, key, promise, options, undefined, () => producer(args))
426
+ registerEntry(store, key, promise, options, undefined, () => producer(args), false)
362
427
  return promise
363
428
  }
364
429
 
@@ -369,6 +434,7 @@ function invokeRemote<Args>(
369
434
  rawFn: RawRemoteFunction<Args>,
370
435
  args: Args | undefined,
371
436
  options: CacheOptions | undefined,
437
+ retain: boolean,
372
438
  ): Promise<Response> {
373
439
  if (existing) {
374
440
  return shareable(existing.promise as Promise<Response>)
@@ -382,7 +448,7 @@ function invokeRemote<Args>(
382
448
  '[abide] cache() received a function whose call did not record metadata — was it produced by a rpc helper?',
383
449
  )
384
450
  }
385
- registerEntry(store, key, promise, options, request, () => rawFn(args as Args))
451
+ registerEntry(store, key, promise, options, request, () => rawFn(args as Args), retain)
386
452
  return shareable(promise)
387
453
  }
388
454
 
@@ -398,10 +464,12 @@ function registerEntry(
398
464
  options: CacheOptions | undefined,
399
465
  request: Request | undefined,
400
466
  refetch: () => Promise<unknown>,
467
+ retain: boolean,
401
468
  ): CacheEntry {
402
469
  const ttl = options?.ttl
403
- /* Capture the refetch thunk + window only when swr was asked for. */
404
- const policy = swrWindow(options)
470
+ /* Capture the refetch thunk + window when swr was asked for OR this is a smart
471
+ retained read (SWR unconditional). */
472
+ const policy = swrWindow(options, retain)
405
473
  const invalidation = policy
406
474
  ? { refetch, throttle: policy.throttle, debounce: policy.debounce }
407
475
  : undefined
@@ -420,6 +488,7 @@ function registerEntry(
420
488
  tags: options?.tags === undefined ? undefined : toTagSet(options.tags),
421
489
  refreshing,
422
490
  invalidation,
491
+ retain: retain || undefined,
423
492
  }
424
493
  store.entries.set(key, entry)
425
494
  store.markLifecycle(key)
@@ -463,6 +532,29 @@ function registerEntry(
463
532
  deleteIfCurrent()
464
533
  return
465
534
  }
535
+ /*
536
+ A post-invalidate reload registers with refreshing=true; an invalidation
537
+ firing while it was in flight parked its refetch on policy.pending
538
+ (fireRefetch bails when refreshing). This settle path — not fireRefetch —
539
+ cleared the flag, so drain the parked refetch here or it's lost and the
540
+ entry keeps data that predates the invalidation.
541
+ */
542
+ if (entry.invalidation?.pending) {
543
+ reschedulePendingRefetch(store, entry, entry.invalidation)
544
+ }
545
+ /*
546
+ Smart retained read: the display value is kept unconditionally — never
547
+ hard-evicted on settle. ttl marks a staleness deadline (the next read past
548
+ it revalidates in the background, stale stays visible, refreshing() true)
549
+ instead of eviction; ttl 0/undefined retain with no staleness clock.
550
+ */
551
+ if (entry.retain) {
552
+ materializeRetained(store, entry, result)
553
+ if (ttl !== undefined && ttl > 0) {
554
+ stampStaleDeadline(entry, ttl)
555
+ }
556
+ return
557
+ }
466
558
  if (ttl === 0) {
467
559
  if (!keepZeroTtlForRequest) {
468
560
  deleteIfCurrent()
@@ -492,11 +584,71 @@ function evictIfCurrent(store: CacheStore, entry: CacheEntry): void {
492
584
  /* Arms the ttl > 0 expiry sweep; `expiresAt` re-checks at fire time so a refreshed deadline survives. */
493
585
  function armTtlExpiry(store: CacheStore, entry: CacheEntry, ttl: number): void {
494
586
  entry.expiresAt = Date.now() + ttl
587
+ scheduleTtlSweep(store, entry)
588
+ }
589
+
590
+ /* setTimeout clamps a delay beyond 2^31-1 ms (~24.8 days) to ~1ms, which would
591
+ fire the sweep instantly, find the deadline unexpired, and never re-arm —
592
+ the entry would live forever. Chain capped hops until the deadline is in
593
+ reach; each hop bails once a newer entry owns the key. */
594
+ const MAX_TIMEOUT_DELAY = 2147483647
595
+
596
+ function scheduleTtlSweep(store: CacheStore, entry: CacheEntry): void {
597
+ if (store.entries.get(entry.key) !== entry) {
598
+ return
599
+ }
600
+ const remaining = (entry.expiresAt ?? 0) - Date.now()
601
+ if (remaining > MAX_TIMEOUT_DELAY) {
602
+ setTimeout(() => scheduleTtlSweep(store, entry), MAX_TIMEOUT_DELAY).unref?.()
603
+ return
604
+ }
495
605
  setTimeout(() => {
496
606
  if ((entry.expiresAt ?? 0) <= Date.now()) {
497
607
  evictIfCurrent(store, entry)
498
608
  }
499
- }, ttl).unref?.()
609
+ }, remaining).unref?.()
610
+ }
611
+
612
+ /*
613
+ Stamps the smart-read staleness deadline. Revalidation is access-triggered, NOT
614
+ timer-driven: once `expiresAt` has passed, the NEXT read of the entry (see the
615
+ staleness branch in readThrough) schedules a background revalidation — the stale
616
+ value stays visible, refreshing() flips true, and fireRefetch re-stamps a fresh
617
+ deadline on success. No `setTimeout`, so an entry no reader touches never refetches
618
+ on its own — no background polling accretes across a session.
619
+ */
620
+ function stampStaleDeadline(entry: CacheEntry, ttl: number): void {
621
+ entry.expiresAt = Date.now() + ttl
622
+ }
623
+
624
+ /*
625
+ Decodes a retained read's Response onto entry.value so peek() can read the current
626
+ value synchronously, and reads take the warm (cloned) path. Client-only: on the
627
+ server the SSR snapshot serializer owns value materialization (from the response
628
+ body it inlines), so leaving entry.value untouched there keeps the snapshot path
629
+ unchanged. Async decode (a microtask); markLifecycle re-runs a peek() scope once
630
+ the value lands. A non-Response (a producer retain — not currently reachable) is
631
+ stored as-is.
632
+ */
633
+ function materializeRetained(store: CacheStore, entry: CacheEntry, result: unknown): void {
634
+ if (typeof window === 'undefined') {
635
+ return
636
+ }
637
+ if (result instanceof Response) {
638
+ decodeResponse(result.clone()).then(
639
+ (value) => {
640
+ /* Only if this entry still owns the key and hasn't been re-mutated meanwhile. */
641
+ if (store.entries.get(entry.key) === entry) {
642
+ entry.value = value
643
+ store.markLifecycle(entry.key)
644
+ }
645
+ },
646
+ () => undefined,
647
+ )
648
+ return
649
+ }
650
+ entry.value = result
651
+ store.markLifecycle(entry.key)
500
652
  }
501
653
 
502
654
  /*
@@ -511,11 +663,27 @@ the already-painted DOM stays put) and the next read fetches live. The first
511
663
  reader consumes the flag, so its declaration wins; live entries never carry
512
664
  the flag and keep the ttl they registered with.
513
665
  */
514
- function adoptTtl(store: CacheStore, entry: CacheEntry, options: CacheOptions | undefined): void {
666
+ function adoptTtl(
667
+ store: CacheStore,
668
+ entry: CacheEntry,
669
+ options: CacheOptions | undefined,
670
+ retain: boolean,
671
+ ): void {
515
672
  if (entry.hydrated !== true) {
516
673
  return
517
674
  }
518
675
  entry.hydrated = false
676
+ /* A smart read adopting a hydrated entry retains it and stamps the staleness
677
+ deadline, mirroring registerEntry's retain branch — never the hard-evict path. */
678
+ if (retain) {
679
+ entry.retain = true
680
+ const ttl = options?.ttl
681
+ entry.ttl = ttl
682
+ if (ttl !== undefined && ttl > 0) {
683
+ stampStaleDeadline(entry, ttl)
684
+ }
685
+ return
686
+ }
519
687
  const ttl = options?.ttl
520
688
  if (ttl === undefined) {
521
689
  return
@@ -553,6 +721,11 @@ function invalidate<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args
553
721
  const prefix = selectorPrefix(arg, args)
554
722
  const matches = selectorMatcher(arg, args, prefix)
555
723
  invalidateTripwire(selectorLabel(arg, args, prefix))
724
+ /* Reset any recorded rpc errors for this selector too (independent of cache entries — a
725
+ bare call that errored never became one). Only fn selectors resolve a prefix. */
726
+ if (prefix !== undefined) {
727
+ rpcErrorRegistry.clearMatching(prefix)
728
+ }
556
729
  for (const store of cacheStores()) {
557
730
  const matched: string[] = []
558
731
  const affected: string[] = []
@@ -603,6 +776,169 @@ function selectorLabel<Args, Return>(
603
776
 
604
777
  cache.invalidate = invalidate
605
778
 
779
+ /*
780
+ The smart-call refetch: refetches every entry matching the selector, keeping the
781
+ stale value visible (refreshing() true) until the fresh value swaps in — never
782
+ blanks. This is the old invalidate reborn as *refetch*: because SWR retains the
783
+ value, there is no "invalidate that drops to pending" for the smart call. Follows
784
+ invalidate's exact selector grammar (fn / fn+args / { tags } / bare = all).
785
+
786
+ A smart-read match already carries an invalidation policy, so it routes straight
787
+ through scheduleInvalidationRefetch (throttle/debounce-honouring). A policy-less
788
+ remote match (an explicit cache() entry, or a hydrated one) gets a refetch armed
789
+ on the fly by replaying its stored Request. A policy-less producer match has no
790
+ way to re-run, so it drops (the next read reloads) — the invalidate fallback.
791
+ */
792
+ function refresh<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args): void {
793
+ const prefix = selectorPrefix(arg, args)
794
+ const matches = selectorMatcher(arg, args, prefix)
795
+ invalidateTripwire(selectorLabel(arg, args, prefix))
796
+ if (prefix !== undefined) {
797
+ rpcErrorRegistry.clearMatching(prefix)
798
+ }
799
+ for (const store of cacheStores()) {
800
+ const matched: string[] = []
801
+ const affected: string[] = []
802
+ for (const entry of store.entries.values()) {
803
+ if (!matches(entry)) {
804
+ continue
805
+ }
806
+ matched.push(entry.key)
807
+ /* Arm a refetch on the fly for a policy-less remote entry by replaying its
808
+ stored Request — so a refresh always refetches-and-swaps, never blanks. */
809
+ if (entry.invalidation === undefined && entry.request !== undefined) {
810
+ const request = entry.request
811
+ entry.invalidation = { refetch: () => fetch(request.clone()) }
812
+ }
813
+ if (entry.invalidation !== undefined) {
814
+ scheduleInvalidationRefetch(store, entry)
815
+ } else {
816
+ /* No policy, no request (a producer never cached with swr): drop so the
817
+ next read reloads, flagged a reload if a reader is holding it. */
818
+ store.entries.delete(entry.key)
819
+ if (store.hasReader(entry.key)) {
820
+ store.pendingRefresh.add(entry.key)
821
+ }
822
+ affected.push(entry.key)
823
+ }
824
+ }
825
+ /* Mark the whole match set (probes re-derive); emit only the dropped subset — the
826
+ refetched entries emit when their fresh value lands (fireRefetch). */
827
+ notify(store, matched, affected)
828
+ }
829
+ }
830
+
831
+ cache.refresh = refresh
832
+
833
+ /*
834
+ Local value mutation: replaces the retained value of every entry matching the
835
+ selector via `updater(current)`, re-renders readers, and fires NO network — the
836
+ optimistic-update / real-time primitive (feed a socket frame straight into a
837
+ cached list). The next value is stored onto `entry.value`, which the read path
838
+ serves warm (cloned per read), so it persists across reads until a refresh /
839
+ invalidate replaces the entry. `current` comes from `entry.value` when already
840
+ materialized, else decoded from the settled/in-flight promise (async — readers
841
+ re-render when the patch lands). Follows the selector grammar, but only fn / args
842
+ / tags select a value to mutate; a not-yet-read key has nothing to patch.
843
+ */
844
+ function patch<Args, Return>(
845
+ arg: CacheSelector<Args, Return>,
846
+ args: Args | undefined,
847
+ updater: (current: Return) => Return,
848
+ ): void {
849
+ const prefix = selectorPrefix(arg, args)
850
+ const matches = selectorMatcher(arg, args, prefix)
851
+ for (const store of cacheStores()) {
852
+ for (const entry of store.entries.values()) {
853
+ if (matches(entry)) {
854
+ applyPatch(store, entry, updater as (current: unknown) => unknown)
855
+ }
856
+ }
857
+ }
858
+ }
859
+
860
+ cache.patch = patch
861
+
862
+ /*
863
+ Synchronous, non-triggering value probe: returns the currently-retained value for
864
+ a call, or undefined when nothing is retained (no entry, or not yet settled). Never
865
+ invokes — reading it opens no fetch. Reactive: it subscribes the key AND taps the
866
+ key's lifecycle channel (the exact key is a one-entry prefix, same as
867
+ pending(fn, args)) so a state.computed / on / template scope re-runs both when the
868
+ value changes (invalidate, patch) and when a retained value lands —
869
+ materializeRetained's async decode signals only markLifecycle, no invalidate event,
870
+ so without the lifecycle tap a scope that read undefined would never see the value
871
+ arrive. Outside a tracking scope it is a one-shot snapshot. Returns a clone (like
872
+ the warm read path) so a caller mutating it can't corrupt the retained value.
873
+ `peek(sub)` for a subscribable is wired on the socket side.
874
+ */
875
+ function peek<Args, Return>(
876
+ fn: RemoteFunction<Args, Return> | RawRemoteFunction<Args> | Producer<Args, Return>,
877
+ args?: Args,
878
+ ): Return | undefined {
879
+ const isRemote = REMOTE_FUNCTION in fn
880
+ const rawFn = !isRemote
881
+ ? undefined
882
+ : 'raw' in fn
883
+ ? (fn as RemoteFunction<Args, Return>).raw
884
+ : (fn as RawRemoteFunction<Args>)
885
+ const key = isRemote
886
+ ? keyForRemoteCall(rawFn!.method, rawFn!.url, args)
887
+ : producerKey(fn as Producer<Args, Return>, args)
888
+ const active = activeCacheStore()
889
+ active.subscribe(key)
890
+ active.trackLifecycle(key)
891
+ let entry = active.entries.get(key)
892
+ if (entry === undefined) {
893
+ const global = globalCacheStore()
894
+ global.subscribe(key)
895
+ global.trackLifecycle(key)
896
+ entry = global.entries.get(key)
897
+ }
898
+ if (entry === undefined || entry.settled !== true || entry.value === undefined) {
899
+ return undefined
900
+ }
901
+ /* SSR congruence: the server materializes no cache value, so server-side peek is
902
+ uniformly undefined and the SSR render shows the fallback. Returning a snapshot-
903
+ seeded warm value here — mid-hydration — would diverge from that server text and
904
+ corrupt the claimed text node. Withhold it until the pass ends; the trackLifecycle
905
+ tap above lets wakeHydrationPeeks re-run this scope on the congruent value. */
906
+ if (hydratingSlot.active) {
907
+ return undefined
908
+ }
909
+ return cloneWarmValue(entry.value) as Return
910
+ }
911
+
912
+ cache.peek = peek
913
+
914
+ /*
915
+ Applies one entry's patch: materialize the current decoded value (warm value if
916
+ present, else decode the promise — a Response for a remote entry, cloned so the
917
+ readers' own clones still succeed), run the updater, store it warm, and emit so
918
+ readers re-read. Fire-and-forget on the async branch: patch() stays sync-return.
919
+ */
920
+ function applyPatch(
921
+ store: CacheStore,
922
+ entry: CacheEntry,
923
+ updater: (current: unknown) => unknown,
924
+ ): void {
925
+ function apply(current: unknown): void {
926
+ entry.value = updater(current)
927
+ notify(store, [entry.key], [entry.key])
928
+ }
929
+ if (entry.value !== undefined) {
930
+ apply(entry.value)
931
+ return
932
+ }
933
+ const currentValue =
934
+ entry.request !== undefined
935
+ ? (entry.promise as Promise<Response>).then((response) =>
936
+ decodeResponse(response.clone()),
937
+ )
938
+ : (entry.promise as Promise<unknown>)
939
+ currentValue.then(apply, () => undefined)
940
+ }
941
+
606
942
  /*
607
943
  Event-driven cache maintenance: subscribes to a Subscribable (socket or rpc
608
944
  stream) and runs `handler` once per frame — the declarative home for "this
@@ -647,6 +983,11 @@ function on<T>(
647
983
  },
648
984
  signal: controller.signal,
649
985
  }
986
+ /* Register-on-consume: consuming a named subscribable populates the probe registry so
987
+ pending()/refreshing()/error()/done()(source) work — the stream-side analog of a call
988
+ populating the cache store. Driven by this one loop, so watch(socket) / for-await all
989
+ register through here. An unnamed source (rare) skips probe tracking. */
990
+ const probe = source.name ? openStreamProbe(source.name) : undefined
650
991
  /* `let`: the reconnect path swaps in a fresh iterator; dispose closes the current one. */
651
992
  let iterator = source[Symbol.asyncIterator]()
652
993
  ;(async () => {
@@ -659,18 +1000,25 @@ function on<T>(
659
1000
  return
660
1001
  }
661
1002
  if (error instanceof SocketDisconnectedError) {
1003
+ probe?.reconnecting()
662
1004
  coverage.forEach((replay) => {
663
1005
  replay()
664
1006
  })
665
1007
  iterator = source[Symbol.asyncIterator]()
666
1008
  continue
667
1009
  }
1010
+ probe?.errored(error instanceof Error ? error : new Error(String(error)))
668
1011
  abideLog.error(error)
669
1012
  return
670
1013
  }
671
- if (controller.signal.aborted || next.done === true) {
1014
+ if (controller.signal.aborted) {
672
1015
  return
673
1016
  }
1017
+ if (next.done === true) {
1018
+ probe?.done()
1019
+ return
1020
+ }
1021
+ probe?.frame()
674
1022
  try {
675
1023
  await handler(next.value, context)
676
1024
  } catch (error) {
@@ -680,6 +1028,7 @@ function on<T>(
680
1028
  })()
681
1029
  return () => {
682
1030
  controller.abort()
1031
+ probe?.close()
683
1032
  iterator.return?.(undefined)?.catch(() => undefined)
684
1033
  }
685
1034
  }
@@ -766,11 +1115,23 @@ function fireRefetch(store: CacheStore, entry: CacheEntry): void {
766
1115
  entry.promise = inflight
767
1116
  entry.value = undefined
768
1117
  entry.settled = true
1118
+ /* Re-materialize the retained value from the fresh Response so peek() stays
1119
+ current after a background revalidation. */
1120
+ if (entry.retain) {
1121
+ materializeRetained(store, entry, result)
1122
+ }
769
1123
  /* 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. */
1124
+ entry keeps its original expiresAt and would read as stale (or be evicted
1125
+ at the old deadline) despite holding fresh data. Mirrors registerEntry's
1126
+ settle path: a retained smart read re-stamps the staleness deadline (the
1127
+ next read past it revalidates), an explicit-swr entry re-arms the eviction
1128
+ clock. */
772
1129
  if (entry.ttl !== undefined && entry.ttl !== 0) {
773
- armTtlExpiry(store, entry, entry.ttl)
1130
+ if (entry.retain) {
1131
+ stampStaleDeadline(entry, entry.ttl)
1132
+ } else {
1133
+ armTtlExpiry(store, entry, entry.ttl)
1134
+ }
774
1135
  }
775
1136
  /* Fresh value landed — mark and emit so readers re-read. */
776
1137
  notify(store, [entry.key], [entry.key])
@@ -863,8 +1224,14 @@ function attachPolicy(
863
1224
  entry: CacheEntry,
864
1225
  options: CacheOptions | undefined,
865
1226
  refetch: () => Promise<unknown>,
1227
+ defaultOn: boolean,
866
1228
  ): void {
867
- const policy = swrWindow(options)
1229
+ /* A smart read hitting a bare existing entry (e.g. one an explicit cache()
1230
+ created first) adopts unconditional retention too. */
1231
+ if (defaultOn) {
1232
+ entry.retain = true
1233
+ }
1234
+ const policy = swrWindow(options, defaultOn)
868
1235
  if (entry.invalidation || !policy) {
869
1236
  return
870
1237
  }