@abide/abide 0.53.0 → 0.55.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 +330 -494
- package/CHANGELOG.md +106 -0
- package/README.md +166 -146
- package/package.json +3 -4
- package/src/checkAbide.ts +1 -1
- package/src/lib/server/rpc/defineRpc.ts +2 -1
- package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
- package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
- package/src/lib/server/runtime/createUiPageRenderer.ts +54 -20
- package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
- package/src/lib/server/runtime/types/RequestStore.ts +9 -0
- package/src/lib/server/sockets/amendFamilyEntry.ts +39 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
- package/src/lib/server/sockets/defineSocket.ts +11 -1
- package/src/lib/shared/AMEND_TOPIC_PREFIX.ts +9 -0
- package/src/lib/shared/SEEDS.ts +33 -0
- package/src/lib/shared/SOCKET_SEED.ts +12 -0
- package/src/lib/shared/amend.ts +48 -0
- package/src/lib/shared/amendBroadcastSlot.ts +19 -0
- package/src/lib/shared/applyAmendLocally.ts +20 -0
- package/src/lib/shared/attachRpcSelectorMethods.ts +5 -5
- package/src/lib/shared/buildSocketOverChannel.ts +9 -2
- package/src/lib/shared/cache.ts +203 -84
- package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
- package/src/lib/shared/createCacheStore.ts +8 -0
- package/src/lib/shared/createRemoteFunction.ts +4 -1
- package/src/lib/shared/encodeSeedValue.ts +24 -0
- package/src/lib/shared/jsonSchemaForType.ts +3 -16
- package/src/lib/shared/peek.ts +4 -7
- package/src/lib/shared/socketTailsSlot.ts +13 -0
- package/src/lib/shared/types/AmendApply.ts +15 -0
- package/src/lib/shared/types/CacheReaderHook.ts +11 -0
- package/src/lib/shared/types/RemoteFunction.ts +30 -19
- package/src/lib/shared/types/SocketTails.ts +13 -0
- package/src/lib/shared/types/SsrPayload.ts +6 -0
- package/src/lib/test/createTestApp.ts +6 -0
- package/src/lib/ui/amendReaderHook.ts +125 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
- package/src/lib/ui/compile/analyzeComponent.ts +0 -2
- package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
- package/src/lib/ui/compile/cachedShadowProgram.ts +39 -0
- package/src/lib/ui/compile/compileComponent.ts +1 -3
- package/src/lib/ui/compile/componentOrdinals.ts +39 -0
- package/src/lib/ui/compile/desugarSignals.ts +65 -36
- package/src/lib/ui/compile/generateBuild.ts +25 -24
- package/src/lib/ui/compile/generateSSR.ts +37 -24
- package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
- package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
- package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
- package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
- package/src/lib/ui/compile/lowerContext.ts +26 -6
- package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
- package/src/lib/ui/compile/lowerScript.ts +1 -11
- package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +25 -45
- package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +10 -15
- package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
- package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
- package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
- package/src/lib/ui/compile/wrapSeed.ts +28 -0
- package/src/lib/ui/computed.ts +16 -0
- package/src/lib/ui/createScope.ts +4 -3
- package/src/lib/ui/dom/anchorCursor.ts +2 -1
- package/src/lib/ui/dom/anchoredBranch.ts +14 -3
- package/src/lib/ui/dom/appendStatic.ts +11 -20
- package/src/lib/ui/dom/appendText.ts +41 -43
- package/src/lib/ui/dom/appendTextAt.ts +10 -57
- package/src/lib/ui/dom/awaitBlock.ts +31 -45
- package/src/lib/ui/dom/awaitSubject.ts +118 -0
- package/src/lib/ui/dom/claimText.ts +38 -0
- package/src/lib/ui/dom/cloneStatic.ts +2 -8
- package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
- package/src/lib/ui/dom/discardBoundary.ts +5 -4
- package/src/lib/ui/dom/each.ts +3 -5
- package/src/lib/ui/dom/fillBoundary.ts +2 -1
- package/src/lib/ui/dom/hydrate.ts +5 -12
- package/src/lib/ui/dom/mountChild.ts +73 -5
- package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
- package/src/lib/ui/dom/openMarker.ts +2 -4
- package/src/lib/ui/dom/outlet.ts +4 -3
- package/src/lib/ui/dom/readCell.ts +26 -11
- package/src/lib/ui/dom/skeleton.ts +2 -7
- package/src/lib/ui/dom/switchBlock.ts +2 -1
- package/src/lib/ui/dom/tryBlock.ts +27 -39
- package/src/lib/ui/dom/when.ts +6 -2
- package/src/lib/ui/linked.ts +14 -0
- package/src/lib/ui/resumeSeedScript.ts +1 -1
- package/src/lib/ui/router.ts +12 -16
- package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
- package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
- package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
- package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
- package/src/lib/ui/runtime/RENDER.ts +7 -5
- package/src/lib/ui/runtime/RESUME.ts +5 -7
- package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
- package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
- package/src/lib/ui/runtime/claimChild.ts +5 -7
- package/src/lib/ui/runtime/claimExpected.ts +2 -6
- package/src/lib/ui/runtime/claimMarker.ts +13 -0
- package/src/lib/ui/runtime/claimRun.ts +22 -0
- package/src/lib/ui/runtime/consumeSeed.ts +38 -0
- package/src/lib/ui/runtime/createAsyncCell.ts +56 -8
- package/src/lib/ui/runtime/createComputedNode.ts +1 -0
- package/src/lib/ui/runtime/createDoc.ts +5 -1
- package/src/lib/ui/runtime/createEffectNode.ts +1 -0
- package/src/lib/ui/runtime/createSignalNode.ts +1 -0
- package/src/lib/ui/runtime/parkCursor.ts +10 -0
- package/src/lib/ui/runtime/readNode.ts +21 -1
- package/src/lib/ui/runtime/renderPath.ts +2 -2
- package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
- package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
- package/src/lib/ui/runtime/runNode.ts +14 -0
- package/src/lib/ui/runtime/types/Cell.ts +4 -2
- package/src/lib/ui/runtime/types/Doc.ts +3 -1
- package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
- package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
- package/src/lib/ui/settleAsyncCells.ts +30 -3
- package/src/lib/ui/socketProxy.ts +17 -1
- package/src/lib/ui/startClient.ts +23 -0
- package/src/lib/ui/tryEncodeResume.ts +6 -15
- package/src/lib/ui/types/Scope.ts +1 -1
- package/src/lib/ui/watch.ts +21 -16
- package/src/serverEntry.ts +15 -0
- package/src/lib/shared/patch.ts +0 -41
- package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
- package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
- package/src/lib/ui/dom/readCellBlocking.ts +0 -29
package/src/lib/shared/cache.ts
CHANGED
|
@@ -176,10 +176,16 @@ its value is kept for display regardless of ttl, and ttl marks a staleness
|
|
|
176
176
|
deadline (the next read past it revalidates in the background) instead of driving
|
|
177
177
|
eviction. No call-site options (ADR-0020): all policy is read off the endpoint
|
|
178
178
|
(fn.cache). Always a decoded RemoteFunction read (the callable carries .raw), so
|
|
179
|
-
it returns Promise<Return>.
|
|
179
|
+
it returns Promise<Return>. `key` is the caller's already-derived cache key (the
|
|
180
|
+
callable keys its rpc-error registry with it) — threaded through so readThrough
|
|
181
|
+
reuses it instead of re-deriving the identical string; omitted, readThrough derives.
|
|
180
182
|
*/
|
|
181
|
-
function smartRead<Args, Return>(
|
|
182
|
-
|
|
183
|
+
function smartRead<Args, Return>(
|
|
184
|
+
fn: RemoteFunction<Args, Return>,
|
|
185
|
+
args?: Args,
|
|
186
|
+
key?: string,
|
|
187
|
+
): Promise<Return> {
|
|
188
|
+
return readThrough(fn, args, undefined, true, key) as Promise<Return>
|
|
183
189
|
}
|
|
184
190
|
cache.read = smartRead
|
|
185
191
|
|
|
@@ -196,6 +202,7 @@ function readThrough<Args, Return>(
|
|
|
196
202
|
args: Args | undefined,
|
|
197
203
|
options: CacheOptions | undefined,
|
|
198
204
|
smart: boolean,
|
|
205
|
+
precomputedKey?: string,
|
|
199
206
|
): Promise<Return | Response> {
|
|
200
207
|
/*
|
|
201
208
|
A remote function carries the REMOTE_FUNCTION brand (set by
|
|
@@ -239,13 +246,17 @@ function readThrough<Args, Return>(
|
|
|
239
246
|
- a read that resolved to the process store WITHOUT `shared` — a server read outside any
|
|
240
247
|
request scope, where activeCacheStore() falls back to the shared store. Nothing bounds
|
|
241
248
|
its lifetime there, so retaining it would leak forever.
|
|
242
|
-
Both apply on either side and however the call is made.
|
|
249
|
+
Both apply on either side and however the call is made. An explicit `shared` defeats both:
|
|
250
|
+
it is a deliberate "memoise this across requests" opt-in (a POST that is a pure function
|
|
251
|
+
of its args, using the body only to carry a large payload — highlightCode), so it retains
|
|
252
|
+
in the process store with the default Infinity ttl rather than dropping on settle. An
|
|
253
|
+
explicit `ttl` already defeats them via the `ttl === undefined` guard above.
|
|
243
254
|
*/
|
|
244
255
|
const coalesceOnly =
|
|
245
256
|
isRemote &&
|
|
246
257
|
policy?.ttl === undefined &&
|
|
247
|
-
|
|
248
|
-
|
|
258
|
+
!policy?.shared &&
|
|
259
|
+
(!replayable || (typeof window === 'undefined' && store === sharedCacheStore()))
|
|
249
260
|
const effectiveOptions = coalesceOnly ? { ...policy, ttl: 0 } : policy
|
|
250
261
|
if (!isRemote) {
|
|
251
262
|
warnAnonymousProducer(fn as Producer<Args, Return>)
|
|
@@ -254,7 +265,12 @@ function readThrough<Args, Return>(
|
|
|
254
265
|
return invokeProducer(store, fn as Producer<Args, Return>, args, effectiveOptions)
|
|
255
266
|
}
|
|
256
267
|
const remote = rawFn as RawRemoteFunction<Args>
|
|
257
|
-
|
|
268
|
+
/* Reuse the caller's derived key when it threaded one (the smart bare call already
|
|
269
|
+
computes it for its rpc-error registry, and remote is that callable's .raw — same
|
|
270
|
+
method/url, so the string is identical). Otherwise derive it — the public cache()
|
|
271
|
+
and the raw path pass none. Saves a query-encode / canonical-json stringify per read,
|
|
272
|
+
warm hits included. */
|
|
273
|
+
const key = precomputedKey ?? keyForRemoteCall(remote.method, remote.url, args)
|
|
258
274
|
if (
|
|
259
275
|
!effectiveOptions?.shared &&
|
|
260
276
|
effectiveOptions?.ttl !== undefined &&
|
|
@@ -756,22 +772,100 @@ rather than a first-ever load. An empty or unmatched selector is a no-op on the
|
|
|
756
772
|
cache; the lifecycle ping still fires but recomputes pending() to the same value.
|
|
757
773
|
*/
|
|
758
774
|
function invalidate<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args): void {
|
|
759
|
-
/* Resolve the fn-selector prefix once; the matcher
|
|
775
|
+
/* Resolve the fn-selector prefix once; the matcher, the label, and the exact-key
|
|
776
|
+
fast path all consume it. */
|
|
760
777
|
const prefix = selectorPrefix(arg, args)
|
|
761
|
-
invalidateMatching(
|
|
778
|
+
invalidateMatching(
|
|
779
|
+
selectorMatcher(arg, args, prefix),
|
|
780
|
+
selectorLabel(arg, args, prefix),
|
|
781
|
+
prefix,
|
|
782
|
+
exactSelectorKey(arg, args, prefix),
|
|
783
|
+
)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/*
|
|
787
|
+
The single entry key a `fn + args` selector targets: args narrows the match to one
|
|
788
|
+
call, so the resolved prefix IS the exact key — the apply loops can `Map.get` it
|
|
789
|
+
directly instead of scanning every store. undefined for an all / bare-fn-prefix /
|
|
790
|
+
tag selector (which may match many entries) so those still scan, and for a producer
|
|
791
|
+
never cached (prefix undefined) whose scan matches nothing anyway.
|
|
792
|
+
*/
|
|
793
|
+
function exactSelectorKey<Args, Return>(
|
|
794
|
+
arg: CacheSelector<Args, Return> | undefined,
|
|
795
|
+
args: Args | undefined,
|
|
796
|
+
prefix: string | undefined,
|
|
797
|
+
): string | undefined {
|
|
798
|
+
return typeof arg === 'function' && args !== undefined ? prefix : undefined
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/*
|
|
802
|
+
The ONE per-store selection dispatch every selector-driven verb runs through: an exact
|
|
803
|
+
key (a fn + args selector, from exactSelectorKey) picks its single candidate via a
|
|
804
|
+
direct Map.get instead of scanning; any other selector scans the store against
|
|
805
|
+
`matches`. Shared so the fast path and the scan can't diverge across
|
|
806
|
+
invalidate / refresh / amend (ADR-0041).
|
|
807
|
+
*/
|
|
808
|
+
function applyToMatching(
|
|
809
|
+
store: CacheStore,
|
|
810
|
+
exactKey: string | undefined,
|
|
811
|
+
matches: (entry: CacheEntry) => boolean,
|
|
812
|
+
apply: (entry: CacheEntry) => void,
|
|
813
|
+
): void {
|
|
814
|
+
if (exactKey !== undefined) {
|
|
815
|
+
const entry = store.entries.get(exactKey)
|
|
816
|
+
if (entry !== undefined) {
|
|
817
|
+
apply(entry)
|
|
818
|
+
}
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
/* Deleting the current entry mid-iteration is spec-safe on a Map; no snapshot needed. */
|
|
822
|
+
for (const entry of store.entries.values()) {
|
|
823
|
+
if (matches(entry)) {
|
|
824
|
+
apply(entry)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/*
|
|
830
|
+
The per-entry drop/refetch decision of invalidate() — the single seam so the full
|
|
831
|
+
scan and the exact-key fast path can't diverge (ADR-0041). A refetch-policy entry is
|
|
832
|
+
kept and its refetch coalesced; every other match is dropped so the next read reloads.
|
|
833
|
+
*/
|
|
834
|
+
function invalidateEntry(
|
|
835
|
+
store: CacheStore,
|
|
836
|
+
entry: CacheEntry,
|
|
837
|
+
matched: string[],
|
|
838
|
+
affected: string[],
|
|
839
|
+
): void {
|
|
840
|
+
matched.push(entry.key)
|
|
841
|
+
if (entry.invalidation) {
|
|
842
|
+
scheduleInvalidationRefetch(store, entry)
|
|
843
|
+
} else {
|
|
844
|
+
store.entries.delete(entry.key)
|
|
845
|
+
/* Flag the next read a reload (refreshing()) — but only if a reader is
|
|
846
|
+
holding the value now; with none on screen the next read is a first
|
|
847
|
+
load, and an ungated add would linger forever on the tab store. */
|
|
848
|
+
if (store.hasReader(entry.key)) {
|
|
849
|
+
store.pendingRefresh.add(entry.key)
|
|
850
|
+
}
|
|
851
|
+
affected.push(entry.key)
|
|
852
|
+
}
|
|
762
853
|
}
|
|
763
854
|
|
|
764
855
|
/*
|
|
765
856
|
The store-loop body of invalidate(), driven by a raw entry predicate rather than a
|
|
766
|
-
selector —
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
857
|
+
selector — a local invalidate() and a wire-driven one (a server broadcast decoded via
|
|
858
|
+
matcherFromEnvelope) share the per-entry `invalidateEntry` seam and can't diverge
|
|
859
|
+
(ADR-0041). `label` feeds the cycle tripwire; `prefix` (undefined for a tag selector)
|
|
860
|
+
resets recorded rpc errors for the selector. `exactKey` (a fn + args selector's one
|
|
861
|
+
key, from exactSelectorKey) picks that entry directly instead of scanning; the
|
|
862
|
+
wire-driven path passes none and scans as before.
|
|
770
863
|
*/
|
|
771
864
|
function invalidateMatching(
|
|
772
865
|
matches: (entry: CacheEntry) => boolean,
|
|
773
866
|
label: string,
|
|
774
867
|
prefix: string | undefined,
|
|
868
|
+
exactKey?: string,
|
|
775
869
|
): void {
|
|
776
870
|
invalidateTripwire(label)
|
|
777
871
|
/* Reset any recorded rpc errors for this selector too (independent of cache entries — a
|
|
@@ -782,25 +876,9 @@ function invalidateMatching(
|
|
|
782
876
|
for (const store of cacheStores()) {
|
|
783
877
|
const matched: string[] = []
|
|
784
878
|
const affected: string[] = []
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
continue
|
|
789
|
-
}
|
|
790
|
-
matched.push(entry.key)
|
|
791
|
-
if (entry.invalidation) {
|
|
792
|
-
scheduleInvalidationRefetch(store, entry)
|
|
793
|
-
} else {
|
|
794
|
-
store.entries.delete(entry.key)
|
|
795
|
-
/* Flag the next read a reload (refreshing()) — but only if a reader is
|
|
796
|
-
holding the value now; with none on screen the next read is a first
|
|
797
|
-
load, and an ungated add would linger forever on the tab store. */
|
|
798
|
-
if (store.hasReader(entry.key)) {
|
|
799
|
-
store.pendingRefresh.add(entry.key)
|
|
800
|
-
}
|
|
801
|
-
affected.push(entry.key)
|
|
802
|
-
}
|
|
803
|
-
}
|
|
879
|
+
applyToMatching(store, exactKey, matches, (entry) =>
|
|
880
|
+
invalidateEntry(store, entry, matched, affected),
|
|
881
|
+
)
|
|
804
882
|
/* Every match changed state (probes re-derive); only the dropped subset
|
|
805
883
|
changed its visible value (readers re-read) — swr matches stay put. */
|
|
806
884
|
notify(store, matched, affected)
|
|
@@ -851,18 +929,65 @@ way to re-run, so it drops (the next read reloads) — the invalidate fallback.
|
|
|
851
929
|
*/
|
|
852
930
|
function refresh<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args): void {
|
|
853
931
|
const prefix = selectorPrefix(arg, args)
|
|
854
|
-
refreshMatching(
|
|
932
|
+
refreshMatching(
|
|
933
|
+
selectorMatcher(arg, args, prefix),
|
|
934
|
+
selectorLabel(arg, args, prefix),
|
|
935
|
+
prefix,
|
|
936
|
+
exactSelectorKey(arg, args, prefix),
|
|
937
|
+
)
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/*
|
|
941
|
+
The per-entry refetch/drop decision of refresh() — the single seam shared by the full
|
|
942
|
+
scan and the exact-key fast path (ADR-0041). Only a match with a live reader refetches;
|
|
943
|
+
a reader-less match drops so the next read reloads.
|
|
944
|
+
*/
|
|
945
|
+
function refreshEntry(
|
|
946
|
+
store: CacheStore,
|
|
947
|
+
entry: CacheEntry,
|
|
948
|
+
matched: string[],
|
|
949
|
+
affected: string[],
|
|
950
|
+
): void {
|
|
951
|
+
matched.push(entry.key)
|
|
952
|
+
/* No live reader is holding this key — its reactive scope has torn down (or
|
|
953
|
+
it was never read reactively and already dropped). A refresh has no
|
|
954
|
+
on-screen value to keep-stale-and-swap, so firing the network now is
|
|
955
|
+
wasted work that may be stale again before anything shows it. Drop the
|
|
956
|
+
entry so the next read reloads fresh instead — the same lazy path a
|
|
957
|
+
reader-less invalidate() takes. */
|
|
958
|
+
if (!store.hasReader(entry.key)) {
|
|
959
|
+
store.entries.delete(entry.key)
|
|
960
|
+
return
|
|
961
|
+
}
|
|
962
|
+
/* Arm a refetch on the fly for a policy-less remote entry by replaying its
|
|
963
|
+
stored Request — so a refresh always refetches-and-swaps, never blanks. */
|
|
964
|
+
if (entry.invalidation === undefined && entry.request !== undefined) {
|
|
965
|
+
const request = entry.request
|
|
966
|
+
entry.invalidation = { refetch: () => fetch(request.clone()) }
|
|
967
|
+
}
|
|
968
|
+
if (entry.invalidation !== undefined) {
|
|
969
|
+
scheduleInvalidationRefetch(store, entry)
|
|
970
|
+
} else {
|
|
971
|
+
/* No policy, no request (a producer never cached with a refetch window): drop so the
|
|
972
|
+
next read reloads. A reader is holding it (checked above), so flag the
|
|
973
|
+
next read a reload (refreshing()). */
|
|
974
|
+
store.entries.delete(entry.key)
|
|
975
|
+
store.pendingRefresh.add(entry.key)
|
|
976
|
+
affected.push(entry.key)
|
|
977
|
+
}
|
|
855
978
|
}
|
|
856
979
|
|
|
857
980
|
/*
|
|
858
981
|
The store-loop body of refresh(), driven by a raw entry predicate — the refetch
|
|
859
|
-
analogue of invalidateMatching, so a local refresh() and a wire-driven one share
|
|
860
|
-
|
|
982
|
+
analogue of invalidateMatching, so a local refresh() and a wire-driven one share the
|
|
983
|
+
per-entry `refreshEntry` seam (ADR-0041). `exactKey` (a fn + args selector's one key)
|
|
984
|
+
picks that entry directly instead of scanning; the wire-driven path passes none.
|
|
861
985
|
*/
|
|
862
986
|
function refreshMatching(
|
|
863
987
|
matches: (entry: CacheEntry) => boolean,
|
|
864
988
|
label: string,
|
|
865
989
|
prefix: string | undefined,
|
|
990
|
+
exactKey?: string,
|
|
866
991
|
): void {
|
|
867
992
|
invalidateTripwire(label)
|
|
868
993
|
if (prefix !== undefined) {
|
|
@@ -871,38 +996,9 @@ function refreshMatching(
|
|
|
871
996
|
for (const store of cacheStores()) {
|
|
872
997
|
const matched: string[] = []
|
|
873
998
|
const affected: string[] = []
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
}
|
|
878
|
-
matched.push(entry.key)
|
|
879
|
-
/* No live reader is holding this key — its reactive scope has torn down (or
|
|
880
|
-
it was never read reactively and already dropped). A refresh has no
|
|
881
|
-
on-screen value to keep-stale-and-swap, so firing the network now is
|
|
882
|
-
wasted work that may be stale again before anything shows it. Drop the
|
|
883
|
-
entry so the next read reloads fresh instead — the same lazy path a
|
|
884
|
-
reader-less invalidate() takes. */
|
|
885
|
-
if (!store.hasReader(entry.key)) {
|
|
886
|
-
store.entries.delete(entry.key)
|
|
887
|
-
continue
|
|
888
|
-
}
|
|
889
|
-
/* Arm a refetch on the fly for a policy-less remote entry by replaying its
|
|
890
|
-
stored Request — so a refresh always refetches-and-swaps, never blanks. */
|
|
891
|
-
if (entry.invalidation === undefined && entry.request !== undefined) {
|
|
892
|
-
const request = entry.request
|
|
893
|
-
entry.invalidation = { refetch: () => fetch(request.clone()) }
|
|
894
|
-
}
|
|
895
|
-
if (entry.invalidation !== undefined) {
|
|
896
|
-
scheduleInvalidationRefetch(store, entry)
|
|
897
|
-
} else {
|
|
898
|
-
/* No policy, no request (a producer never cached with a refetch window): drop so the
|
|
899
|
-
next read reloads. A reader is holding it (checked above), so flag the
|
|
900
|
-
next read a reload (refreshing()). */
|
|
901
|
-
store.entries.delete(entry.key)
|
|
902
|
-
store.pendingRefresh.add(entry.key)
|
|
903
|
-
affected.push(entry.key)
|
|
904
|
-
}
|
|
905
|
-
}
|
|
999
|
+
applyToMatching(store, exactKey, matches, (entry) =>
|
|
1000
|
+
refreshEntry(store, entry, matched, affected),
|
|
1001
|
+
)
|
|
906
1002
|
/* Mark the whole match set (probes re-derive); emit only the dropped subset — the
|
|
907
1003
|
refetched entries emit when their fresh value lands (fireRefetch). */
|
|
908
1004
|
notify(store, matched, affected)
|
|
@@ -920,26 +1016,28 @@ cached list). The next value is stored onto `entry.value`, which the read path
|
|
|
920
1016
|
serves warm (cloned per read), so it persists across reads until a refresh /
|
|
921
1017
|
invalidate replaces the entry. `current` comes from `entry.value` when already
|
|
922
1018
|
materialized, else decoded from the settled/in-flight promise (async — readers
|
|
923
|
-
re-render when the
|
|
924
|
-
/ tags select a value to mutate; a not-yet-read key has nothing to
|
|
1019
|
+
re-render when the amend lands). Follows the selector grammar, but only fn / args
|
|
1020
|
+
/ tags select a value to mutate; a not-yet-read key has nothing to amend.
|
|
925
1021
|
*/
|
|
926
|
-
function
|
|
1022
|
+
function amend<Args, Return>(
|
|
927
1023
|
arg: CacheSelector<Args, Return>,
|
|
928
1024
|
args: Args | undefined,
|
|
929
1025
|
updater: (current: Return) => Return,
|
|
1026
|
+
isValue: boolean,
|
|
930
1027
|
): void {
|
|
931
1028
|
const prefix = selectorPrefix(arg, args)
|
|
1029
|
+
const exactKey = exactSelectorKey(arg, args, prefix)
|
|
932
1030
|
const matches = selectorMatcher(arg, args, prefix)
|
|
1031
|
+
/* The exact-key arm is the optimistic-update / socket-frame path, potentially once
|
|
1032
|
+
per frame — a direct Map.get through the shared dispatch, no scan. */
|
|
933
1033
|
for (const store of cacheStores()) {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
}
|
|
938
|
-
}
|
|
1034
|
+
applyToMatching(store, exactKey, matches, (entry) =>
|
|
1035
|
+
applyAmend(store, entry, updater as (current: unknown) => unknown, isValue),
|
|
1036
|
+
)
|
|
939
1037
|
}
|
|
940
1038
|
}
|
|
941
1039
|
|
|
942
|
-
cache.
|
|
1040
|
+
cache.amend = amend
|
|
943
1041
|
|
|
944
1042
|
/*
|
|
945
1043
|
Synchronous, non-triggering value probe: returns the currently-retained value for
|
|
@@ -947,7 +1045,7 @@ a call, or undefined when nothing is retained (no entry, or not yet settled). Ne
|
|
|
947
1045
|
invokes — reading it opens no fetch. Reactive: it subscribes the key AND taps the
|
|
948
1046
|
key's lifecycle channel (the exact key is a one-entry prefix, same as
|
|
949
1047
|
pending(fn, args)) so a state.computed / on / template scope re-runs both when the
|
|
950
|
-
value changes (invalidate,
|
|
1048
|
+
value changes (invalidate, amend) and when a retained value lands —
|
|
951
1049
|
materializeRetained's async decode signals only markLifecycle, no invalidate event,
|
|
952
1050
|
so without the lifecycle tap a scope that read undefined would never see the value
|
|
953
1051
|
arrive. Outside a tracking scope it is a one-shot snapshot. Returns a clone (like
|
|
@@ -1003,21 +1101,26 @@ function peek<Args, Return>(
|
|
|
1003
1101
|
cache.peek = peek
|
|
1004
1102
|
|
|
1005
1103
|
/*
|
|
1006
|
-
Applies one entry's
|
|
1104
|
+
Applies one entry's amend: materialize the current decoded value (warm value if
|
|
1007
1105
|
present, else decode the promise — a Response for a remote entry, cloned so the
|
|
1008
1106
|
readers' own clones still succeed), run the updater, store it warm, and emit so
|
|
1009
|
-
readers re-read. Fire-and-forget on the async branch:
|
|
1107
|
+
readers re-read. Fire-and-forget on the async branch: amend() stays sync-return.
|
|
1010
1108
|
*/
|
|
1011
|
-
function
|
|
1109
|
+
function applyAmend(
|
|
1012
1110
|
store: CacheStore,
|
|
1013
1111
|
entry: CacheEntry,
|
|
1014
1112
|
updater: (current: unknown) => unknown,
|
|
1113
|
+
isValue: boolean,
|
|
1015
1114
|
): void {
|
|
1016
1115
|
function apply(current: unknown): void {
|
|
1017
1116
|
entry.value = updater(current)
|
|
1018
1117
|
notify(store, [entry.key], [entry.key])
|
|
1019
1118
|
}
|
|
1020
|
-
|
|
1119
|
+
/* A value-form amend is current-independent (its updater ignores the argument), so set it
|
|
1120
|
+
directly and synchronously like the wire path (amendByKey) — never defer to the in-flight
|
|
1121
|
+
promise. Deferring would drop the value if that fetch rejects (the reject handler below
|
|
1122
|
+
swallows it), diverging local value-amend from the broadcast receive path. */
|
|
1123
|
+
if (isValue || entry.value !== undefined) {
|
|
1021
1124
|
apply(entry.value)
|
|
1022
1125
|
return
|
|
1023
1126
|
}
|
|
@@ -1030,6 +1133,25 @@ function applyPatch(
|
|
|
1030
1133
|
currentValue.then(apply, () => undefined)
|
|
1031
1134
|
}
|
|
1032
1135
|
|
|
1136
|
+
/*
|
|
1137
|
+
Wire-driven value apply (ADR-0043): set the retained value of the entry at an exact
|
|
1138
|
+
cache key and re-render its readers — the receive half of a server amend broadcast. A
|
|
1139
|
+
direct set, not the applyAmend materialize-current dance: the frame carries the
|
|
1140
|
+
authoritative value now, and the receiving subscriber is by construction reading this
|
|
1141
|
+
key. A key with no local entry is a no-op (nothing to amend).
|
|
1142
|
+
*/
|
|
1143
|
+
function amendByKey(key: string, value: unknown): void {
|
|
1144
|
+
for (const store of cacheStores()) {
|
|
1145
|
+
const entry = store.entries.get(key)
|
|
1146
|
+
if (entry !== undefined) {
|
|
1147
|
+
entry.value = value
|
|
1148
|
+
notify(store, [entry.key], [entry.key])
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
cache.amendByKey = amendByKey
|
|
1154
|
+
|
|
1033
1155
|
/*
|
|
1034
1156
|
Event-driven cache maintenance: subscribes to a NamedAsyncIterable (socket or rpc
|
|
1035
1157
|
stream) and runs `handler` once per frame — the declarative home for "this
|
|
@@ -1353,8 +1475,5 @@ function notify(store: CacheStore, marked: string[], emitted: string[]): void {
|
|
|
1353
1475
|
}
|
|
1354
1476
|
|
|
1355
1477
|
function emit(store: CacheStore, keys: string[]): void {
|
|
1356
|
-
if (keys.length === 0) {
|
|
1357
|
-
return
|
|
1358
|
-
}
|
|
1359
1478
|
store.events.dispatchEvent(invalidateEvent(keys))
|
|
1360
1479
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createResolverSlot } from './createResolverSlot.ts'
|
|
2
|
+
import type { CacheReaderHook } from './types/CacheReaderHook.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Client-only seam for the reactive-reader lifecycle (ADR-0043). No fallback: unresolved,
|
|
6
|
+
get() is undefined and the store's engage/disengage calls are inert — so the server's
|
|
7
|
+
request store and isolated unit tests do nothing. The client entry (startClient) installs
|
|
8
|
+
a hook that opens a per-key amend value subscription on a key's first reader and closes it
|
|
9
|
+
on its last, keeping the subscription set congruent with what this tab is reading.
|
|
10
|
+
*/
|
|
11
|
+
export const cacheReaderSocketSlot = createResolverSlot<CacheReaderHook>()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { REACTIVE_CONTEXT } from '../ui/runtime/REACTIVE_CONTEXT.ts'
|
|
2
|
+
import { cacheReaderSocketSlot } from './cacheReaderSocketSlot.ts'
|
|
2
3
|
import { createLifecycleChannel } from './createLifecycleChannel.ts'
|
|
3
4
|
import { createSubscriber } from './createSubscriber.ts'
|
|
4
5
|
import { keyMatchesPrefix } from './keyMatchesPrefix.ts'
|
|
@@ -62,10 +63,17 @@ export function createCacheStore(): CacheStore {
|
|
|
62
63
|
Drop the marker so the tab-scoped store can't accrete one per
|
|
63
64
|
invalidated-but-never-reread key over a session. */
|
|
64
65
|
pendingRefresh.delete(key)
|
|
66
|
+
/* Last reader gone: close any per-key amend subscription opened on engage
|
|
67
|
+
(ADR-0043). Inert unless the client entry installed the hook. */
|
|
68
|
+
cacheReaderSocketSlot.get()?.disengage(key)
|
|
65
69
|
}
|
|
66
70
|
}
|
|
67
71
|
})
|
|
68
72
|
subscribers.set(key, registered)
|
|
73
|
+
/* First reader for this key: open a per-key amend value subscription (ADR-0043), so a
|
|
74
|
+
server amend(args, value) push lands on a key this tab is actually reading. Inert on
|
|
75
|
+
the server / in unit tests (slot unresolved). Paired with disengage in the cleanup. */
|
|
76
|
+
cacheReaderSocketSlot.get()?.engage(key)
|
|
69
77
|
registered()
|
|
70
78
|
}
|
|
71
79
|
|
|
@@ -142,7 +142,10 @@ export function createRemoteFunction<Args, Return>(opts: {
|
|
|
142
142
|
`raw`. There is no call-site options argument any more — all cache policy is read
|
|
143
143
|
from the endpoint (`callable.cache`); per-call transport options live on `.raw`. */
|
|
144
144
|
const key = keyForRemoteCall(method, url, args)
|
|
145
|
-
|
|
145
|
+
/* Thread the derived key into the smart read so readThrough reuses it rather than
|
|
146
|
+
re-deriving the identical string (callable.raw carries the same method/url); the
|
|
147
|
+
key already tags the rpc-error registry entries in the handlers below. */
|
|
148
|
+
return cache.read(callable as RemoteFunction<Args, Return>, args as Args, key).then(
|
|
146
149
|
/* Capture the rejection into the rpc error registry (design Part 4) keyed by call
|
|
147
150
|
identity, and clear it on success — the reactive `fn.error()` probe reads it. */
|
|
148
151
|
(value) => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { encodeRefJson } from './encodeRefJson.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The ONE degradation site for every server→client hydration seed (ADR-0048): ref-json-encode
|
|
5
|
+
a value bound for a warm-seed channel (await resume, cell value, doc snapshot, socket frame,
|
|
6
|
+
streamed-cell chunk), or drop JUST that entry with a warn naming what was dropped and what
|
|
7
|
+
the client does instead. encodeRefJson is total for ordinary graphs (cycles become
|
|
8
|
+
back-references, functions fold to undefined), so a throw is pathological — but it must
|
|
9
|
+
never blank the surrounding payload/stream. Funnelling every channel through here keeps the
|
|
10
|
+
serialize-or-degrade policy, the warn shape, and the per-kind consequence in one place
|
|
11
|
+
instead of seven independently-worded (or silent) drop sites.
|
|
12
|
+
*/
|
|
13
|
+
export function encodeSeedValue(
|
|
14
|
+
value: unknown,
|
|
15
|
+
label: string,
|
|
16
|
+
consequence: string,
|
|
17
|
+
): string | undefined {
|
|
18
|
+
try {
|
|
19
|
+
return encodeRefJson(value)
|
|
20
|
+
} catch (cause) {
|
|
21
|
+
console.warn(`[abide] ${label} is not serializable — ${consequence}.`, cause)
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -85,7 +85,7 @@ function project(
|
|
|
85
85
|
// A `Set`/`Map` rides the wire as a JSON array (ADR-0029 output encode), so the projected schema
|
|
86
86
|
// MUST agree with those bytes — a `Set<T>` is `T[]`, a `Map<K,V>` is `[K,V][]` entries.
|
|
87
87
|
if (symbolName === 'Set' || symbolName === 'ReadonlySet') {
|
|
88
|
-
return
|
|
88
|
+
return projectArray(checker, type, seen)
|
|
89
89
|
}
|
|
90
90
|
if (symbolName === 'Map' || symbolName === 'ReadonlyMap') {
|
|
91
91
|
return projectMap(checker, type, seen)
|
|
@@ -131,7 +131,8 @@ function projectUnion(
|
|
|
131
131
|
return { anyOf }
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/* `T[]`/`readonly T[]` → `{ type: 'array', items: project(T) }`.
|
|
134
|
+
/* `T[]`/`readonly T[]` → `{ type: 'array', items: project(T) }`. Also serves `Set<T>`, which
|
|
135
|
+
rides the wire as `[...set]` (ADR-0029) — the same `T[]` shape. */
|
|
135
136
|
function projectArray(
|
|
136
137
|
checker: ts.TypeChecker,
|
|
137
138
|
type: ts.Type,
|
|
@@ -144,20 +145,6 @@ function projectArray(
|
|
|
144
145
|
return { type: 'array', items }
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
/* `Set<T>`/`readonly Set<T>` → `{ type: 'array', items: project(T) }` — congruent with the
|
|
148
|
-
`wireJsonReplacer` encode (`[...set]`). */
|
|
149
|
-
function projectSet(
|
|
150
|
-
checker: ts.TypeChecker,
|
|
151
|
-
type: ts.Type,
|
|
152
|
-
seen: Set<ts.Type>,
|
|
153
|
-
): Record<string, unknown> {
|
|
154
|
-
const element = checker.getTypeArguments(type as ts.TypeReference)[0]
|
|
155
|
-
seen.add(type)
|
|
156
|
-
const items = element === undefined ? {} : project(checker, element, seen)
|
|
157
|
-
seen.delete(type)
|
|
158
|
-
return { type: 'array', items }
|
|
159
|
-
}
|
|
160
|
-
|
|
161
148
|
/* `Map<K,V>`/`readonly Map<K,V>` → an array of `[K,V]` entry tuples — congruent with the
|
|
162
149
|
`wireJsonReplacer` encode (`[...map]`). Each entry is a fixed 2-tuple, so the item schema is
|
|
163
150
|
positional `prefixItems` with `items: false` to forbid extras (matching projectTuple). */
|
package/src/lib/shared/peek.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cache } from './cache.ts'
|
|
2
2
|
import { isAsyncCell } from './isAsyncCell.ts'
|
|
3
|
+
import { isAsyncIterable } from './isAsyncIterable.ts'
|
|
3
4
|
import type { AsyncComputed } from './types/AsyncComputed.ts'
|
|
4
5
|
import type { RemoteFunction } from './types/RemoteFunction.ts'
|
|
5
6
|
import type { Socket } from './types/Socket.ts'
|
|
@@ -8,7 +9,7 @@ import type { Socket } from './types/Socket.ts'
|
|
|
8
9
|
The value member of the probe family: the currently-retained value, synchronously,
|
|
9
10
|
without triggering anything — `T | undefined` (undefined when nothing is retained
|
|
10
11
|
yet). For a cached read it is the retained cache value (reactive in a tracking
|
|
11
|
-
scope — re-runs when a refresh lands or
|
|
12
|
+
scope — re-runs when a refresh lands or an amend mutates it; a one-shot snapshot
|
|
12
13
|
otherwise). For a subscribable (socket / stream) it is the latest frame, read off
|
|
13
14
|
the source's own `.peek()`. Instance sugar `getFoo.peek(args?)` ≡ `peek(getFoo,
|
|
14
15
|
args?)`, `socket.peek()` ≡ `peek(socket)`.
|
|
@@ -38,12 +39,8 @@ export function peek(source: unknown, args?: unknown): unknown {
|
|
|
38
39
|
/* A subscribable (socket/stream) carries its own latest-frame probe; an rpc does not
|
|
39
40
|
have Symbol.asyncIterator, so this cleanly splits the two even though both expose a
|
|
40
41
|
`.peek` method. */
|
|
41
|
-
if (
|
|
42
|
-
source
|
|
43
|
-
typeof source === 'object' &&
|
|
44
|
-
typeof (source as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function'
|
|
45
|
-
) {
|
|
46
|
-
return (source as { peek: () => unknown }).peek()
|
|
42
|
+
if (isAsyncIterable(source)) {
|
|
43
|
+
return (source as unknown as { peek: () => unknown }).peek()
|
|
47
44
|
}
|
|
48
45
|
return cache.peek(source as RemoteFunction<unknown, unknown>, args as never)
|
|
49
46
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createResolverSlot } from './createResolverSlot.ts'
|
|
2
|
+
import type { SocketTails } from './types/SocketTails.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The active socket-tail slot — mirrors `resolvedCellsSlot`, but deliberately WITHOUT a lazily-created
|
|
6
|
+
shared fallback. `defineSocket`'s server `peek()` records into it on every retained-frame read, and a
|
|
7
|
+
socket peeked outside a request (a ws message handler, boot, cron) has no request store — a shared
|
|
8
|
+
fallback would then accumulate entries no page render ever drains, a slow leak. With no fallback,
|
|
9
|
+
`get()` returns undefined off-request and the record is skipped. During an SSR render the server
|
|
10
|
+
entry's resolver returns the request store's list, which `createUiPageRenderer` drains into
|
|
11
|
+
`__SSR__.sockets`.
|
|
12
|
+
*/
|
|
13
|
+
export const socketTailsSlot = createResolverSlot<SocketTails>()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CacheSelector } from './CacheSelector.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The side-swappable applier stored in amendBroadcastSlot (ADR-0043). Given a selector,
|
|
5
|
+
its args, whether the payload is a concrete value (vs. an updater closure), and the
|
|
6
|
+
payload itself, either apply it to this side's store (client / fallback) or broadcast
|
|
7
|
+
the keyed value to every authorized reader (server). Only the value form is
|
|
8
|
+
broadcastable; the server resolver throws on an updater (a closure has no wire form).
|
|
9
|
+
*/
|
|
10
|
+
export type AmendApply = <Args, Return>(
|
|
11
|
+
selector: CacheSelector<Args, Return>,
|
|
12
|
+
args: Args | undefined,
|
|
13
|
+
isValue: boolean,
|
|
14
|
+
payload: Return | ((current: Return) => Return),
|
|
15
|
+
) => void
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The client-installed reactive-reader lifecycle hook stored in cacheReaderSocketSlot
|
|
3
|
+
(ADR-0043). The cache store calls engage(key) when a key gains its first live reader
|
|
4
|
+
and disengage(key) when it loses its last, so the client can open/close a per-key
|
|
5
|
+
amend value subscription whose lifetime tracks "a reader is holding this key on
|
|
6
|
+
screen". Inert (slot unresolved) on the server and in isolated unit tests.
|
|
7
|
+
*/
|
|
8
|
+
export interface CacheReaderHook {
|
|
9
|
+
engage(key: string): void
|
|
10
|
+
disengage(key: string): void
|
|
11
|
+
}
|