@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.
Files changed (129) hide show
  1. package/AGENTS.md +330 -494
  2. package/CHANGELOG.md +106 -0
  3. package/README.md +166 -146
  4. package/package.json +3 -4
  5. package/src/checkAbide.ts +1 -1
  6. package/src/lib/server/rpc/defineRpc.ts +2 -1
  7. package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
  8. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
  9. package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
  10. package/src/lib/server/runtime/createUiPageRenderer.ts +54 -20
  11. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  12. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  13. package/src/lib/server/sockets/amendFamilyEntry.ts +39 -0
  14. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  15. package/src/lib/server/sockets/defineSocket.ts +11 -1
  16. package/src/lib/shared/AMEND_TOPIC_PREFIX.ts +9 -0
  17. package/src/lib/shared/SEEDS.ts +33 -0
  18. package/src/lib/shared/SOCKET_SEED.ts +12 -0
  19. package/src/lib/shared/amend.ts +48 -0
  20. package/src/lib/shared/amendBroadcastSlot.ts +19 -0
  21. package/src/lib/shared/applyAmendLocally.ts +20 -0
  22. package/src/lib/shared/attachRpcSelectorMethods.ts +5 -5
  23. package/src/lib/shared/buildSocketOverChannel.ts +9 -2
  24. package/src/lib/shared/cache.ts +203 -84
  25. package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
  26. package/src/lib/shared/createCacheStore.ts +8 -0
  27. package/src/lib/shared/createRemoteFunction.ts +4 -1
  28. package/src/lib/shared/encodeSeedValue.ts +24 -0
  29. package/src/lib/shared/jsonSchemaForType.ts +3 -16
  30. package/src/lib/shared/peek.ts +4 -7
  31. package/src/lib/shared/socketTailsSlot.ts +13 -0
  32. package/src/lib/shared/types/AmendApply.ts +15 -0
  33. package/src/lib/shared/types/CacheReaderHook.ts +11 -0
  34. package/src/lib/shared/types/RemoteFunction.ts +30 -19
  35. package/src/lib/shared/types/SocketTails.ts +13 -0
  36. package/src/lib/shared/types/SsrPayload.ts +6 -0
  37. package/src/lib/test/createTestApp.ts +6 -0
  38. package/src/lib/ui/amendReaderHook.ts +125 -0
  39. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
  40. package/src/lib/ui/compile/analyzeComponent.ts +0 -2
  41. package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
  42. package/src/lib/ui/compile/cachedShadowProgram.ts +39 -0
  43. package/src/lib/ui/compile/compileComponent.ts +1 -3
  44. package/src/lib/ui/compile/componentOrdinals.ts +39 -0
  45. package/src/lib/ui/compile/desugarSignals.ts +65 -36
  46. package/src/lib/ui/compile/generateBuild.ts +25 -24
  47. package/src/lib/ui/compile/generateSSR.ts +37 -24
  48. package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
  49. package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
  50. package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
  51. package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
  52. package/src/lib/ui/compile/lowerContext.ts +26 -6
  53. package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
  54. package/src/lib/ui/compile/lowerScript.ts +1 -11
  55. package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
  56. package/src/lib/ui/compile/renameSignalRefs.ts +25 -45
  57. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +10 -15
  58. package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
  59. package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
  60. package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
  61. package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
  62. package/src/lib/ui/compile/wrapSeed.ts +28 -0
  63. package/src/lib/ui/computed.ts +16 -0
  64. package/src/lib/ui/createScope.ts +4 -3
  65. package/src/lib/ui/dom/anchorCursor.ts +2 -1
  66. package/src/lib/ui/dom/anchoredBranch.ts +14 -3
  67. package/src/lib/ui/dom/appendStatic.ts +11 -20
  68. package/src/lib/ui/dom/appendText.ts +41 -43
  69. package/src/lib/ui/dom/appendTextAt.ts +10 -57
  70. package/src/lib/ui/dom/awaitBlock.ts +31 -45
  71. package/src/lib/ui/dom/awaitSubject.ts +118 -0
  72. package/src/lib/ui/dom/claimText.ts +38 -0
  73. package/src/lib/ui/dom/cloneStatic.ts +2 -8
  74. package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
  75. package/src/lib/ui/dom/discardBoundary.ts +5 -4
  76. package/src/lib/ui/dom/each.ts +3 -5
  77. package/src/lib/ui/dom/fillBoundary.ts +2 -1
  78. package/src/lib/ui/dom/hydrate.ts +5 -12
  79. package/src/lib/ui/dom/mountChild.ts +73 -5
  80. package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
  81. package/src/lib/ui/dom/openMarker.ts +2 -4
  82. package/src/lib/ui/dom/outlet.ts +4 -3
  83. package/src/lib/ui/dom/readCell.ts +26 -11
  84. package/src/lib/ui/dom/skeleton.ts +2 -7
  85. package/src/lib/ui/dom/switchBlock.ts +2 -1
  86. package/src/lib/ui/dom/tryBlock.ts +27 -39
  87. package/src/lib/ui/dom/when.ts +6 -2
  88. package/src/lib/ui/linked.ts +14 -0
  89. package/src/lib/ui/resumeSeedScript.ts +1 -1
  90. package/src/lib/ui/router.ts +12 -16
  91. package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
  92. package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
  93. package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
  94. package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
  95. package/src/lib/ui/runtime/RENDER.ts +7 -5
  96. package/src/lib/ui/runtime/RESUME.ts +5 -7
  97. package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
  98. package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
  99. package/src/lib/ui/runtime/claimChild.ts +5 -7
  100. package/src/lib/ui/runtime/claimExpected.ts +2 -6
  101. package/src/lib/ui/runtime/claimMarker.ts +13 -0
  102. package/src/lib/ui/runtime/claimRun.ts +22 -0
  103. package/src/lib/ui/runtime/consumeSeed.ts +38 -0
  104. package/src/lib/ui/runtime/createAsyncCell.ts +56 -8
  105. package/src/lib/ui/runtime/createComputedNode.ts +1 -0
  106. package/src/lib/ui/runtime/createDoc.ts +5 -1
  107. package/src/lib/ui/runtime/createEffectNode.ts +1 -0
  108. package/src/lib/ui/runtime/createSignalNode.ts +1 -0
  109. package/src/lib/ui/runtime/parkCursor.ts +10 -0
  110. package/src/lib/ui/runtime/readNode.ts +21 -1
  111. package/src/lib/ui/runtime/renderPath.ts +2 -2
  112. package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
  113. package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
  114. package/src/lib/ui/runtime/runNode.ts +14 -0
  115. package/src/lib/ui/runtime/types/Cell.ts +4 -2
  116. package/src/lib/ui/runtime/types/Doc.ts +3 -1
  117. package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
  118. package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
  119. package/src/lib/ui/settleAsyncCells.ts +30 -3
  120. package/src/lib/ui/socketProxy.ts +17 -1
  121. package/src/lib/ui/startClient.ts +23 -0
  122. package/src/lib/ui/tryEncodeResume.ts +6 -15
  123. package/src/lib/ui/types/Scope.ts +1 -1
  124. package/src/lib/ui/watch.ts +21 -16
  125. package/src/serverEntry.ts +15 -0
  126. package/src/lib/shared/patch.ts +0 -41
  127. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
  128. package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
  129. package/src/lib/ui/dom/readCellBlocking.ts +0 -29
