@abide/abide 0.45.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.
- package/AGENTS.md +365 -320
- package/CHANGELOG.md +96 -0
- package/README.md +203 -168
- package/package.json +12 -8
- package/src/abideLsp.ts +11 -12
- package/src/abideResolverPlugin.ts +9 -4
- package/src/lib/bundle/disconnected.abide +3 -0
- package/src/lib/cli/printCommandHelp.ts +43 -0
- package/src/lib/cli/printSessionHelp.ts +2 -1
- package/src/lib/cli/{printHelp.ts → printTopLevelHelp.ts} +3 -40
- package/src/lib/cli/runCli.ts +2 -1
- package/src/lib/mcp/buildPrompts.ts +25 -0
- package/src/lib/mcp/dispatchMcpRequest.ts +8 -6
- package/src/lib/mcp/mcpResourceServerSlot.ts +5 -10
- package/src/lib/mcp/mcpSurface.ts +13 -252
- package/src/lib/mcp/mcpTools.ts +187 -0
- package/src/lib/mcp/renderPrompt.ts +25 -0
- package/src/lib/mcp/types/McpSurface.ts +15 -0
- package/src/lib/mcp/types/PromptDescriptor.ts +5 -0
- package/src/lib/mcp/types/PromptMessage.ts +2 -0
- package/src/lib/mcp/types/ToolDescriptor.ts +7 -0
- package/src/lib/mcp/types/ToolResult.ts +2 -0
- package/src/lib/server/agent.ts +1 -1
- package/src/lib/server/jsonl.ts +2 -2
- package/src/lib/server/rpc/defineRpc.ts +7 -1
- package/src/lib/server/runtime/containsTraversal.ts +21 -17
- package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
- package/src/lib/server/runtime/createServer.ts +101 -71
- package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
- package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
- package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
- package/src/lib/server/runtime/finalizeResponse.ts +32 -0
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
- package/src/lib/server/socket.ts +1 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
- package/src/lib/server/sockets/defineSocket.ts +26 -7
- package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
- package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
- package/src/lib/server/sse.ts +2 -2
- package/src/lib/shared/DEFER.ts +8 -0
- package/src/lib/shared/activeCacheStore.ts +2 -2
- package/src/lib/shared/activePage.ts +2 -2
- package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
- package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
- package/src/lib/shared/basePath.ts +2 -2
- package/src/lib/shared/baseSlot.ts +6 -5
- package/src/lib/shared/bodyValueForKind.ts +1 -2
- package/src/lib/shared/buildSocketOverChannel.ts +40 -4
- package/src/lib/shared/cache.ts +477 -110
- package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
- package/src/lib/shared/cacheStoreSlot.ts +9 -5
- package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
- package/src/lib/shared/createRemoteFunction.ts +58 -9
- package/src/lib/shared/createResolverSlot.ts +24 -31
- package/src/lib/shared/createSocketSubRegistry.ts +2 -2
- package/src/lib/shared/decodeRefJson.ts +4 -2
- package/src/lib/shared/decodeResponse.ts +8 -6
- package/src/lib/shared/done.ts +14 -0
- package/src/lib/shared/encodeRefJson.ts +4 -2
- package/src/lib/shared/globalCacheStoreSlot.ts +9 -6
- package/src/lib/shared/hydratingSlot.ts +12 -0
- package/src/lib/shared/isLayoutFile.ts +12 -0
- package/src/lib/shared/keyForRemoteCall.ts +2 -1
- package/src/lib/shared/keyPrefixForRemote.ts +12 -0
- package/src/lib/shared/matchRoute.ts +175 -0
- package/src/lib/shared/normalizePathname.ts +11 -0
- package/src/lib/shared/pageSlot.ts +14 -5
- package/src/lib/shared/pageUrlForFile.ts +3 -3
- package/src/lib/shared/parseRouteSegments.ts +16 -7
- package/src/lib/shared/patch.ts +41 -0
- package/src/lib/shared/peek.ts +35 -0
- package/src/lib/shared/prepareRemoteExport.ts +40 -0
- package/src/lib/shared/prepareRpcModule.ts +98 -16
- package/src/lib/shared/prepareSocketModule.ts +11 -15
- package/src/lib/shared/queryStringFromArgs.ts +11 -0
- package/src/lib/shared/reachable.ts +102 -0
- package/src/lib/shared/refresh.ts +22 -0
- package/src/lib/shared/requestScopeSlot.ts +10 -7
- package/src/lib/shared/routeParamsShape.ts +9 -9
- package/src/lib/shared/rpcErrorRegistry.ts +69 -0
- package/src/lib/shared/selectorPrefix.ts +3 -10
- package/src/lib/shared/setOwnProperty.ts +19 -0
- package/src/lib/shared/snippet.ts +1 -1
- package/src/lib/shared/subscribableProbes.ts +111 -0
- package/src/lib/shared/tailProbeSlot.ts +8 -1
- package/src/lib/shared/types/CacheEntry.ts +9 -15
- package/src/lib/shared/types/CacheOptions.ts +8 -0
- package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
- package/src/lib/shared/types/RemoteCallable.ts +25 -7
- package/src/lib/shared/types/RemoteFunction.ts +48 -13
- package/src/lib/shared/types/ResolverSlot.ts +7 -4
- package/src/lib/shared/types/RpcError.ts +26 -0
- package/src/lib/shared/types/SmartReadOptions.ts +32 -0
- package/src/lib/shared/types/Socket.ts +54 -0
- package/src/lib/shared/types/SsrBootState.ts +27 -0
- package/src/lib/shared/types/SsrPayload.ts +21 -0
- package/src/lib/shared/types/Subscribable.ts +13 -13
- package/src/lib/shared/types/TailHooks.ts +2 -1
- package/src/lib/shared/url.ts +29 -14
- package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
- package/src/lib/shared/writeTestSocketsDts.ts +1 -1
- package/src/lib/test/createScriptedSurface.ts +1 -1
- package/src/lib/test/createTestApp.ts +2 -2
- package/src/lib/test/createTestSocketChannel.ts +3 -3
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
- package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
- package/src/lib/ui/compile/analyzeComponent.ts +28 -6
- package/src/lib/ui/compile/compileModule.ts +137 -11
- package/src/lib/ui/compile/compileShadow.ts +101 -84
- package/src/lib/ui/compile/desugarSignals.ts +120 -107
- package/src/lib/ui/compile/generateBuild.ts +47 -11
- package/src/lib/ui/compile/generateSSR.ts +117 -14
- package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
- package/src/lib/ui/compile/lowerContext.ts +10 -4
- package/src/lib/ui/compile/lowerScript.ts +72 -5
- package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
- package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
- package/src/lib/ui/compile/signalCallee.ts +24 -0
- package/src/lib/ui/compile/stripEffects.ts +14 -11
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
- package/src/lib/ui/dom/appendStatic.ts +9 -1
- package/src/lib/ui/dom/appendText.ts +15 -3
- package/src/lib/ui/dom/assertClaimedText.ts +20 -0
- package/src/lib/ui/dom/awaitBlock.ts +17 -56
- package/src/lib/ui/dom/bindSelectValue.ts +48 -0
- package/src/lib/ui/dom/each.ts +12 -1
- package/src/lib/ui/dom/hydrate.ts +10 -0
- package/src/lib/ui/effect.ts +4 -3
- package/src/lib/ui/installHotBridge.ts +4 -2
- package/src/lib/ui/remoteProxy.ts +18 -2
- package/src/lib/ui/renderToStream.ts +9 -13
- package/src/lib/ui/resumeSeedScript.ts +2 -2
- package/src/lib/ui/router.ts +21 -2
- package/src/lib/ui/runtime/RESUME.ts +0 -6
- package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
- package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
- package/src/lib/ui/scope.ts +6 -3
- package/src/lib/ui/seedBootState.ts +53 -0
- package/src/lib/ui/socketChannel.ts +2 -2
- package/src/lib/ui/socketProxy.ts +9 -3
- package/src/lib/ui/startClient.ts +16 -30
- package/src/lib/ui/state.ts +44 -13
- package/src/lib/ui/sync.ts +11 -5
- package/src/lib/ui/tryEncodeResume.ts +2 -5
- package/src/lib/ui/types/Scope.ts +14 -10
- package/src/lib/ui/watch.ts +140 -0
- package/src/serverEntry.ts +6 -6
- package/template/CLAUDE.md +1 -1
- package/template/package.json +1 -1
- package/template/src/server/rpc/getHello.ts +3 -3
- package/template/src/ui/pages/page.abide +2 -2
- package/template/test/app.test.ts +1 -1
- package/src/lib/server/reachable.ts +0 -45
- package/src/lib/server/sockets/types/Socket.ts +0 -23
- package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
- package/src/lib/shared/baseResolver.ts +0 -10
- package/src/lib/shared/cacheKeyOf.ts +0 -7
- package/src/lib/shared/cacheKeyStore.ts +0 -8
- package/src/lib/shared/cacheStoreResolver.ts +0 -12
- package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
- package/src/lib/shared/pageResolver.ts +0 -16
- package/src/lib/shared/recordCacheKey.ts +0 -6
- package/src/lib/shared/requestScopeResolver.ts +0 -12
- package/src/lib/shared/setBaseResolver.ts +0 -4
- package/src/lib/shared/setCacheStoreResolver.ts +0 -4
- package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
- package/src/lib/shared/setPageResolver.ts +0 -4
- package/src/lib/shared/setRequestScopeResolver.ts +0 -4
- package/src/lib/shared/toBunRoutePattern.ts +0 -28
- package/src/lib/shared/types/TailOptions.ts +0 -10
- package/src/lib/ui/deferResume.ts +0 -29
- package/src/lib/ui/matchRoute.ts +0 -106
- package/src/lib/ui/tail.ts +0 -324
- /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
- /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
package/src/lib/shared/cache.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
78
|
-
cache(getPost.raw
|
|
79
|
-
cache(fetchRates)
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
105
|
+
const post = await cache(getPost, { id }) // blocks render → baked into
|
|
105
106
|
// the initial SSR HTML
|
|
106
|
-
<template await={cache(getPost
|
|
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
|
-
):
|
|
137
|
+
): Promise<Return>
|
|
126
138
|
export function cache<Args>(
|
|
127
139
|
fn: RawRemoteFunction<Args>,
|
|
140
|
+
args?: Args,
|
|
128
141
|
options?: CacheOptions,
|
|
129
|
-
):
|
|
142
|
+
): Promise<Response>
|
|
130
143
|
export function cache<Args, Return>(
|
|
131
144
|
fn: Producer<Args, Return>,
|
|
145
|
+
args?: Args,
|
|
132
146
|
options?: CacheOptions,
|
|
133
|
-
):
|
|
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
|
-
):
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
|
263
|
-
`
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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 ===
|
|
317
|
+
if (swr === false) {
|
|
273
318
|
return undefined
|
|
274
319
|
}
|
|
275
|
-
|
|
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(
|
|
288
|
-
|
|
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
|
|
354
|
+
throw new Error('[abide] cache(): set throttle or debounce, not both')
|
|
294
355
|
}
|
|
295
|
-
|
|
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 && !
|
|
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
|
|
404
|
-
|
|
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
|
-
},
|
|
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(
|
|
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
|
|
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
|
|
771
|
-
despite holding fresh data. Mirrors registerEntry's
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|