@abide/abide 0.124.0 → 0.125.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 (73) hide show
  1. package/ABIDE.md +4 -0
  2. package/CHANGELOG.md +43 -0
  3. package/bin/abide.ts +3 -2
  4. package/docs/REGISTRY.md +67 -41
  5. package/docs/RULEBOOK.md +207 -20
  6. package/docs/abide.md +392 -68
  7. package/package.json +1 -1
  8. package/src/compiler/Lowering.ts +29 -4
  9. package/src/compiler/appMap.ts +14 -1
  10. package/src/compiler/declaredExports.ts +41 -0
  11. package/src/compiler/handlersUnder.ts +1 -0
  12. package/src/compiler/hookCalls.ts +1 -0
  13. package/src/compiler/projectShadow.ts +53 -2
  14. package/src/compiler/readsIn.ts +39 -34
  15. package/src/server/BRIDGE_VERSION.ts +1 -1
  16. package/src/server/PageRender.ts +7 -8
  17. package/src/server/abideModules.ts +6 -0
  18. package/src/server/answerRequest.ts +115 -52
  19. package/src/server/answerSocket.ts +6 -5
  20. package/src/server/answerWorker.ts +34 -23
  21. package/src/server/assistant.ts +141 -35
  22. package/src/server/banner.ts +9 -5
  23. package/src/server/bridgeRelay.ts +3 -3
  24. package/src/server/callerOf.ts +5 -0
  25. package/src/server/claudeCode.ts +20 -11
  26. package/src/server/commands.ts +14 -7
  27. package/src/server/createApp.ts +65 -12
  28. package/src/server/devWorker.ts +18 -16
  29. package/src/server/generatedSurfaces.ts +6 -5
  30. package/src/server/handlerFileMount.ts +2 -0
  31. package/src/server/hooks.ts +54 -8
  32. package/src/server/index.ts +18 -4
  33. package/src/server/invokeHandler.ts +1 -1
  34. package/src/server/mountHandlers.ts +6 -0
  35. package/src/server/principal.ts +3 -2
  36. package/src/server/render.ts +11 -2
  37. package/src/server/requestScope.ts +61 -1
  38. package/src/server/responseRelay.ts +9 -3
  39. package/src/server/serveDev.ts +18 -3
  40. package/src/server/sqliteStore.ts +65 -0
  41. package/src/server/testMount.ts +1 -0
  42. package/src/shared/AssistantEvent.ts +40 -0
  43. package/src/shared/BrowserHooks.ts +73 -0
  44. package/src/shared/CURRENT_ROUTER.ts +3 -1
  45. package/src/shared/Issues.ts +11 -3
  46. package/src/shared/Middleware.ts +9 -9
  47. package/src/shared/UNEXPECTED.ts +1 -1
  48. package/src/shared/answerOf.ts +9 -4
  49. package/src/shared/imagePiece.ts +85 -0
  50. package/src/shared/index.ts +17 -0
  51. package/src/shared/isServer.ts +15 -0
  52. package/src/shared/navigate.ts +10 -3
  53. package/src/shared/onError.ts +51 -0
  54. package/src/shared/onRefusal.ts +47 -0
  55. package/src/shared/online.ts +5 -1
  56. package/src/shared/reactive/Keyed.ts +12 -1
  57. package/src/shared/reactive/Reactive.ts +65 -26
  58. package/src/shared/reactive/cellShape.ts +4 -3
  59. package/src/shared/reactive/poll.ts +49 -0
  60. package/src/shared/reactive/scoped.ts +10 -1
  61. package/src/shared/reactive/scoping.ts +14 -0
  62. package/src/shared/reactive/watch.ts +19 -8
  63. package/src/shared/resultsOf.ts +21 -0
  64. package/src/shared/route.ts +18 -4
  65. package/src/shared/rpc.ts +112 -26
  66. package/src/shared/seededDocument.ts +5 -6
  67. package/src/shared/storedValue.ts +32 -0
  68. package/src/shared/webStore.ts +54 -0
  69. package/src/ui/Router.ts +42 -9
  70. package/src/ui/outboxQueue.ts +1 -1
  71. package/src/ui/wire.ts +4 -4
  72. package/src/shared/KEEP_OPENED.ts +0 -7
  73. /package/src/{server → shared}/IMAGE_TYPES.ts +0 -0
@@ -0,0 +1,15 @@
1
+ // 22.11 — WHICH SIDE THE WORK RUNNING IS ON, read where it runs rather than fixed at build or load.
2
+ //
3
+ // NOT `typeof document`: a test process preloads a DOM and runs both halves of a page through one
4
+ // instance of every module — `App.fetch` and `mount`'s render are a server's, `mount`'s hydration a
5
+ // browser's — so the globals are the same on both sides and a build-time constant is one answer for
6
+ // two. What tells them apart is what the server alone enters: a request scope, or a render (the
7
+ // provider's `serverStops`). A process with no document at all is a server whatever it is doing.
8
+
9
+ import { serverStops } from './reactive/scoping.ts'
10
+
11
+ /** Whether the work running is a server's. */
12
+ export function isServer(): boolean {
13
+ if (typeof document === 'undefined') return true
14
+ return serverStops() !== undefined
15
+ }
@@ -1,7 +1,8 @@
1
1
  import { CURRENT_ROUTER, type NavigateOptions } from './CURRENT_ROUTER.ts'
