@abide/abide 0.103.0 → 0.105.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 (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/abide.ts +80 -173
  3. package/docs/REGISTRY.md +27 -27
  4. package/docs/RULEBOOK.md +68 -24
  5. package/docs/abide.md +31 -46
  6. package/package.json +1 -1
  7. package/src/compiler/Lowering.ts +15 -5
  8. package/src/compiler/Refusals.ts +57 -0
  9. package/src/compiler/TypeSession.ts +46 -89
  10. package/src/compiler/appLayout.ts +447 -0
  11. package/src/compiler/appMap.ts +21 -25
  12. package/src/compiler/checkApp.ts +61 -97
  13. package/src/compiler/checkedShadow.ts +12 -10
  14. package/src/compiler/classifyRead.ts +3 -6
  15. package/src/compiler/compileApp.ts +183 -478
  16. package/src/compiler/componentGraph.ts +2 -71
  17. package/src/compiler/declaredExports.ts +79 -60
  18. package/src/compiler/emitClient.ts +11 -13
  19. package/src/compiler/emitComponent.ts +21 -6
  20. package/src/compiler/emitSource.ts +20 -20
  21. package/src/compiler/handlersUnder.ts +46 -36
  22. package/src/compiler/hookCalls.ts +152 -0
  23. package/src/compiler/mountsOf.ts +31 -54
  24. package/src/compiler/projectShadow.ts +1 -1
  25. package/src/compiler/readSources.ts +27 -42
  26. package/src/compiler/readsIn.ts +5 -16
  27. package/src/compiler/resolverIn.ts +77 -0
  28. package/src/server/abideModules.ts +152 -146
  29. package/src/server/answerRequest.ts +18 -4
  30. package/src/server/answerSocket.ts +136 -76
  31. package/src/server/answerValue.ts +28 -15
  32. package/src/server/appTree.ts +59 -73
  33. package/src/server/claudeApi.ts +2 -5
  34. package/src/server/clientBundle.ts +198 -0
  35. package/src/server/coerceArgs.ts +1 -1
  36. package/src/server/commands.ts +93 -57
  37. package/src/server/createApp.ts +90 -16
  38. package/src/server/deferredBuilds.ts +175 -0
  39. package/src/server/devWorker.ts +107 -319
  40. package/src/server/faceOf.ts +120 -0
  41. package/src/server/fenceServer.ts +10 -18
  42. package/src/server/generatedSurfaces.ts +12 -53
  43. package/src/server/index.ts +3 -0
  44. package/src/server/placement.ts +167 -0
  45. package/src/server/remote.ts +14 -42
  46. package/src/server/render.ts +25 -17
  47. package/src/server/renderComponent.ts +9 -5
  48. package/src/server/requestScope.ts +10 -4
  49. package/src/server/responseRelay.ts +389 -0
  50. package/src/server/runCompiled.ts +9 -12
  51. package/src/server/runDesktop.ts +6 -9
  52. package/src/server/serveBuild.ts +26 -73
  53. package/src/server/serveDev.ts +19 -176
  54. package/src/server/streamEvent.ts +29 -0
  55. package/src/server/versionOf.ts +1 -2
  56. package/src/server/withAbideSources.ts +2 -1
  57. package/src/shared/HandlerRoute.ts +68 -0
  58. package/src/shared/REFUSAL_KEY.ts +5 -0
  59. package/src/shared/RESERVED_DIRECTORIES.ts +6 -0
  60. package/src/shared/answerOf.ts +105 -78
  61. package/src/shared/filledPath.ts +1 -9
  62. package/src/shared/framing.ts +127 -128
  63. package/src/shared/queryText.ts +15 -7
  64. package/src/shared/reactive/Cursor.ts +69 -124
  65. package/src/shared/reactive/Reactive.ts +296 -162
  66. package/src/shared/reactive/callLoad.ts +4 -18
  67. package/src/shared/reactive/index.ts +11 -8
  68. package/src/shared/reactive/pairReactive.ts +1 -1
  69. package/src/shared/reactive/scoped.ts +4 -3
  70. package/src/shared/reactive/share.ts +11 -1
  71. package/src/shared/routePattern.ts +0 -110
  72. package/src/shared/rpc.ts +17 -24
  73. package/src/shared/rpcRequest.ts +18 -47
  74. package/src/shared/takeEarlyCall.ts +12 -37
  75. package/src/shared/url.ts +1 -1
  76. package/src/ui/Level.ts +276 -0
  77. package/src/ui/List.ts +17 -17
  78. package/src/ui/Router.ts +20 -87
  79. package/src/ui/applyHead.ts +92 -79
  80. package/src/ui/index.ts +4 -2
  81. package/src/ui/reconcile.ts +4 -110
  82. package/src/ui/rows.ts +123 -2
  83. package/src/ui/wire.ts +12 -27
  84. package/src/compiler/hooksCalled.ts +0 -35
  85. package/src/server/clientBuildOptions.ts +0 -54
  86. package/src/server/refuseAddressCollision.ts +0 -37
  87. package/src/shared/reactive/chunksOfStream.ts +0 -57
  88. package/src/ui/MOUNTING_LEVEL.ts +0 -18
@@ -1,46 +1,26 @@
1
- // 16.32 — A FRAMED ANSWER, read one JSON value per line: the whole line for jsonl, the `data:` line of
2
- // each event for sse. A line that does not carry the prefix — an sse `event:` line, a blank line — is
3
- // skipped. One line reader behind both a body read to the end and one read as it lands.
1
+ // 16.32 — A FRAMED ANSWER, read one JSON value per frame: each line for jsonl, the `data` of each
2
+ // event for sse. One reader, `frameReader`, behind both a body read to the end and one read as it
3
+ // lands — and behind `abide watch`, which reads an event stream's events by name. The writer is the
4
+ // server's (`server/streamEvent.ts`).
4
5
  //
5
6
  // 16.68 — AND ITS LAST FRAME MAY BE A REFUSAL: one that reached the stream after it had answered, so
6
- // after its status was sent. An sse `event: refusal` and the Problem Details on its `data:` line, or a
7
+ // after its status was sent. An sse `event: refusal` and the Problem Details as its `data`, or a
7
8
  // jsonl line `{"$refusal": <Problem Details>}` — read here as the refusal, thrown where it lands.
8
9
 
9
- import { failedFrom, isProblem, type Problem } from './Failed.ts'
10
+ import { failedFrom, isProblem } from './Failed.ts'
11
+ import { REFUSAL_KEY } from './REFUSAL_KEY.ts'
10
12
 
11
- /** The key a jsonl line carries a refusal under (16.68). */
12
- const REFUSAL_KEY = '$refusal'
13
+ /** The two framings a streamed JSON answer is read in. 17.6. */
14
+ export type Framed = 'jsonl' | 'sse'
13
15
 
14
- /** A framed stream's last frame, as the server writes it for a refusal (16.68). */
15
- export function refusalFrame(problem: Problem, prefix: string): string {
16
- return prefix === ''
17
- ? `${JSON.stringify({ [REFUSAL_KEY]: problem })}\n`
18
- : `event: refusal\n${prefix}${JSON.stringify(problem)}\n\n`
19
- }
20
-
21
- // What a read of one framed body carries from one line to the next: whether an sse event named the
22
- // data line after it a refusal.
23
- type Reading = { refusal: boolean }
24
-
25
- // A refusal's frame, read: thrown where it lands among the chunks.
26
- class Refused {
27
- problem: Problem
28
- constructor(problem: Problem) {
29
- this.problem = problem
30
- }
31
- }
32
-
33
- /**
34
- * What precedes a chunk's JSON on its line: nothing for jsonl, `data: ` for sse, and `null` where
35
- * the answer is not framed at all.
36
- */
37
- export function framingPrefix(type: string | null | undefined): string | null {
16
+ /** How an answer at `type` is framed, or `null` where it is not framed at all. */
17
+ export function framingOf(type: string | null | undefined): Framed | null {
38
18
  if (
39
19
  type?.startsWith('application/jsonl') ||
40
20
  type?.startsWith('application/x-ndjson')
41
21
  )
42
- return ''
43
- if (type?.startsWith('text/event-stream')) return 'data: '
22
+ return 'jsonl'
23
+ if (type?.startsWith('text/event-stream')) return 'sse'
44
24
  return null
45
25
  }
46
26
 
@@ -52,24 +32,102 @@ export function framingPrefix(type: string | null | undefined): string | null {
52
32
  */
53
33
  export function carriesBytes(type: string | null | undefined): boolean {
54
34
  if (type === null || type === undefined || type === '') return false
55
- if (framingPrefix(type) !== null) return false
35
+ if (framingOf(type) !== null) return false
56
36
  const semicolon = type.indexOf(';')
57
37
  const essence = semicolon === -1 ? type : type.slice(0, semicolon)
58
38
  return essence !== 'application/json' && !essence.endsWith('+json')
59
39
  }
60
40
 
41
+ /** One frame: an sse event's name — `message` where it named none — `id` and `data`, or a jsonl line. */
42
+ export type Frame = { name: string; id: string | undefined; data: string }
43
+
44
+ /**
45
+ * A framed body's reader, fed its text as it arrives: pushes each frame the text completes onto
46
+ * `frames` — and, where `ended`, the last one too.
47
+ *
48
+ * An event stream is read as the format says (18.27) — fields by name, a comment skipped, `data`
49
+ * lines joined, the event ending at a blank line. A jsonl line is a frame with nothing but its data.
50
+ * A closure over the read's own state, which is all it captures.
51
+ */
52
+ function frameReader(
53
+ lines: boolean,
54
+ ): (text: string, ended: boolean, frames: Frame[]) => void {
55
+ // The text after the last newline read, held until its line completes.
56
+ let rest = ''
57
+ let name = 'message'
58
+ let id: string | undefined
59
+ let data: string | undefined
60
+ return (text, ended, frames) => {
61
+ // A long line across many reads is held unscanned until its newline lands, or each read
62
+ // rescans everything before it.
63
+ if (!ended && !text.includes('\n')) {
64
+ rest += text
65
+ return
66
+ }
67
+ const read = (rest + text).split('\n')
68
+ // The unfinished last line waits for the next read; at the end, a blank line ends it.
69
+ rest = ended ? '' : (read.pop() as string)
70
+ if (ended) read.push('')
71
+ for (const line of read) {
72
+ // A blank line ends an event; a jsonl line is one.
73
+ if (lines || line === '') {
74
+ const complete = lines ? line || undefined : data
75
+ if (complete !== undefined)
76
+ frames.push({ name, id, data: complete })
77
+ name = 'message'
78
+ id = data = undefined
79
+ continue
80
+ }
81
+ // A field and its value, the one space after the colon dropped and a CRLF's `\r` with
82
+ // it; a line with no colon is a field with no value. A comment — an event stream's
83
+ // opener — names the field ``, which is none.
84
+ const colon = line.indexOf(':')
85
+ const field = colon === -1 ? line : line.slice(0, colon)
86
+ if (field !== 'data' && field !== 'event' && field !== 'id')
87
+ continue
88
+ let start = colon === -1 ? line.length : colon + 1
89
+ if (line.charCodeAt(start) === 32) start += 1
90
+ const value = line.slice(
91
+ start,
92
+ line.charCodeAt(line.length - 1) === 13
93
+ ? line.length - 1
94
+ : line.length,
95
+ )
96
+ if (field === 'data')
97
+ data = data === undefined ? value : `${data}\n${value}`
98
+ else if (field === 'event') name = value
99
+ else id = value
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The chunk a frame carried, or — where it is a refusal (16.68) — that refusal, thrown.
106
+ *
107
+ * Per chunk: the reserved key is tested before any key list is built.
108
+ */
109
+ function chunkOf(frame: Frame, lines: boolean): unknown {
110
+ const value = JSON.parse(frame.data)
111
+ const problem = !lines
112
+ ? frame.name === 'refusal' && value
113
+ : typeof value === 'object' &&
114
+ Object.hasOwn(Object(value), REFUSAL_KEY) &&
115
+ Object.keys(value).length === 1 &&
116
+ value[REFUSAL_KEY]
117
+ if (isProblem(problem)) throw failedFrom(problem)
118
+ return value
119
+ }
120
+
61
121
  /**
62
- * A streamed answer's chunks: each JSON value its framing carried, or, for bytes (16.46), each
63
- * `Uint8Array` as it was read — bytes have no framing to split them back into the chunks the
64
- * handler yielded.
122
+ * A streamed answer's chunks: each JSON value its `framing` carried, or, for bytes (16.46) — an
123
+ * unread body with no framing — each `Uint8Array` as it was read: bytes have no framing to split
124
+ * them back into the chunks the handler yielded.
65
125
  */
66
126
  export function answerChunks(
67
127
  body: ReadableStream<Uint8Array>,
68
- type: string | null | undefined,
128
+ framing: Framed | null,
69
129
  ): AsyncIterable<unknown> {
70
- return carriesBytes(type)
71
- ? bytesStream(body)
72
- : framedStream(body, framingPrefix(type) ?? '')
130
+ return framing === null ? bytesStream(body) : framedStream(body, framing)
73
131
  }
74
132
 
75
133
  /**
@@ -92,113 +150,54 @@ async function* bytesStream(
92
150
  }
93
151
  }
94
152
 
95
- /** The chunks a framed text carried, every line of it complete. */
96
- export function framedChunks(text: string, prefix: string): unknown[] {
97
- const chunks: unknown[] = []
98
- readLines(text, prefix, true, chunks, { refusal: false })
99
- for (const chunk of chunks)
100
- if (chunk instanceof Refused) throw failedFrom(chunk.problem)
101
- return chunks
153
+ /** The chunks a framed text carried, every frame of it complete. */
154
+ export function framedChunks(text: string, framing: Framed): unknown[] {
155
+ const lines = framing === 'jsonl'
156
+ const frames: Frame[] = []
157
+ frameReader(lines)(text, true, frames)
158
+ return frames.map((frame) => chunkOf(frame, lines))
159
+ }
160
+
161
+ /** An event stream's events, yielded as each completes. */
162
+ export function eventsOf(
163
+ body: ReadableStream<Uint8Array>,
164
+ ): AsyncGenerator<Frame> {
165
+ return framedStream(body, 'sse', true) as AsyncGenerator<Frame>
102
166
  }
103
167
 
104
168
  /**
105
- * A framed body's chunks, yielded as each line completes.
169
+ * A framed body's chunks — or, where `events`, its frames as they are — yielded as each frame
170
+ * completes.
106
171
  *
107
172
  * Canceled when the reader stops reading — a refresh starts a new run and the old one's reader has
108
173
  * nothing left to hand anything.
109
174
  */
110
175
  export async function* framedStream(
111
176
  body: ReadableStream<Uint8Array>,
112
- prefix: string,
177
+ framing: Framed,
178
+ events?: true,
113
179
  ): AsyncGenerator<unknown> {
180
+ const lines = framing === 'jsonl'
114
181
  const reader = body.getReader()
115
182
  const decoder = new TextDecoder()
116
- const chunks: unknown[] = []
117
- const reading: Reading = { refusal: false }
118
- let buffered = ''
183
+ const framed = frameReader(lines)
184
+ const frames: Frame[] = []
119
185
  try {
120
186
  for (;;) {
121
187
  const { done, value } = await reader.read()
122
- const decoded = done
123
- ? decoder.decode()
124
- : decoder.decode(value, { stream: true })
125
- // A long line across many reads is held unscanned until its newline lands, or each read
126
- // rescans everything before it.
127
- if (!done && !decoded.includes('\n')) {
128
- buffered += decoded
129
- continue
130
- }
131
- buffered = readLines(
132
- buffered + decoded,
133
- prefix,
188
+ framed(
189
+ done
190
+ ? decoder.decode()
191
+ : decoder.decode(value, { stream: true }),
134
192
  done,
135
- chunks,
136
- reading,
193
+ frames,
137
194
  )
138
- for (const chunk of chunks) {
139
- if (chunk instanceof Refused) throw failedFrom(chunk.problem)
140
- yield chunk
141
- }
142
- chunks.length = 0
195
+ for (const frame of frames)
196
+ yield events ? frame : chunkOf(frame, lines)
197
+ frames.length = 0
143
198
  if (done) return
144
199
  }
145
200
  } finally {
146
201
  void reader.cancel().catch(() => {})
147
202
  }
148
203
  }
149
-
150
- /**
151
- * Parses each complete line of `text` into `chunks`, and the unfinished last one too where `ended`.
152
- *
153
- * Answers what is left of `text` after the last newline.
154
- */
155
- function readLines(
156
- text: string,
157
- prefix: string,
158
- ended: boolean,
159
- chunks: unknown[],
160
- reading: Reading,
161
- ): string {
162
- let start = 0
163
- for (;;) {
164
- const newline = text.indexOf('\n', start)
165
- if (newline === -1 && !ended) return text.slice(start)
166
- const end = newline === -1 ? text.length : newline
167
- if (prefix !== '' && text.startsWith('event: refusal', start))
168
- reading.refusal = true
169
- else if (end > start && text.startsWith(prefix, start)) {
170
- const value: unknown = JSON.parse(
171
- text.slice(start + prefix.length, end),
172
- )
173
- chunks.push(refusalIn(value, prefix, reading) ?? value)
174
- reading.refusal = false
175
- }
176
- if (newline === -1) return ''
177
- start = newline + 1
178
- }
179
- }
180
-
181
- // The refusal a value carries as its frame: the data line an sse event named one, or a jsonl line
182
- // holding the one reserved key and Problem Details under it.
183
- function refusalIn(
184
- value: unknown,
185
- prefix: string,
186
- reading: Reading,
187
- ): Refused | undefined {
188
- if (prefix !== '')
189
- return reading.refusal && isProblem(value)
190
- ? new Refused(value)
191
- : undefined
192
- // Per chunk: the reserved key is tested before any key list is built.
193
- if (
194
- value === null ||
195
- typeof value !== 'object' ||
196
- !Object.hasOwn(value, REFUSAL_KEY)
197
- )
198
- return undefined
199
- const keys = Object.keys(value)
200
- const problem = (value as Record<string, unknown>)[REFUSAL_KEY]
201
- return keys.length === 1 && isProblem(problem)
202
- ? new Refused(problem)
203
- : undefined
204
- }
@@ -1,18 +1,26 @@
1
1
  /**
2
2
  * A record of members as a query string.
3
3
  *
4
- * ONE QUERY-STRING RULE for `rpcRequest`'s args (16.18) and `url`'s `Query` (30.6): a member left
4
+ * 30.28 — ONE QUERY-STRING RULE for `rpcRequest`'s args (16.18) and `url`'s `Query`: a member left
5
5
  * `undefined` is omitted, a list is its member repeated, and anything else is its text — `null`
6
- * included, which 19.7 reads back as `null` where the schema declares it. An empty list writes
7
- * nothing here; `rpcRequest` spells it as a bare name.
6
+ * included, which 19.7 reads back as `null` where the schema declares it. AN EMPTY LIST IS ITS NAME
7
+ * WITH NO `=`, last, which no text value spells — `tags=` is the one-member list `['']` — so 19.7
8
+ * reads it back as `[]` rather than losing the member.
8
9
  */
9
10
  export function queryText(members: Record<string, unknown>): string {
10
11
  const query = new URLSearchParams()
12
+ let bare = ''
11
13
  for (const [name, value] of Object.entries(members)) {
12
14
  if (value === undefined) continue
13
- if (Array.isArray(value))
14
- for (const each of value) query.append(name, String(each))
15
- else query.append(name, String(value))
15
+ if (!Array.isArray(value)) query.append(name, String(value))
16
+ else if (value.length === 0) bare += `&${bareName(name)}`
17
+ else for (const each of value) query.append(name, String(each))
16
18
  }
17
- return query.toString()
19
+ const text = query.toString()
20
+ return text === '' ? bare.slice(1) : text + bare
21
+ }
22
+
23
+ /** A query member's name in the encoding `URLSearchParams` gives it, without its `=`. */
24
+ export function bareName(name: string): string {
25
+ return new URLSearchParams([[name, '']]).toString().slice(0, -1)
18
26
  }
@@ -1,14 +1,12 @@
1
- // THE CURSOR. RULEBOOK 2.5: `for await (… of s)` — the production in flight, then each one after
2
- // it. On a streaming producer that is one turn per CHUNK, read from the chunk buffer by ordinal,
3
- // so two chunks between two `await`s are two turns of the loop. On every other value it is the
4
- // standing production: a burst between two turns collapses to the latest, since nothing else is
5
- // held. An app that wants every message keeps them — a channel is an array.
1
+ // THE CURSOR. RULEBOOK 2.6: a streaming producer's chunks, read from the chunk buffer by ordinal,
2
+ // so two chunks between two `await`s are two turns of the loop, and a patch or a write over the
3
+ // stream moves no place (5.7, 9.12). Where the run ENDS is the caller's settling, handed to
4
+ // `closeOn`: one run's reader is `Reactive.ts`'s `runChunks`.
6
5
  //
7
6
  // A NARROW FACE rather than the `Cell`, the same way `tracking.ts` takes a `Tracked`: what a
8
7
  // cursor reads is a handful of fields, and naming them keeps the cell's internals out of a
9
8
  // second file.
10
9
 
11
- import { PATCHES } from './PATCHES.ts'
12
10
  import {
13
11
  dropReader,
14
12
  newSubscriber,
@@ -17,27 +15,20 @@ import {
17
15
  } from './tracking.ts'
18
16
 
19
17
  /**
20
- * What a cursor reads, and all of a cell the server is handed (`servedCellOf`).
18
+ * What a cursor reads of a cell.
21
19
  *
22
20
  * `ring[index]` carries ordinal `ringBase + index + 1`, so a cursor holding an ordinal finds its
23
21
  * place in a stream's chunk buffer without the buffer having to hold one.
24
22
  */
25
23
  export type Retained = Tracked & {
26
- /** The window a `throttle` or a `debounce` holds open, `holding` while a production waits on it. */
27
- cap: { holding: boolean } | null
28
24
  ordinal: number
29
25
  ring: unknown[]
30
26
  ringBase: number
31
- servable: boolean
32
- state: number
33
- /**
34
- * Non-null on a streaming producer, whose ring is the accumulation of the run being served
35
- * (9.1) and is retained whole — the chunks ARE the value.
36
- */
37
- stream: object | null
38
- value: unknown
39
27
  }
40
28
 
29
+ /** What `take` answers when the cursor has caught up — never a chunk, which may be `undefined`. */
30
+ export const CAUGHT_UP: unique symbol = Symbol('caught up')
31
+
41
32
  // The cursors standing over each source, for a patch to keep the place of the ones behind (5.7).
42
33
  const CURSORS = new WeakMap<Retained, Set<Cursor>>()
43
34
 
@@ -70,62 +61,39 @@ export function hasCursorBehind(source: Retained): boolean {
70
61
  }
71
62
 
72
63
  /**
73
- * A live reader of a source's productions, one turn per production, in order.
64
+ * A reader of a stream's chunks, in order, waking whoever waits on it when one lands.
74
65
  *
75
- * A RECORD, NOT A SCOPE. The four closures this was — `take`, `notify`, `stop` and `next` — shared
76
- * one environment holding `at`, `waiting`, `source` and `reader`, and a cursor OUTLIVES the call
77
- * that made it: a `for await` holds one for the life of the loop, so the environment and every
78
- * function object hanging off it is live that whole time. Fields on a prototype are the object
79
- * alone, and `at` — read and written on every turn — becomes a property load on a fixed structure
80
- * rather than a scope load.
66
+ * A RECORD, NOT A SCOPE. A cursor outlives the call that made it — a `for await` holds one for the
67
+ * life of the loop — so fields on a prototype are the object alone, and `at`, read and written on
68
+ * every turn, is a property load on a fixed structure rather than a scope load.
81
69
  */
82
- export class Cursor implements AsyncIterator<unknown> {
70
+ export class Cursor {
83
71
  source: Retained
84
- /** The ordinal of the last production this cursor handed over. */
72
+ /** The ordinal of the last chunk this cursor handed over. */
85
73
  at: number
86
- /**
87
- * The patch epoch the value it last handed over stood at.
88
- *
89
- * A PATCH MINTS NO ORDINAL (5.7) — the follow's version tells a patch from a production by
90
- * exactly that — so a cursor over a value patched in place reads the change off the object.
91
- */
92
- patched: number
93
- /**
94
- * The turns asked for and not yet answered.
95
- *
96
- * A QUEUE rather than one slot because a consumer may ask twice before either answer arrives
97
- * — `Promise.all([ it.next(), it.next() ])` is the shape, and with one slot the second `next`
98
- * overwrote the first's resolver and that promise never settled at all. `for await` asks one
99
- * at a time, which is why nothing here said so.
100
- */
101
- waiting: ((result: IteratorResult<unknown>) => void)[]
74
+ /** Called once at the next wake, then cleared. */
75
+ wake: (() => void) | null
76
+ /** Whether the run `closeOn` was handed has settled. */
77
+ closed: boolean
78
+ /** What that run rejected with. */
79
+ failure: unknown
102
80
  reader: Subscriber
103
81
 
104
82
  constructor(source: Retained, from: number) {
105
83
  this.source = source
106
84
  this.at = from
107
- this.patched = 0
108
- this.waiting = []
109
- // The one closure left, and it captures this cursor and nothing else. `notify` is
110
- // called as a method on the SUBSCRIBER, so it cannot be the cursor's own method —
111
- // and widening `Subscriber` with a back-edge would put a field on the shape every
112
- // reader in the graph carries for the sake of the few that are cursors.
113
- // Registered outside the tracking protocol, so the source it waits on is declared
114
- // rather than discovered — see the same shape in `Reactive`'s `settled`.
85
+ this.wake = null
86
+ this.closed = false
87
+ this.failure = undefined
88
+ // The one closure left, and it captures this cursor and nothing else. `notify` is called as a
89
+ // method on the SUBSCRIBER, so it cannot be the cursor's own method. Registered outside the
90
+ // tracking protocol, so the source it waits on is declared rather than discovered — see the
91
+ // same shape in `Reactive`'s `settled`.
115
92
  this.reader = newSubscriber([source], null)
116
- // One production per waiting turn, in the order they were asked for: a burst that
117
- // lands three productions on a consumer holding two turns answers both.
118
- this.reader.notify = (): void => {
119
- const waiting = this.waiting
120
- while (waiting.length !== 0) {
121
- const next = this.take()
122
- if (next === null) return
123
- ;(waiting.shift() as (result: IteratorResult<unknown>) => void)(
124
- next,
125
- )
126
- }
127
- }
93
+ this.reader.notify = (): void => this.woken()
128
94
  source.readers.push(this.reader)
95
+ // ONE PLACED PAST EVERY CHUNK is a wake and never behind, so no patch has a place to keep.
96
+ if (from === Number.POSITIVE_INFINITY) return
129
97
  let cursors = CURSORS.get(source)
130
98
  if (cursors === undefined) {
131
99
  cursors = new Set()
@@ -135,83 +103,60 @@ export class Cursor implements AsyncIterator<unknown> {
135
103
  }
136
104
 
137
105
  /**
138
- * One production, or `null` where the cursor has caught up.
106
+ * The next chunk, or `CAUGHT_UP`.
139
107
  *
140
- * Reading a stream's chunks by ordinal is what keeps a burst from being a gap or a repeat.
108
+ * Reading by ordinal is what keeps a burst from being a gap or a repeat.
141
109
  */
142
- take(): IteratorResult<unknown> | null {
110
+ take(): unknown {
143
111
  const source = this.source
144
- let at = this.at
145
- if (source.stream === null) {
146
- const value = source.value
147
- // 6.10: an `undefined` standing is caught up, not a turn.
148
- if (value === undefined) return null
149
- const patched =
150
- value !== null && typeof value === 'object'
151
- ? (PATCHES.written.get(value) ?? 0)
152
- : 0
153
- if (at >= source.ordinal && patched === this.patched) return null
154
- // 5.9 — A PRODUCTION A WINDOW HOLDS has minted its ordinal and is not yet the value: the
155
- // turn is the release's, which wakes this cursor.
156
- if (source.cap?.holding === true) return null
157
- // The standing production is the only one held, so everything minted or patched
158
- // since the last turn collapses into it.
159
- this.at = source.ordinal
160
- this.patched = patched
161
- return { value, done: false }
162
- }
163
- if (at >= source.ordinal) return null
164
- // A run that restarted moved the buffer past this cursor: answer from its start.
165
- if (at < source.ringBase) at = source.ringBase
166
- const value = source.ring[at - source.ringBase]
167
- this.at = at + 1
168
- return { value, done: false }
169
- }
170
-
171
- next(): Promise<IteratorResult<unknown>> {
172
- if (!this.reader.alive)
173
- return Promise.resolve({ value: undefined, done: true })
174
- // A TURN ALREADY ASKED FOR AND NOT ANSWERED KEEPS THE ORDER: taking from the
175
- // ring here while one is queued would hand the later caller the earlier
176
- // production.
177
- if (this.waiting.length === 0) {
178
- const ready = this.take()
179
- // NOT `async`, and no `await` on the caught-up arm. A cursor behind a
180
- // stream's chunks is the common shape while a burst drains, and an
181
- // `async` here would put a microtask tick between every one of those
182
- // turns.
183
- if (ready !== null) return Promise.resolve(ready)
184
- }
185
- const waiting = this.waiting
186
- return new Promise((resolve) => {
187
- waiting.push(resolve)
188
- })
112
+ if (this.at >= source.ordinal) return CAUGHT_UP
113
+ const value = source.ring[this.at - source.ringBase]
114
+ this.at += 1
115
+ return value
189
116
  }
190
117
 
191
118
  /**
192
- * Ends the cursor: `for await` calls this on `break`, on `return` and on a throw.
119
+ * Ends the cursor's run where `run` settles: `closed` is set, `failure` holds a rejection, and
120
+ * the current `turn` wakes.
193
121
  *
194
- * It is the only thing that drops the subscriber — a live cursor has no end of its own.
122
+ * ONE REACTION ON THE CLOSE FOR THE WHOLE RUN, never one per chunk held until the run ends. A
123
+ * chunk landing in the same turn as the close is still unread when the close wakes the caller,
124
+ * so a drain before reading `closed` hands it over rather than losing it to whichever settled
125
+ * first.
195
126
  */
196
- return(): Promise<IteratorResult<unknown>> {
197
- return this.stop()
127
+ closeOn(run: PromiseLike<unknown>): void {
128
+ run.then(
129
+ () => {
130
+ this.closed = true
131
+ this.woken()
132
+ },
133
+ (error: unknown) => {
134
+ this.closed = true
135
+ this.failure = error
136
+ this.woken()
137
+ },
138
+ )
139
+ }
140
+
141
+ /** Settles at the next chunk or the close, whichever lands first. */
142
+ turn(): Promise<void> {
143
+ return new Promise((resolve) => {
144
+ this.wake = resolve
145
+ })
198
146
  }
199
147
 
200
- throw(): Promise<IteratorResult<unknown>> {
201
- return this.stop()
148
+ private woken(): void {
149
+ const wake = this.wake
150
+ this.wake = null
151
+ wake?.()
202
152
  }
203
153
 
204
- stop(): Promise<IteratorResult<unknown>> {
154
+ /** Drops the subscriber; nothing else does. */
155
+ stop(): void {
205
156
  const reader = this.reader
206
157
  reader.alive = false
207
158
  dropReader(this.source, reader)
208
159
  CURSORS.get(this.source)?.delete(this)
209
- const waiting = this.waiting
210
- while (waiting.length !== 0)
211
- (waiting.shift() as (result: IteratorResult<unknown>) => void)({
212
- value: undefined,
213
- done: true,
214
- })
215
- return Promise.resolve({ value: undefined, done: true })
160
+ this.wake = null
216
161
  }
217
162
  }