@@ -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>(fn: RemoteFunction<Args, Return>, args?: Args): Promise<Return> {
182
- return readThrough(fn, args, undefined, true) as Promise<Return>
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
- (!replayable ||
248
- (!policy?.shared && typeof window === 'undefined' && store === sharedCacheStore()))
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
- const key = keyForRemoteCall(remote.method, remote.url, args)
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 and the label both consume it. */
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(selectorMatcher(arg, args, prefix), selectorLabel(arg, args, prefix), prefix)
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 — the single apply-by-matcher seam so a local invalidate() and a
767
- wire-driven one (a server broadcast decoded via matcherFromEnvelope) run the EXACT
768
- same drop loop and can't diverge (ADR-0041). `label` feeds the cycle tripwire;
769
- `prefix` (undefined for a tag selector) resets recorded rpc errors for the selector.
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
- /* Deleting the current entry mid-iteration is spec-safe on a Map; no snapshot needed. */
786
- for (const entry of store.entries.values()) {
787
- if (!matches(entry)) {
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(selectorMatcher(arg, args, prefix), selectorLabel(arg, args, prefix), prefix)
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
- one refetch loop (ADR-0041).
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
- for (const entry of store.entries.values()) {
875
- if (!matches(entry)) {
876
- continue
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 patch lands). Follows the selector grammar, but only fn / args
924
- / tags select a value to mutate; a not-yet-read key has nothing to patch.
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 patch<Args, Return>(
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
- for (const entry of store.entries.values()) {
935
- if (matches(entry)) {
936
- applyPatch(store, entry, updater as (current: unknown) => unknown)
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.patch = patch
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, patch) and when a retained value lands —
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 patch: materialize the current decoded value (warm value if
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: patch() stays sync-return.
1107
+ readers re-read. Fire-and-forget on the async branch: amend() stays sync-return.
1010
1108
  */
1011
- function applyPatch(
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
- if (entry.value !== undefined) {
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
- return cache.read(callable as RemoteFunction<Args, Return>, args as Args).then(
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 projectSet(checker, type, seen)
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). */
@@ -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 a patch mutates it; a one-shot snapshot
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 !== null &&
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
+ }