2
2
 
3
3
  /**
4
- * Navigates the document's router to a URL without reloading; rejects where there is no router.
4
+ * Navigates the document's router to a URL, or steps its history by a number of entries, without
5
+ * reloading; rejects where there is no router.
5
6
  *
6
7
  * 30.7 — NAVIGATES, AND MUST NOT RELOAD THE DOCUMENT. The whole of that clause lives in the client
7
8
  * router; this is the app-facing name, and it sits on the isomorphic seam because that is the
@@ -11,8 +12,13 @@ import { CURRENT_ROUTER, type NavigateOptions } from './CURRENT_ROUTER.ts'
11
12
  * refuses rather than resolving quietly — a `navigate` that no-ops during a render is a page that
12
13
  * renders the wrong thing and says nothing.
13
14
  */
15
+ export function navigate(by: number): Promise<void>
14
16
  export function navigate(
15
- url: URL | string = '',
17
+ url?: URL | string,
18
+ options?: NavigateOptions,
19
+ ): Promise<void>
20
+ export function navigate(
21
+ to: URL | string | number = '',
16
22
  options: NavigateOptions = {},
17
23
  ): Promise<void> {
18
24
  const router = CURRENT_ROUTER.current
@@ -22,5 +28,6 @@ export function navigate(
22
28
  'navigate() was called with no router in this document. A browser gets one from the routes the build emitted; a server render has no document to navigate (RULEBOOK 30.7).',
23
29
  ),
24
30
  )
25
- return router.go(url, options)
31
+ // 30.38 — A NUMBER IS A STEP THROUGH THE HISTORY, as `history.go` takes one.
32
+ return typeof to === 'number' ? router.step(to) : router.go(to, options)
26
33
  }
@@ -0,0 +1,51 @@
1
+ // 37.16, 37.17, 37.18 — THE BROWSER'S `onError`: the name and the signature `@abide/abide/server`'s
2
+ // has, called in a `<script>` rather than a `<script server>`, and filed while the component that
3
+ // called it stands — so a layout's hears every page below it, as the server's hears every route
4
+ // below it. A server's is `#server/hooks.ts`'s, which `@abide/abide/server` exports over this one;
5
+ // this one called in a server's render files nothing.
6
+ //
7
+ // WHAT NOTHING CAUGHT IS THE BROWSER'S OWN ACCOUNT: the window's `error` event and its
8
+ // `unhandledrejection`, which a call's refusal no handler awaited or caught arrives as. 12.6's
9
+ // throws are handed through `UNEXPECTED` as they are on a server, and the one of them that then
10
+ // surfaces as uncaught (12.7) is not handed a second time.
11
+
12
+ import { BrowserHooks } from './BrowserHooks.ts'
13
+ import { UNEXPECTED } from './UNEXPECTED.ts'
14
+
15
+ const HOOKS = new BrowserHooks<[error: unknown]>(
16
+ 'onError',
17
+ "abide: onError of `@abide/abide` files only in a component's <script>; a <script server> or a server module files the server's, of `@abide/abide/server`. RULEBOOK 37.21.",
18
+ listen,
19
+ )
20
+
21
+ // What 12.6 handed and 12.7 may yet surface as uncaught, held until the task that handed it is over.
22
+ const SURFACING: unknown[] = []
23
+
24
+ /** Files `hook` for every failure nothing caught while the calling component stands; returns what unfiles it. */
25
+ export function onError(hook: (error: unknown) => unknown): () => void {
26
+ return HOOKS.file(hook)
27
+ }
28
+
29
+ function listen(): void {
30
+ // A SERVER'S SLOT IS LEFT AS IT IS: a test with a document and a server has both lanes.
31
+ UNEXPECTED.report ??= handedUnexpected
32
+ addEventListener('error', (event) => surfaced((event as ErrorEvent).error))
33
+ addEventListener('unhandledrejection', (event) =>
34
+ surfaced((event as PromiseRejectionEvent).reason),
35
+ )
36
+ }
37
+
38
+ function handedUnexpected(error: unknown): void {
39
+ if (SURFACING.length === 0) setTimeout(() => (SURFACING.length = 0))
40
+ SURFACING.push(error)
41
+ HOOKS.hand(error)
42
+ }
43
+
44
+ function surfaced(error: unknown): void {
45
+ const at = SURFACING.indexOf(error)
46
+ if (at !== -1) {
47
+ SURFACING.splice(at, 1)
48
+ return
49
+ }
50
+ HOOKS.hand(error)
51
+ }
@@ -0,0 +1,47 @@
1
+ // 37.22-37.27 — THE BROWSER'S `onRefusal`: filed as the browser's `onError` is, and handed every
2
+ // refusal a call over http is answered with in this browser — a refusal settles as the value's
3
+ // `error` rather than throwing, so `onError` never hears one a page read.
4
+ //
5
+ // HANDED WHERE AN ANSWER BECOMES A `Failed`, `answered` in `answerOf.ts`, which every browser call
6
+ // reads its answer through — a handler's `wire`, a handler abide mounts, and a call `outbox` sends —
7
+ // so no caller carries a branch of its own. An aborted call is never answered (16.49), and a call
8
+ // 16.81 settles to a stand-in is not refused.
9
+
10
+ import { BrowserHooks } from './BrowserHooks.ts'
11
+ import type { Failed } from './Failed.ts'
12
+ import { untracked } from './reactive/tracking.ts'
13
+ import type { Method } from './rpc.ts'
14
+
15
+ export type RefusedCall = { method: Method; url: string }
16
+
17
+ const HOOKS = new BrowserHooks<
18
+ [refusal: Failed<string, unknown>, call: RefusedCall]
19
+ >(
20
+ 'onRefusal',
21
+ "abide: onRefusal files only in a component's <script>. RULEBOOK 37.23.",
22
+ )
23
+
24
+ /** Files `hook` for every refusal a call is answered with while the calling component stands; returns what unfiles it. */
25
+ export function onRefusal(
26
+ hook: (refusal: Failed<string, unknown>, call: RefusedCall) => unknown,
27
+ ): () => void {
28
+ return HOOKS.file(hook)
29
+ }
30
+
31
+ /** 37.24 — hands `refusal`, the answer to `call`, to every `onRefusal` filed. */
32
+ export function refused(
33
+ refusal: Failed<string, unknown>,
34
+ call: RefusedCall,
35
+ ): void {
36
+ if (HOOKS.empty()) return
37
+ // UNTRACKED: an answer the server painted settled is read inside the value's body (41.27), and a
38
+ // hook's read there would subscribe that value to whatever the hook reads.
39
+ untracked(handRefused, refusal, { method: call.method, url: call.url })
40
+ }
41
+
42
+ function handRefused(
43
+ refusal: Failed<string, unknown>,
44
+ call: RefusedCall,
45
+ ): void {
46
+ HOOKS.hand(refusal, call)
47
+ }
@@ -7,12 +7,16 @@
7
7
  // PURE, SO AN APP THAT NEVER NAMES `online` DOES NOT SHIP IT: the listeners are a side effect at
