@abide/abide 0.124.1 → 0.125.1

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 (74) hide show
  1. package/ABIDE.md +4 -0
  2. package/CHANGELOG.md +50 -0
  3. package/bin/abide.ts +3 -2
  4. package/docs/REGISTRY.md +67 -40
  5. package/docs/RULEBOOK.md +206 -18
  6. package/docs/abide.md +393 -67
  7. package/package.json +1 -1
  8. package/src/compiler/appMap.ts +14 -1
  9. package/src/compiler/compileApp.ts +1 -0
  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 +52 -2
  14. package/src/compiler/readsIn.ts +39 -34
  15. package/src/server/PageRender.ts +7 -8
  16. package/src/server/abideModules.ts +22 -9
  17. package/src/server/addressed.ts +14 -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/faceOf.ts +3 -1
  30. package/src/server/generatedSurfaces.ts +6 -5
  31. package/src/server/handlerFileMount.ts +2 -0
  32. package/src/server/hooks.ts +54 -8
  33. package/src/server/index.ts +21 -4
  34. package/src/server/invokeHandler.ts +1 -1
  35. package/src/server/mountHandlers.ts +9 -7
  36. package/src/server/principal.ts +3 -2
  37. package/src/server/render.ts +11 -2
  38. package/src/server/requestScope.ts +61 -1
  39. package/src/server/responseRelay.ts +9 -3
  40. package/src/server/serveDev.ts +18 -3
  41. package/src/server/sqliteStore.ts +65 -0
  42. package/src/server/testMount.ts +1 -0
  43. package/src/shared/AssistantEvent.ts +40 -0
  44. package/src/shared/BrowserHooks.ts +73 -0
  45. package/src/shared/CURRENT_ROUTER.ts +3 -1
  46. package/src/shared/Issues.ts +11 -3
  47. package/src/shared/Middleware.ts +14 -7
  48. package/src/shared/UNEXPECTED.ts +1 -1
  49. package/src/shared/answerOf.ts +9 -4
  50. package/src/shared/imagePiece.ts +85 -0
  51. package/src/shared/index.ts +17 -0
  52. package/src/shared/isServer.ts +15 -0
  53. package/src/shared/navigate.ts +10 -3
  54. package/src/shared/onError.ts +51 -0
  55. package/src/shared/onRefusal.ts +47 -0
  56. package/src/shared/online.ts +5 -1
  57. package/src/shared/reactive/Keyed.ts +12 -1
  58. package/src/shared/reactive/Reactive.ts +53 -25
  59. package/src/shared/reactive/cellShape.ts +4 -3
  60. package/src/shared/reactive/poll.ts +49 -0
  61. package/src/shared/reactive/scoped.ts +10 -1
  62. package/src/shared/reactive/scoping.ts +14 -0
  63. package/src/shared/reactive/watch.ts +19 -8
  64. package/src/shared/resultsOf.ts +23 -0
  65. package/src/shared/route.ts +18 -4
  66. package/src/shared/rpc.ts +125 -30
  67. package/src/shared/seededDocument.ts +5 -6
  68. package/src/shared/storedValue.ts +32 -0
  69. package/src/shared/webStore.ts +54 -0
  70. package/src/ui/Router.ts +42 -9
  71. package/src/ui/outboxQueue.ts +1 -1
  72. package/src/ui/wire.ts +4 -4
  73. package/src/shared/KEEP_OPENED.ts +0 -7
  74. /package/src/{server → shared}/IMAGE_TYPES.ts +0 -0
@@ -10,6 +10,12 @@
10
10
  // value (15.1), so it is in this seam rather than the server's: the same object crosses a
11
11
  // wire, fills `s.error` and narrows through `s.isError`.
12
12
 
13
+ // 44 — a conversation as a page sends it and is handed it back: the server's `assistant` yields it.
14
+ export type {
15
+ AssistantContent,
16
+ AssistantEvent,
17
+ AssistantMessage,
18
+ } from './AssistantEvent.ts'
13
19
  // 33.23 — an element's attributes, typed as a component's props.
14
20
  export type { Attributes } from './Attributes.ts'
15
21
  // 32.37 — what a value writes into an attribute, on both sides.