8
8
  // load, and a top-level side effect keeps the module in the shared chunk whether anything reads it.
9
9
 
10
+ // A BODY RATHER THAN A VALUE where there is a window, so each scope's instance asks again: a test
11
+ // process has both a DOM and a server render, and the render's `online` is 23.8's (22.11).
12
+
13
+ import { isServer } from './isServer.ts'
10
14
  import { ambient } from './reactive/index.ts'
11
15
 
12
16
  /** Whether the caller can reach the app, as a `Reactive<boolean>`. */
13
17
  export const online = /* @__PURE__ */ (() => {
14
18
  if (typeof window === 'undefined') return ambient<boolean>(true)
15
- const value = ambient<boolean>(navigator.onLine)
19
+ const value = ambient<boolean>(() => isServer() || navigator.onLine)
16
20
  window.addEventListener('online', () => value.set(true))
17
21
  window.addEventListener('offline', () => value.set(false))
18
22
  return value
@@ -23,6 +23,7 @@ import { KEYED_BRAND } from './KEYED_BRAND.ts'
23
23
  import { matchesPattern } from './matchesPattern.ts'
24
24
  import { NotLanded } from './NotLanded.ts'
25
25
  import { parameterDefaults } from './parameterDefaults.ts'
26
+ import { armPoll, refreshIdle, refuseBadPoll } from './poll.ts'
26
27
  import {
27
28
  abandonDeferred,
28
29
  createReactive,
@@ -344,7 +345,8 @@ export function createKeyed<
344
345
  // 11.45: A KEYED `ttl` IS THE LIFE OF AN ENTRY, not of a production inside one, so it is
345
346
  // taken off the options every entry is built from — forwarded, it would build the
346
347
  // unkeyed mechanism in every entry and evict nothing from this map.
347
- const { ttl, args, throttle, debounce, ...rest } = options ?? {}
348
+ // 7.20 — and `poll`, so one timer walks the entries rather than one per entry.
349
+ const { ttl, args, throttle, debounce, poll, ...rest } = options ?? {}
348
350
  const gate = args as Schema<Args> | undefined
349
351
  const entryOptions = rest
350
352
  // 5.14, at the factory: a keyed value is capped one way or the other.
@@ -375,6 +377,15 @@ export function createKeyed<
375
377
  for (const entry of entries.values()) unwatchStore(entry)
376
378
  })
377
379
 
380
+ // 7.20, 7.23 — one timer over the entries held at each tick: an evicted entry is out of the map.
381
+ if (poll !== undefined) {
382
+ refuseBadPoll(`${label}()`, poll)
383
+ const stop = armPoll(poll, () => {
384
+ for (const entry of entries.values()) refreshIdle(entry)
385
+ })
386
+ if (owning()) owned(stop)
387
+ }
388
+
378
389
  function askMembership(): void {
379
390
  const reader = TRACKING.reader
380
391
  if (reader !== null) trackProbe(membership, reader)
@@ -40,6 +40,7 @@ import { isThenable } from './isThenable.ts'
40
40
  import { NO_INITIAL } from './NO_INITIAL.ts'
41
41
  import { NotLanded } from './NotLanded.ts'
42
42
  import { logOf, PatchLog, type PatchOp, sendInvalidate } from './patchLog.ts'
43
+ import { armPoll, refreshIdle, refuseBadPoll } from './poll.ts'
43
44
  import { REACTIVE_BRAND } from './REACTIVE_BRAND.ts'
44
45
  import { recordWrites } from './recordWrites.ts'
45
46
  import { runInScope } from './scoping.ts'
@@ -55,7 +56,6 @@ import { STREAMING } from './STREAMING.ts'
55
56
  import {
56
57
  CHECK,
57
58
  CLEAN,
58
- detach,
59
59
  DIRTY,
60
60
  dropProbeReader,
61
61
  newSubscriber,
@@ -147,6 +147,13 @@ export type ReactiveOptions<
147
147
  * once — see `expiryFor`.
148
148
  */
149
149
  ttl?: number
150
+ /**
151
+ * A refresh every this many milliseconds.
152
+ *
153
+ * RULEBOOK 7.20 through 7.25: `s.refresh` on a clock, skipped while the value is busy and
154
+ * stopped with the value. Inert on a value with no producer (7.24).
155
+ */
156
+ poll?: number
150
157
  /**
151
158
  * The gate on the way in, run before `transform` on every way in.
152
159
  *
@@ -560,7 +567,11 @@ function markReaders(cell: Cell, wanted: number): void {
560
567
  const cap = below.cap
561
568
  if (cap !== null && below.body !== null) {
562
569
  if (windowHolds(below, cap)) {
563
- cap.owed = true
570
+ // 5.15: the held run is a production held back, so `busy` moves as it is owed.
571
+ if (!cap.owed) {
572
+ cap.owed = true
573
+ probesMoved(below)
574
+ }
564
575
  // BACK TO CLEAN so the next source production marks it again and re-arms.
565
576
  // Left marked it takes this walk's own `state >= wanted` short-circuit,
566
577
  // and the changes after the first become invisible to the window.
@@ -1008,8 +1019,8 @@ function serve(cell: Cell, value: unknown): void {
1008
1019
  // servable — `armNext` reads the deadline off a production that has landed, and from
1009
1020
  // `produce` it would be reading one that had not.
1010
1021
  if (cell.expiry !== null) armNext(cell, cell.expiry)
1011
- // 8.8: every production that reaches a reader is written, and nothing else is — a write the
1012
- // identity gate collapsed never arrives here.
1022
+ // 8.8: every production that reaches a reader is written but the two `persist` withholds, and
1023
+ // nothing else is — a write the identity gate collapsed never arrives here.
1013
1024
  if (cell.store !== null) persist(cell, cell.store, value)
1014
1025
  }
1015
1026
 
@@ -1174,6 +1185,8 @@ function openWindow(cell: Cell, cap: Cap): void {
1174
1185
  cell.state = DIRTY
1175
1186
  cell.direct = false
1176
1187
  cascade(cell)
1188
+ // The close is a `busy` edge, as a held production's is.
1189
+ probesMoved(cell)
1177
1190
  return
1178
1191
  }
1179
1192
  // A throttle's window ending with nothing held is the window simply over: the
@@ -1671,7 +1684,8 @@ function run(cell: Cell): void {
1671
1684
  // where the cascade would try: `a` reads `b` and `b` reads `a`. Without this the
1672
1685
  // recursion is unbounded and the failure is a stack overflow rather than a value.
1673
1686
  if (cell.running) return
1674
- // A released orphan read again (12.14) — see `tombstone`.
1687
+ // A released orphan read again (12.14) — see `tombstone`. A released stream's run is its next
1688
+ // start: the generator its body hands back is promoted onto the stream it already has (9.6).
1675
1689
  if (!cell.alive) revive(cell as unknown as Subscriber)
1676
1690
  cell.generation += 1
1677
1691
  cell.state = CLEAN
@@ -1841,11 +1855,13 @@ function landOpening(cell: Cell): void {
1841
1855
  * WHAT STARTS A PRODUCER, for the three callers that start one: construction, `refresh`
1842
1856
  * and `invalidate`. A stream is dispatched HERE rather than inside `run`, which a recompute
1843
1857
  * walks per node per write — and a stream is never recomputed, having no tracked source to
1844
- * be marked by (14.13).
1858
+ * be marked by (14.13). A RELEASED stream (12.16) is the exception: it is owed a run, and the
1859
+ * run is what revives it and settles the debt, where a start here would leave the debt for the
1860
+ * next read to start it a second time.
1845
1861
  */
1846
1862
  function startProducer(cell: Cell): void {
1847
1863
  const stream = ownStream(cell)
1848
- if (stream === null) run(cell)
1864
+ if (stream === null || !cell.alive) run(cell)
1849
1865
  else startStream(cell, stream)
1850
1866
  }
1851
1867
 
@@ -2465,6 +2481,11 @@ let PROBE_PASS = 0
2465
2481
  */
2466
2482
  function walkBusy(cell: Cell, pass: number): boolean {
2467
2483
  if (cell.loading) return true
2484
+ // 5.15 over a body: a run the window owes. Read here rather than written into `loading`,
2485
+ // which a load ending inside the window clears while the run is still owed. Only a value with
2486
+ // sources is ever owed, so `busyNow`'s leaf answer never needs it.
2487
+ const cap = cell.cap
2488
+ if (cap !== null && cap.owed) return true
2468
2489
  const adoption = cell.adoption
2469
2490
  if (cell.sources.length === 0 && adoption === null) return false
2470
2491
  if (cell.probePass === pass) return false
@@ -3192,11 +3213,19 @@ function settleRestore(
3192
3213
  restore(cell, persisted, value)
3193
3214
  return
3194
3215
  }
3195
- // A MISS IS THE VALUE WITHOUT A STORE: the body runs, or the initial value lands (8.3).
3216
+ // A MISS IS THE VALUE WITHOUT A STORE: the body runs, or the initial value lands (8.3). The
3217
+ // body's run is a production the store is filled from; the initial value is the declaration,
3218
+ // and writing it back on every miss is what a server render did to a browser's store (8.8).
3196
3219
  if (cell.body !== null) startProducer(cell)
3197
3220
  else if (initial === undefined) landOpening(cell)
3198
- else if (initial !== NO_INITIAL) open(cell, initial)
3199
- else endLoad(cell, false)
3221
+ else if (initial !== NO_INITIAL) {
3222
+ persisted.withheld = true
3223
+ try {
3224
+ open(cell, initial)
3225
+ } finally {
3226
+ persisted.withheld = false
3227
+ }
3228
+ } else endLoad(cell, false)
3200
3229
  }
3201
3230
 
3202
3231
  /**
@@ -3207,13 +3236,13 @@ function settleRestore(
3207
3236
  * it back.
3208
3237
  */
3209
3238
  function restore(cell: Cell, persisted: Persisted, value: unknown): void {
3210
- persisted.restoring = true
3239
+ persisted.withheld = true
3211
3240
  try {
3212
3241
  const stream = cell.stream
3213
3242
  if (stream === null) produce(cell, value)
3214
3243
  else restoreStream(cell, stream, value as unknown[])
3215
3244
  } finally {
3216
- persisted.restoring = false
3245
+ persisted.withheld = false
3217
3246
  }
3218
3247
  }
3219
3248
 
@@ -3252,7 +3281,7 @@ function restoreStream(cell: Cell, stream: Stream, chunks: unknown[]): void {
3252
3281
  * asked at all and the written value stands in its place. Asking took the value BACKWARDS and left
3253
3282
  * it there: `s.set(99)` over a store still holding 1 leaves a `set` in flight, a `refresh` in the
3254
3283
  * same turn read 1 and produced it, and the queued write then landed 99 in the store — the cell
3255
- * serving 1 and the store holding 99 for the life of the value, with `restoring` stopping the
3284
+ * serving 1 and the store holding 99 for the life of the value, with `withheld` stopping the
3256
3285
  * reverted value from being written back and no probe moving to say so.
3257
3286
  */
3258
3287
  function rereadStore(cell: Cell, persisted: Persisted): void {
@@ -3342,13 +3371,12 @@ function settleMiss(cell: Cell): void {
3342
3371
  }
3343
3372
 
3344
3373
  /**
3345
- * Writes a production to the store, unless it is a restore or a stream's.
3374
+ * Writes a production to the store, unless 8.8 withholds it or it is a stream's.
3346
3375
  *
3347
- * A restore is what was just read, and a stream is written at its close (8.9) — neither is
3348
- * persisted from here.
3376
+ * A stream is written at its close (8.9), not from here.
3349
3377
  */
3350
3378
  function persist(cell: Cell, persisted: Persisted, value: unknown): void {
3351
- if (persisted.restoring || cell.stream !== null) return
3379
+ if (persisted.withheld || cell.stream !== null) return
3352
3380
  writeStore(cell, persisted, value)
3353
3381
  }
3354
3382
 
@@ -3863,22 +3891,22 @@ function orphan(cell: Cell): void {
3863
3891
  * Left subscribed, a value a dropped row built stayed on its sources' reader lists for the life of
3864
3892
  * the page, and an adopting one's mirror ran on every write to what it adopted. Released, it holds
3865
3893
  * what it last served; a read that reaches it later — it escaped its owner — runs the body again
3866
- * and subscribes afresh, so it is never stale, and from then on it is nobody's to release. A
3867
- * stream is stopped rather than owed: it is never recomputed (14.13), only started.
3894
+ * and subscribes afresh, so it is never stale, and from then on it is nobody's to release. 12.16 — a
3895
+ * stream's run is stopped and it is owed the same run: the body's next start (9.6), which serves
3896
+ * what the stopped one held until it says something (9.7) — a stream nothing pumps is the one value
3897
+ * a later read could never bring current.
3868
3898
  */
3869
3899
  function releaseOrphan(cell: Cell): void {
3870
3900
  cell.orphaned = false
3871
- const stream = cell.stream
3872
3901
  const own = ownStream(cell)
3873
3902
  if (own !== null) stopStream(own)
3874
3903
  dropAdoption(cell)
3875
3904
  cell.aborted = false
3876
- if (cell.body !== null && stream === null) {
3877
- // OWED A RUN, so a read that reaches it again runs it — and `run` revives it first.
3878
- tombstone(cell as unknown as Subscriber)
3879
- cell.state = DIRTY
3880
- cell.direct = false
3881
- } else detach(cell as unknown as Subscriber)
3905
+ // OWED A RUN, so a read that reaches it again runs it — and `run` revives it first. No `body`
3906
+ // test: only a value with one is ever orphaned (`createReactive`).
3907
+ tombstone(cell as unknown as Subscriber)
3908
+ cell.state = DIRTY
3909
+ cell.direct = false
3882
3910
  }
3883
3911
 
3884
3912
  RELEASING.unread = (source: Tracked): void => releaseOrphan(source as Cell)
@@ -3976,5 +4004,16 @@ export function createReactive<
3976
4004
  else if (initial !== NO_INITIAL) open(cell, initial)
3977
4005
  } else if (deferred) cell.loading = true
3978
4006
  else startProducer(cell)
4007
+ // 7.20, 7.23, 7.24 — a `Keyed` takes `poll` off its entries' options and polls them itself.
4008
+ const poll = options?.poll
4009
+ if (poll !== undefined) {
4010
+ refuseBadPoll(label, poll)
4011
+ if (reloads(cell)) {
4012
+ const stop = armPoll(poll, () =>
4013
+ refreshIdle(self as unknown as Reactive<unknown>),
4014
+ )
4015
+ if (owning()) owned(stop)
4016
+ }
4017
+ }
3979
4018
  return self as unknown as Reactive<Stored, Accepted, Failures, Produced>
3980
4019
  }
@@ -102,9 +102,10 @@ export type Persisted = {
102
102
  /** The value's own `ttl`, taken once, and infinity where it declares none (8.13). */
103
103
  ttl: number
104
104
  /**
105
- * Set while a restore mints its production, so what was just read is not written back (8.8).
105
+ * Set while a production 8.8 does not write back is minted: a restore, which is what was just
106
+ * read, and the initial value a miss falls back to, which the store never held.
106
107
  */
107
- restoring: boolean
108
+ withheld: boolean
108
109
  writing: boolean
109
110
  pending: boolean
110
111
  /**
@@ -601,7 +602,7 @@ export class Cell {
601
602
  ? store(args as never)
602
603
  : store) as Store<unknown>,
603
604
  ttl: options?.ttl ?? Number.POSITIVE_INFINITY,
604
- restoring: false,
605
+ withheld: false,
605
606
  writing: false,
606
607
  pending: false,
607
608
  unconfirmed: undefined,
@@ -0,0 +1,49 @@
1
+ // A REFRESH ON A CLOCK. RULEBOOK 7.20 through 7.25: `poll` is `s.refresh` (7.4) run every `poll`
2
+ // milliseconds, which is what an app otherwise writes as a `setInterval` in `onStart` and a
3
+ // `clearInterval` in `onStop` — and then has to remember for every value, keyed entry and request.
4
+ //
5
+ // NO FIELD ON THE CELL. The timer's stop is handed to whatever ends the value — its owner, or its
6
+ // `Keyed` — so a value declaring no `poll` carries nothing for it, and the read and write paths
7
+ // never see it. A `Keyed` polls with ONE timer over the entries it holds at each tick, so an entry
8
+ // its `ttl` evicted is out of the walk by being out of the map.
9
+
10
+ import { LONGEST_DELAY } from '../LONGEST_DELAY.ts'
11
+ import type { Disposer } from './watch.ts'
12
+
13
+ /**
14
+ * Throws where `poll` is not a positive number of milliseconds (7.25).
15
+ *
16
+ * At construction, as 5.14 is: a zero or negative interval is a refresh per turn, and the app
17
+ * still has a stack for it here.
18
+ */
19
+ export function refuseBadPoll(label: string, poll: number | undefined): void {
20
+ if (poll !== undefined && !(poll > 0))
21
+ throw new Error(
22
+ `${label} declares poll: ${poll}. A poll is a positive number of milliseconds.`,
23
+ )
24
+ }
25
+
26
+ /**
27
+ * Runs `tick` every `poll` milliseconds, never holding the process open, and answers its stop.
28
+ *
29
+ * 7.22 FOLLOWS 6.4's FEATURE TEST: Bun's `Timer` carries `unref`, a browser's number does not and
30
+ * has no process to hold. `Infinity` arms nothing; a finite interval past what a timer holds runs
31
+ * at the longest one it does.
32
+ */
33
+ export function armPoll(poll: number, tick: () => void): Disposer {
34
+ if (poll === Number.POSITIVE_INFINITY) return () => {}
35
+ const timer = setInterval(tick, Math.min(poll, LONGEST_DELAY))
36
+ const unref = (timer as { unref?: () => void }).unref
37
+ if (typeof unref === 'function') unref.call(timer)
38
+ return () => clearInterval(timer)
39
+ }
40
+
41
+ /**
42
+ * Refreshes `value` unless it is busy (7.21).
43
+ *
44
+ * A refresh over a load in flight supersedes it, so a producer slower than its interval would
45
+ * otherwise never land.
46
+ */
47
+ export function refreshIdle(value: { busy(): boolean; refresh(): void }): void {
48
+ if (!value.busy()) value.refresh()
49
+ }
@@ -20,6 +20,7 @@ import { brandedPrototype, MEMBER_KEYS, signalMoved } from './Reactive.ts'
20
20
  import { currentScope, runInScope, SCOPING } from './scoping.ts'
21
21
  import { SELECTION_MEMBERS } from './SELECTION_MEMBERS.ts'
22
22
  import { newTracked, type Tracked, TRACKING, trackProbe } from './tracking.ts'
23
+ import { owning, unowned } from './watch.ts'
23
24
 
24
25
  const UNBUILT: unique symbol = Symbol('abide.unbuilt')
25
26
 
@@ -38,6 +39,11 @@ class Filed {
38
39
  home: unknown
39
40
  make: () => unknown
40
41
  global: boolean
42
+ /**
43
+ * 12.17 — whether the declaration had an owner. A home declared with none — a module's, which is
44
+ * every handler's — is the process's, and is built as one wherever it is first reached.
45
+ */
46
+ declaredOwned: boolean
41
47
  /**
42
48
  * 3.3, 13.19 — what a probe asked before the value was built here subscribes to, moved by the
43
49
  * build: the home's. A scope files its own in `instances` under this one as its key, and drops
@@ -49,6 +55,7 @@ class Filed {
49
55
  this.make = make
50
56
  this.home = lazy ? UNBUILT : make()
51
57
  this.global = global
58
+ this.declaredOwned = owning()
52
59
  this.unbuilt = newTracked()
53
60
  }
54
61
  }
@@ -66,7 +73,9 @@ function instanceOf(filed: Filed): Record<PropertyKey, unknown> {
66
73
  const scope = filed.global ? undefined : currentScope()
67
74
  if (scope === undefined) {
68
75
  if (filed.home === UNBUILT) {
69
- filed.home = runInScope(undefined, () => filed.make(), undefined)
76
+ filed.home = filed.declaredOwned
77
+ ? runInScope(undefined, filed.make, undefined)
78
+ : unowned(() => runInScope(undefined, filed.make, undefined))
70
79
  signalMoved(filed.unbuilt)
71
80
  }
72
81
  return filed.home as Record<PropertyKey, unknown>
@@ -25,6 +25,14 @@ export type Scope = {
25
25
 
26
26
  export type Scoping = {
27
27
  current(): Scope | undefined
28
+ /**
29
+ * The stops of the server work running — a request's scope, or a `render` outside every request —
30
+ * or `undefined` where none is.
31
+ *
32
+ * 12.5 — what a `watch` opened in that work is stopped with. 22.11 — and whether the work running
33
+ * is a server's at all.
34
+ */
35
+ serverStops(): (() => void)[] | undefined
28
36
  /**
29
37
  * Runs `body` with `scope` as the current one, and `undefined` as no scope at all.
30
38
  *
@@ -46,6 +54,12 @@ export function currentScope(): Scope | undefined {
46
54
  return provider === null ? undefined : provider.current()
47
55
  }
48
56
 
57
+ /** What a `watch` opened now is stopped with on a server — 12.5 — or `undefined` in a browser. */
58
+ export function serverStops(): (() => void)[] | undefined {
59
+ const provider = SCOPING.provider
60
+ return provider === null ? undefined : provider.serverStops()
61
+ }
62
+
49
63
  /**
50
64
  * Runs `body` under `scope` and returns what it returns.
51
65
  *
@@ -11,6 +11,7 @@
11
11
  // imports the narrowed form from here. A second argument is the author saying which form
12
12
  // they meant, and it needs nothing from the other module but a type.
13
13
 
14
+ import { isServer } from '../isServer.ts'
14
15
  import { namedLogger } from '../log.ts'
15
16
  import { UNEXPECTED } from '../UNEXPECTED.ts'
16
17
  import { WORK } from '../WORK.ts'
@@ -18,7 +19,7 @@ import { CELL } from './CELL.ts'
18
19
  import type { Callable } from './cellShape.ts'
19
20
  import { NotLanded } from './NotLanded.ts'
20
21
  import type { Reactive } from './Reactive.ts'
21
- import { currentScope, runInScope } from './scoping.ts'
22
+ import { currentScope, runInScope, serverStops } from './scoping.ts'
22
23
  import {
23
24
  detach,
24
25
  newSubscriber,
@@ -49,9 +50,10 @@ function reportThrow(thrown: unknown, message: string): void {
49
50
  /**
50
51
  * Reports an effect's throw, then re-throws it in a browser.
51
52
  *
52
- * 12.7 — A BROWSER HAS NO `onError`, so the throw goes on out to surface as an uncaught error: to
53
- * the caller of `watch` on a first run, and on a rerun to the drain, which reports it and runs the
54
- * rest of its queue (`drainPending`). A server's has been handed it, and re-thrown there it would
53
+ * 12.7 — A BROWSER'S THROW GOES ON OUT to surface as an uncaught error: to the caller of `watch` on
54
+ * a first run, and on a rerun to the drain, which reports it and runs the rest of its queue
55
+ * (`drainPending`) — and its `onError`, handed it already, is not handed it again (`onError.ts`).
56
+ * A server's has been handed it, and re-thrown there it would
55
57
  * fail the handler or the render whose write woke this effect — a second report of one failure,
56
58
  * against code that did not fail.
57
59
  */
@@ -60,7 +62,8 @@ function effectThrew(thrown: unknown): void {
60
62
  thrown,
61
63
  'an effect threw, and its watch is stopped. RULEBOOK 12.6, 12.8.',
62
64
  )
63
- if (typeof document !== 'undefined') throw thrown
65
+ // 22.11, NOT `typeof document`: a test process's in-process render has one, and re-threw.
66
+ if (!isServer()) throw thrown
64
67
  }
65
68
 
66
69
  /**
@@ -104,16 +107,24 @@ export function collectWatches(
104
107
  * Hands `dispose` to the setup being collected, if one is, and to the scope it was opened in, if
105
108
  * there is one; answers it.
106
109
  *
107
- * 12.5 — the scope is a server's request (`currentScope`), which a browser never has.
110
+ * 12.5 — the scope is a server's request, or its render outside every request (`serverStops`),
111
+ * which a browser never has.
108
112
  */
109
113
  export function owned(dispose: Disposer): Disposer {
110
114
  if (OWNING.free) return dispose
111
115
  const stops = OWNING.stops
112
116
  if (stops !== undefined) stops.push(dispose)
113
- currentScope()?.stops.push(dispose)
117
+ serverStops()?.push(dispose)
114
118
  return dispose
115
119
  }
116
120
 
121
+ /**
122
+ * Whether a component's setup is being collected now: a browser's, since a server's never is.
123
+ */
124
+ export function settingUp(): boolean {
125
+ return OWNING.stops !== undefined
126
+ }
127
+
117
128
  /**
118
129
  * Whether something opened now has an owner to be handed to — a setup being collected, or a scope.
119
130
  *
@@ -123,7 +134,7 @@ export function owned(dispose: Disposer): Disposer {
123
134
  export function owning(): boolean {
124
135
  return (
125
136
  !OWNING.free &&
126
- (OWNING.stops !== undefined || currentScope() !== undefined)
137
+ (OWNING.stops !== undefined || serverStops() !== undefined)
127
138
  )
128
139
  }
129
140
 
@@ -0,0 +1,21 @@
1
+ import type { AssistantEvent } from './AssistantEvent.ts'
2
+ import type { Rpc } from './rpc.ts'
3
+
4
+ /** What every call to `handler` in `events` answered, in order, a failed call's left out. 44.51. */
5
+ export function resultsOf<Value>(
6
+ events: readonly AssistantEvent[],
7
+ // Any handler at all: its args, failures and production are no part of what it answered.
8
+ handler: Rpc<Value, never, unknown, unknown>,
9
+ ): Value[] {
10
+ const values: Value[] = []
11
+ for (const event of events)
12
+ if (
13
+ event.type === 'result' &&
14
+ !event.failed &&
15
+ event.handler !== undefined &&
16
+ event.handler.method === handler.method &&
17
+ event.handler.address === handler.address
18
+ )
19
+ values.push(event.value as Value)
20
+ return values
21
+ }