@@ -27,8 +33,11 @@ export { escapeAttribute, escapeStyleValue, escapeText } from './escapeText.ts'
27
33
  export { type Failed, isFailed } from './Failed.ts'
28
34
  // 25 — the account of the app.
29
35
  export { type Health, health } from './health.ts'
36
+ // 44.52-44.55 — a pasted, dropped or picked image as a turn's piece.
37
+ export { imageFiles, imagePiece } from './imagePiece.ts'
30
38
  export type { Issues, Paths } from './Issues.ts'
31
39
  export type { JsonValue } from './JsonValue.ts'
40
+ export { isServer } from './isServer.ts'
32
41
  export { jsonSchema } from './jsonSchema.ts'
33
42
  // 32.15's run-time half. In this seam because BOTH sides ask it what a `{#for}` subject iterates
34
43
  // as — the server's emitted block, the client's `updateList`, and the markup a client builds for a
@@ -52,7 +61,11 @@ export {
52
61
  export { navigate } from './navigate.ts'
53
62
  export { online } from './online.ts'
54
63
  export { outbox, type QueuedCall } from './outbox.ts'
64
+ // 37.16 — the browser's `onError`. `@abide/abide/server` exports the server's over it.
65
+ export { onError } from './onError.ts'
55
66
  export { onMenu } from './onMenu.ts'
67
+ // 37.22 — the browser's `onRefusal`, which has no server's.
68
+ export { onRefusal } from './onRefusal.ts'
56
69
  export { type Principal, principal } from './principal.ts'
57
70
  // EMITTED CODE'S: a prop the child writes (33.6, 33.7), and one it hands where a `Reactive` is due (33.27).
58
71
  export {
@@ -96,6 +109,8 @@ export {
96
109
  watch,
97
110
  } from './reactive/index.ts'
98
111
  export { aborted, notFound, refuse, validationError } from './refuse.ts'
112
+ // 44.51 — what a conversation's calls to one handler answered.
113
+ export { resultsOf } from './resultsOf.ts'
99
114
  export { route } from './route.ts'
100
115
  export type { Schema, StandardSchemaV1 } from './Schema.ts'
101
116
  export { safeUrl } from './safeUrl.ts'
@@ -119,4 +134,6 @@ export {
119
134
  url,
120
135
  } from './url.ts'
121
136
  export { validateJson } from './validateJson.ts'
137
+ // 8.22 — a `Store` in the browser's Web Storage, inert where there is none (8.23).
138
+ export { webStore } from './webStore.ts'
122
139
  export { mutatePath, writePath } from './writePath.ts'
@@ -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
  *
@@ -1012,8 +1019,8 @@ function serve(cell: Cell, value: unknown): void {
1012
1019
  // servable — `armNext` reads the deadline off a production that has landed, and from
1013
1020
  // `produce` it would be reading one that had not.
1014
1021
  if (cell.expiry !== null) armNext(cell, cell.expiry)
1015
- // 8.8: every production that reaches a reader is written, and nothing else is — a write the
1016
- // 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.
1017
1024
  if (cell.store !== null) persist(cell, cell.store, value)
1018
1025
  }
1019
1026
 
@@ -1677,7 +1684,8 @@ function run(cell: Cell): void {
1677
1684
  // where the cascade would try: `a` reads `b` and `b` reads `a`. Without this the
1678
1685
  // recursion is unbounded and the failure is a stack overflow rather than a value.
1679
1686
  if (cell.running) return
1680
- // 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).
1681
1689
  if (!cell.alive) revive(cell as unknown as Subscriber)
1682
1690
  cell.generation += 1
1683
1691
  cell.state = CLEAN
@@ -1847,11 +1855,13 @@ function landOpening(cell: Cell): void {
1847
1855
  * WHAT STARTS A PRODUCER, for the three callers that start one: construction, `refresh`
1848
1856
  * and `invalidate`. A stream is dispatched HERE rather than inside `run`, which a recompute
1849
1857
  * walks per node per write — and a stream is never recomputed, having no tracked source to
1850
- * 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.
1851
1861
  */
1852
1862
  function startProducer(cell: Cell): void {
1853
1863
  const stream = ownStream(cell)
1854
- if (stream === null) run(cell)
1864
+ if (stream === null || !cell.alive) run(cell)
1855
1865
  else startStream(cell, stream)
1856
1866
  }
1857
1867
 
@@ -3203,11 +3213,19 @@ function settleRestore(
3203
3213
  restore(cell, persisted, value)
3204
3214
  return
3205
3215
  }
3206
- // 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).
3207
3219
  if (cell.body !== null) startProducer(cell)
3208
3220
  else if (initial === undefined) landOpening(cell)
3209
- else if (initial !== NO_INITIAL) open(cell, initial)
3210
- 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)
3211
3229
  }
3212
3230
 
3213
3231
  /**
@@ -3218,13 +3236,13 @@ function settleRestore(
3218
3236
  * it back.
3219
3237
  */
3220
3238
  function restore(cell: Cell, persisted: Persisted, value: unknown): void {
3221
- persisted.restoring = true
3239
+ persisted.withheld = true
3222
3240
  try {
3223
3241
  const stream = cell.stream
3224
3242
  if (stream === null) produce(cell, value)
3225
3243
  else restoreStream(cell, stream, value as unknown[])
3226
3244
  } finally {
3227
- persisted.restoring = false
3245
+ persisted.withheld = false
3228
3246
  }
3229
3247
  }
3230
3248
 
@@ -3263,7 +3281,7 @@ function restoreStream(cell: Cell, stream: Stream, chunks: unknown[]): void {
3263
3281
  * asked at all and the written value stands in its place. Asking took the value BACKWARDS and left
3264
3282
  * it there: `s.set(99)` over a store still holding 1 leaves a `set` in flight, a `refresh` in the
3265
3283
  * same turn read 1 and produced it, and the queued write then landed 99 in the store — the cell
3266
- * 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
3267
3285
  * reverted value from being written back and no probe moving to say so.
3268
3286
  */
3269
3287
  function rereadStore(cell: Cell, persisted: Persisted): void {
@@ -3353,13 +3371,12 @@ function settleMiss(cell: Cell): void {
3353
3371
  }
3354
3372
 
3355
3373
  /**
3356
- * 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.
3357
3375
  *
3358
- * A restore is what was just read, and a stream is written at its close (8.9) — neither is
3359
- * persisted from here.
3376
+ * A stream is written at its close (8.9), not from here.
3360
3377
  */
3361
3378
  function persist(cell: Cell, persisted: Persisted, value: unknown): void {
3362
- if (persisted.restoring || cell.stream !== null) return
3379
+ if (persisted.withheld || cell.stream !== null) return
3363
3380
  writeStore(cell, persisted, value)
3364
3381
  }
3365
3382
 
@@ -3874,22 +3891,22 @@ function orphan(cell: Cell): void {
3874
3891
  * Left subscribed, a value a dropped row built stayed on its sources' reader lists for the life of
3875
3892
  * the page, and an adopting one's mirror ran on every write to what it adopted. Released, it holds
3876
3893
  * what it last served; a read that reaches it later — it escaped its owner — runs the body again
3877
- * and subscribes afresh, so it is never stale, and from then on it is nobody's to release. A
3878
- * 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.
3879
3898
  */
3880
3899
  function releaseOrphan(cell: Cell): void {
3881
3900
  cell.orphaned = false
3882
- const stream = cell.stream
3883
3901
  const own = ownStream(cell)
3884
3902
  if (own !== null) stopStream(own)
3885
3903
  dropAdoption(cell)
3886
3904
  cell.aborted = false
3887
- if (cell.body !== null && stream === null) {
3888
- // OWED A RUN, so a read that reaches it again runs it — and `run` revives it first.
3889
- tombstone(cell as unknown as Subscriber)
3890
- cell.state = DIRTY
3891
- cell.direct = false
3892
- } 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
3893
3910
  }
3894
3911
 
3895
3912
  RELEASING.unread = (source: Tracked): void => releaseOrphan(source as Cell)
@@ -3987,5 +4004,16 @@ export function createReactive<
3987
4004
  else if (initial !== NO_INITIAL) open(cell, initial)
3988
4005
  } else if (deferred) cell.loading = true
3989
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
+ }
3990
4018
  return self as unknown as Reactive<Stored, Accepted, Failures, Produced>
3991
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