@jarenjs/contract 0.49.2 → 0.66.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.
- package/README.md +191 -30
- package/dist/types/adapters/fetch.d.ts +11 -10
- package/dist/types/adapters/node.d.ts +24 -10
- package/dist/types/client/http.d.ts +77 -10
- package/dist/types/compat.d.ts +1 -1
- package/dist/types/errors.d.ts +3 -0
- package/dist/types/host.d.ts +179 -0
- package/dist/types/http/body.d.ts +147 -0
- package/dist/types/http/dispatch.d.ts +26 -2
- package/dist/types/http/serve.d.ts +46 -3
- package/dist/types/http/wire.d.ts +31 -17
- package/dist/types/ledger.d.ts +57 -12
- package/dist/types/local/index.d.ts +7 -1
- package/dist/types/messages.d.ts +2 -0
- package/dist/types/path.d.ts +4 -2
- package/dist/types/pipeline.d.ts +15 -1
- package/dist/types/port/client.d.ts +18 -1
- package/dist/types/port/serve.d.ts +38 -5
- package/dist/types/project/tools.d.ts +1 -1
- package/dist/types/project/typescript.d.ts +11 -0
- package/dist/types/runtime.d.ts +25 -0
- package/dist/types/stream/client.d.ts +14 -3
- package/dist/types/stream/server.d.ts +218 -44
- package/dist/types/stream/sse.d.ts +10 -0
- package/docs/APP-INTEGRATION.md +4 -2
- package/docs/CONTRACT-FORMAT.md +617 -145
- package/package.json +5 -5
- package/src/adapters/fetch.js +144 -25
- package/src/adapters/node.js +246 -82
- package/src/cli.js +22 -16
- package/src/client/http.js +588 -189
- package/src/compat.js +1 -1
- package/src/errors.js +3 -0
- package/src/host.js +319 -0
- package/src/http/body.js +337 -0
- package/src/http/dispatch.js +511 -75
- package/src/http/serve.js +39 -5
- package/src/http/wire.js +33 -14
- package/src/ledger.js +119 -36
- package/src/local/index.js +91 -35
- package/src/messages.js +2 -0
- package/src/path.js +9 -3
- package/src/pipeline.js +18 -1
- package/src/port/client.js +39 -6
- package/src/port/serve.js +207 -69
- package/src/project/tools.js +9 -2
- package/src/project/typescript.js +91 -1
- package/src/project/typescript.jtlt.json +39 -7
- package/src/runtime.js +36 -0
- package/src/stream/client.js +40 -6
- package/src/stream/server.js +573 -138
- package/src/stream/sse.js +2 -0
package/src/stream/server.js
CHANGED
|
@@ -3,23 +3,56 @@
|
|
|
3
3
|
* @file The server half of the stream binding, carrier-neutral
|
|
4
4
|
* (docs/CONTRACT-FORMAT.md §17–§18): take the subscription a handler
|
|
5
5
|
* settled (the duck-typed LIVE shape — `result`/`snapshot()`,
|
|
6
|
-
* `subscribe(cb) → stop`, `close()`, optional `replay(
|
|
7
|
-
* resumption, read and validate the snapshot, forward each
|
|
8
|
-
* and guarantee `stop()` then `close()` run **exactly once**
|
|
9
|
-
* the stream ends — peer disconnect, unsubscribe, server
|
|
10
|
-
* error emission, an invalid snapshot
|
|
11
|
-
* intents this module raises as SSE
|
|
12
|
-
* them as push frames — the sequencing
|
|
13
|
-
* carriers can never disagree.
|
|
6
|
+
* `subscribe(cb) → stop`, `close()`, optional `replay(after, options)`),
|
|
7
|
+
* decide resumption, read and validate the snapshot, forward each
|
|
8
|
+
* emission, and guarantee `stop()` then `close()` run **exactly once**
|
|
9
|
+
* however the stream ends — peer disconnect, unsubscribe, server
|
|
10
|
+
* shutdown, an error emission, an invalid snapshot, a slow consumer.
|
|
11
|
+
* The HTTP dispatcher renders the intents this module raises as SSE
|
|
12
|
+
* events; the port server renders them as push frames — the sequencing
|
|
13
|
+
* is decided here once so the two carriers can never disagree.
|
|
14
|
+
*
|
|
15
|
+
* The carrier encodes, the runner decides. A hook answers the WIRE
|
|
16
|
+
* FRAME of one event (an SSE text, a port frame object) or `null` to
|
|
17
|
+
* skip it; the runner measures it through the carrier's `size`, charges
|
|
18
|
+
* it to ONE bounded queue, and writes it through the carrier's `write`
|
|
19
|
+
* one frame at a time, each behind the previous one's settlement (the
|
|
20
|
+
* suite's awaited-sink primitive): an emission that arrives while a
|
|
21
|
+
* write is pending — or while a replay page is loading — waits its turn
|
|
22
|
+
* in order, never overlapped, never dropped. A frame's count and bytes
|
|
23
|
+
* stay charged until its write settled, so a blocked write cannot hide
|
|
24
|
+
* memory outside the bound; when the next frame would cross either
|
|
25
|
+
* bound the stream ends with `JC2096` and the carrier is told to tear
|
|
26
|
+
* its sink down. A write that rejects means the carrier is gone (the
|
|
27
|
+
* peer dropped the socket, the consumer cancelled the stream): the
|
|
28
|
+
* runner releases silently.
|
|
29
|
+
*
|
|
30
|
+
* Replay is paged, never an array (§18.1): `replay(after, { limit,
|
|
31
|
+
* maxBytes, signal })` answers one `changes.page()`-shaped page —
|
|
32
|
+
* `{ items, next?, earliestAvailable, highWatermark, hasMore,
|
|
33
|
+
* resetRequired }` — validated defensively; the first page's
|
|
34
|
+
* `highWatermark` is the target, and paging stops there however busy
|
|
35
|
+
* the writer is. A page with `resetRequired` delivers no suffix: the
|
|
36
|
+
* runner reads a fresh snapshot and emits it with `reset: true`, both
|
|
37
|
+
* watermarks, and an event id no lower than any live emission already
|
|
38
|
+
* buffered, so a patch the snapshot already reflects is never replayed.
|
|
39
|
+
*
|
|
40
|
+
* A source's `stop()` and `close()` may answer promises; the runner's
|
|
41
|
+
* `done` settles only after stop, then close, then the carrier's `done`
|
|
42
|
+
* hook have all settled, once — the completion signal a host's
|
|
43
|
+
* finalizers wait on.
|
|
14
44
|
*
|
|
15
45
|
* Total for everything a handler's subscription can do: a throwing
|
|
16
46
|
* `snapshot()`/`result` accessor, a `subscribe` that throws, a hostile
|
|
17
|
-
* emission, a throwing `stop`/`close` —
|
|
18
|
-
* `
|
|
19
|
-
* wire) or is swallowed at close, and
|
|
47
|
+
* emission, a malformed or rejecting page, a throwing `stop`/`close` —
|
|
48
|
+
* every one settles into the `error` intent (the cause for the
|
|
49
|
+
* binding's `onError`, never the wire) or is swallowed at close, and
|
|
50
|
+
* the stream still terminates.
|
|
20
51
|
*/
|
|
21
52
|
|
|
22
53
|
import { toPromise, isThenable } from '@jarenjs/core/function';
|
|
54
|
+
import { createAwaitedSink } from '@jarenjs/core/async';
|
|
55
|
+
import { isJsonValue } from '@jarenjs/core/object';
|
|
23
56
|
|
|
24
57
|
import { verdict } from '../http/wire.js';
|
|
25
58
|
|
|
@@ -28,34 +61,165 @@ import { verdict } from '../http/wire.js';
|
|
|
28
61
|
*/
|
|
29
62
|
|
|
30
63
|
/**
|
|
31
|
-
* The
|
|
64
|
+
* The options of one replay page call (§18.1): at most `limit`
|
|
65
|
+
* emissions and `maxBytes` serialized patch bytes, and the runner's
|
|
66
|
+
* signal, aborted when the stream stops while the page is loading.
|
|
67
|
+
* @typedef {{ limit: number, maxBytes: number, signal: AbortSignal }} ReplayOptions
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One replay page, in the shape `@jarenjs/db`'s `changes.page()`
|
|
72
|
+
* answers: the emissions after `after` in seq order, `next` the seq to
|
|
73
|
+
* continue from, the log's two watermarks, whether more remain, and
|
|
74
|
+
* the total refusal `resetRequired` — under which `items` is empty and
|
|
75
|
+
* `next` absent.
|
|
76
|
+
* @typedef {{ items: { patch: unknown[], seq: number }[], next?: number,
|
|
77
|
+
* earliestAvailable: number | null, highWatermark: number, hasMore: boolean,
|
|
78
|
+
* resetRequired: boolean }} ReplayPage
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The duck-typed subscription of docs/CONTRACT-FORMAT.md §17.1. `stop`
|
|
83
|
+
* (what `subscribe` answers) and `close` may answer a promise; the
|
|
84
|
+
* runner awaits each before it reports completion. `replay` answers one
|
|
85
|
+
* page per call, value or promise.
|
|
32
86
|
* @typedef {{ result?: unknown, snapshot?: () => unknown,
|
|
33
|
-
* subscribe: (cb: (emission: any) => void) => (() =>
|
|
34
|
-
* close: () =>
|
|
87
|
+
* subscribe: (cb: (emission: any) => void) => (() => unknown),
|
|
88
|
+
* close: () => unknown,
|
|
89
|
+
* replay?: (after: number, options: ReplayOptions) => ReplayPage | null | undefined | Promise<ReplayPage | null | undefined>,
|
|
90
|
+
* mode?: unknown }} SubscriptionLike
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The data of a snapshot event (§18.1): the validated document, whether
|
|
95
|
+
* the stream resumed (never, today: a resumed stream starts with
|
|
96
|
+
* patches), whether this snapshot re-seeds a consumer whose cursor fell
|
|
97
|
+
* behind the log's retention (`reset`), and the log's watermarks as the
|
|
98
|
+
* replay source reported them (`null` on a fresh stream).
|
|
99
|
+
* @typedef {{ value: unknown, resumed: boolean, reset: boolean,
|
|
100
|
+
* earliestAvailable: number | null, highWatermark: number | null }} SnapshotData
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The reasons a stream ends with an `error` event: `'invalid-snapshot'`
|
|
105
|
+
* (send `JC2091`), `'source'` (the subscription emitted `{ error }`
|
|
106
|
+
* with no declared code, or a page/subscribe/snapshot fault — send the
|
|
107
|
+
* host fault code), `'slow-consumer'` (the bounded queue would overflow
|
|
108
|
+
* — send `JC2096`), `'declared'` (the subscription emitted `{ error }`
|
|
109
|
+
* whose `code` the operation declares — send that code as the declared
|
|
110
|
+
* failure it is, with the {@link DeclaredStreamFailure} the runner
|
|
111
|
+
* classified).
|
|
112
|
+
* @typedef {'invalid-snapshot' | 'source' | 'slow-consumer' | 'declared'} ErrorIntent
|
|
35
113
|
*/
|
|
36
114
|
|
|
37
115
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
* `
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
|
|
116
|
+
* A source error the operation declares (§17.1): the declared `code`,
|
|
117
|
+
* JSON-safe `details` when the error carried some (`undefined`
|
|
118
|
+
* otherwise), and `retryable` — the error's own boolean, else whether
|
|
119
|
+
* `policy.retry.on` names the code. Nothing of the error's message or
|
|
120
|
+
* stack is here: the carrier renders the operation's declared message.
|
|
121
|
+
* @typedef {{ code: string, details: unknown, retryable: boolean }} DeclaredStreamFailure
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What the carrier renders, as FRAMES. Each event hook answers the wire
|
|
126
|
+
* frame of one event — the SSE text, the port frame — or `null` to skip
|
|
127
|
+
* it (a frame the wire cannot carry, observed by the carrier). The
|
|
128
|
+
* runner measures a frame with `size` (its bytes on the wire), charges
|
|
129
|
+
* it to the bounded queue, and writes it with `write`, one at a time,
|
|
130
|
+
* each behind the previous write's settlement; `write` may answer a
|
|
131
|
+
* promise. After the `error` or `end` frame no further frame is
|
|
132
|
+
* written. A `write` that throws or rejects is read as "the carrier can
|
|
133
|
+
* no longer deliver": the subscription is released silently. `done`
|
|
134
|
+
* fires exactly once after the stream terminated for any reason, with
|
|
135
|
+
* `'slow-consumer'` when the queue overflowed — the carrier tears its
|
|
136
|
+
* sink down instead of ending it — else `null`; the carrier releases
|
|
137
|
+
* its resources (timers, registries, the sink) there.
|
|
138
|
+
* @template [F=unknown]
|
|
45
139
|
* @typedef {Object} StreamHooks
|
|
46
|
-
* @property {(seq: number,
|
|
47
|
-
* @property {(seq: number, emission: { patch: unknown[], seq: number }) =>
|
|
48
|
-
* @property {(intent:
|
|
49
|
-
* @property {(reason: string, lastSeq: number) =>
|
|
50
|
-
* @property {() =>
|
|
140
|
+
* @property {(seq: number, data: SnapshotData) => F | null} snapshot
|
|
141
|
+
* @property {(seq: number, emission: { patch: unknown[], seq: number }) => F | null} patch
|
|
142
|
+
* @property {(intent: ErrorIntent, cause: unknown, lastSeq: number, declared: DeclaredStreamFailure | null) => F | null} error
|
|
143
|
+
* @property {(reason: string, lastSeq: number) => F | null} end
|
|
144
|
+
* @property {(frame: F) => number} size
|
|
145
|
+
* @property {(frame: F) => unknown} write
|
|
146
|
+
* @property {(reason: 'slow-consumer' | null) => unknown} done
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The bounds of one stream (§18.1): a replay page asks for at most
|
|
151
|
+
* `replay.limit` emissions and `replay.maxBytes` serialized patch
|
|
152
|
+
* bytes; the queue holds at most `queue.events` undelivered frames and
|
|
153
|
+
* `queue.bytes` of their wire bytes.
|
|
154
|
+
* @typedef {{ replay: { limit: number, maxBytes: number }, queue: { events: number, bytes: number } }} StreamLimits
|
|
51
155
|
*/
|
|
52
156
|
|
|
53
157
|
/**
|
|
54
158
|
* @typedef {Object} StreamOptions
|
|
55
159
|
* @property {number | null} lastSeq - the peer's `Last-Event-ID` / `lastSeq`, or `null`
|
|
56
160
|
* @property {boolean} validate - whether snapshots run the output validator
|
|
161
|
+
* @property {StreamLimits} [limits] - the bounds; the defaults when absent
|
|
57
162
|
*/
|
|
58
163
|
|
|
164
|
+
/**
|
|
165
|
+
* What `runSubscription` answers: the stopper, and the completion
|
|
166
|
+
* signal. `stop(reason)` with a string renders the `end` event with
|
|
167
|
+
* that reason first (a server shutdown) and then releases; `stop(null)`
|
|
168
|
+
* is silent (the peer is gone, or asked) and does not wait for a
|
|
169
|
+
* carrier write still pending — a late settlement is ignored. `done`
|
|
170
|
+
* settles (it never rejects) once the source's `stop()` and `close()`
|
|
171
|
+
* and the carrier's `done` hook have all run, in that order, once.
|
|
172
|
+
* @typedef {{ stop: (reason: string | null) => void, done: Promise<void> }} StreamRunner
|
|
173
|
+
*/
|
|
174
|
+
|
|
175
|
+
/** The bounds every stream runs under unless the server says otherwise. */
|
|
176
|
+
export const STREAM_LIMITS_DEFAULT = Object.freeze({
|
|
177
|
+
replay: Object.freeze({ limit: 256, maxBytes: 1024 * 1024 }),
|
|
178
|
+
queue: Object.freeze({ events: 256, bytes: 1024 * 1024 }),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve and validate a server's `streamLimits` option against the
|
|
183
|
+
* defaults: every member a positive integer (`Infinity` allowed), else
|
|
184
|
+
* the binding's host error.
|
|
185
|
+
* @param {unknown} option - the server's `streamLimits`, or `undefined`
|
|
186
|
+
* @param {(reason: string) => Error} refuse - the binding's host-error factory
|
|
187
|
+
* @returns {StreamLimits}
|
|
188
|
+
*/
|
|
189
|
+
export function resolveStreamLimits(option, refuse) {
|
|
190
|
+
if (option === undefined) return STREAM_LIMITS_DEFAULT;
|
|
191
|
+
if (option === null || typeof option !== 'object') {
|
|
192
|
+
throw refuse('options.streamLimits must be an object { replay?: { limit?, maxBytes? }, queue?: { events?, bytes? } }');
|
|
193
|
+
}
|
|
194
|
+
const o = /** @type {any} */ (option);
|
|
195
|
+
/**
|
|
196
|
+
* @param {string} group @param {string} name @param {number} fallback
|
|
197
|
+
* @returns {number}
|
|
198
|
+
*/
|
|
199
|
+
const bound = (group, name, fallback) => {
|
|
200
|
+
const g = o[group];
|
|
201
|
+
if (g === undefined) return fallback;
|
|
202
|
+
if (g === null || typeof g !== 'object') throw refuse(`options.streamLimits.${group} must be an object`);
|
|
203
|
+
const v = g[name];
|
|
204
|
+
if (v === undefined) return fallback;
|
|
205
|
+
if (v === Infinity) return Infinity;
|
|
206
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) {
|
|
207
|
+
throw refuse(`options.streamLimits.${group}.${name} must be a positive integer or Infinity`);
|
|
208
|
+
}
|
|
209
|
+
return v;
|
|
210
|
+
};
|
|
211
|
+
return Object.freeze({
|
|
212
|
+
replay: Object.freeze({
|
|
213
|
+
limit: bound('replay', 'limit', STREAM_LIMITS_DEFAULT.replay.limit),
|
|
214
|
+
maxBytes: bound('replay', 'maxBytes', STREAM_LIMITS_DEFAULT.replay.maxBytes),
|
|
215
|
+
}),
|
|
216
|
+
queue: Object.freeze({
|
|
217
|
+
events: bound('queue', 'events', STREAM_LIMITS_DEFAULT.queue.events),
|
|
218
|
+
bytes: bound('queue', 'bytes', STREAM_LIMITS_DEFAULT.queue.bytes),
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
59
223
|
/**
|
|
60
224
|
* Whether a settled handler value is a usable subscription.
|
|
61
225
|
* Reads guardedly; a hostile value classifies as "not a subscription".
|
|
@@ -88,6 +252,102 @@ function readSnapshot(sub) {
|
|
|
88
252
|
}
|
|
89
253
|
}
|
|
90
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Classify a source error against the operation's declared errors:
|
|
257
|
+
* declared exactly when guarded reads find a string `code` that is an
|
|
258
|
+
* own member of `route.errors`. `details` cross only when JSON-safe;
|
|
259
|
+
* `retryable` is the error's own boolean, else the retry policy's
|
|
260
|
+
* verdict. A hostile error whose members throw is not declared.
|
|
261
|
+
* @param {unknown} error
|
|
262
|
+
* @param {PipelineRoute} route
|
|
263
|
+
* @returns {DeclaredStreamFailure | null}
|
|
264
|
+
*/
|
|
265
|
+
function declaredFailureOf(error, route) {
|
|
266
|
+
try {
|
|
267
|
+
if (error === null || (typeof error !== 'object' && typeof error !== 'function')) return null;
|
|
268
|
+
const e = /** @type {any} */ (error);
|
|
269
|
+
const code = e.code;
|
|
270
|
+
if (typeof code !== 'string' || !Object.hasOwn(route.errors, code)) return null;
|
|
271
|
+
const details = e.details;
|
|
272
|
+
const retryable = typeof e.retryable === 'boolean' ? e.retryable : route.retryOn.has(code);
|
|
273
|
+
return { code, details: details !== undefined && isJsonValue(details) ? details : undefined, retryable };
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Classify one emission: a `{ patch, seq }` record, a source error, or
|
|
282
|
+
* a hostile value. Reads guardedly.
|
|
283
|
+
* @param {unknown} emission
|
|
284
|
+
* @returns {{ kind: 'patch', patch: unknown[], seq: number } | { kind: 'error', cause: unknown }}
|
|
285
|
+
*/
|
|
286
|
+
function classifyEmission(emission) {
|
|
287
|
+
try {
|
|
288
|
+
if (emission === null || typeof emission !== 'object') {
|
|
289
|
+
return { kind: 'error', cause: new TypeError('the subscription emitted a non-object') };
|
|
290
|
+
}
|
|
291
|
+
const e = /** @type {any} */ (emission);
|
|
292
|
+
if (e.error !== undefined) return { kind: 'error', cause: e.error };
|
|
293
|
+
const patch = e.patch;
|
|
294
|
+
const seq = e.seq;
|
|
295
|
+
if (!Array.isArray(patch) || typeof seq !== 'number' || !Number.isFinite(seq)) {
|
|
296
|
+
return { kind: 'error', cause: new TypeError('the subscription emitted a value that is not { patch, seq }') };
|
|
297
|
+
}
|
|
298
|
+
return { kind: 'patch', patch, seq };
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
return { kind: 'error', cause: err };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Validate one replay page defensively against the shape and the
|
|
307
|
+
* bounds it was asked for; a page the source got wrong is the host's
|
|
308
|
+
* fault. Answers the fault's message, or `null` when the page is valid.
|
|
309
|
+
* @param {unknown} page
|
|
310
|
+
* @param {number} after
|
|
311
|
+
* @param {{ limit: number, maxBytes: number }} bounds
|
|
312
|
+
* @returns {string | null}
|
|
313
|
+
*/
|
|
314
|
+
function pageFault(page, after, bounds) {
|
|
315
|
+
try {
|
|
316
|
+
if (page === null || typeof page !== 'object') return 'a replay page must be an object';
|
|
317
|
+
const p = /** @type {any} */ (page);
|
|
318
|
+
if (typeof p.resetRequired !== 'boolean') return 'a replay page must carry a boolean resetRequired';
|
|
319
|
+
if (typeof p.hasMore !== 'boolean') return 'a replay page must carry a boolean hasMore';
|
|
320
|
+
if (!(p.earliestAvailable === null || (typeof p.earliestAvailable === 'number' && Number.isFinite(p.earliestAvailable)))) {
|
|
321
|
+
return 'a replay page must carry earliestAvailable as a number or null';
|
|
322
|
+
}
|
|
323
|
+
if (typeof p.highWatermark !== 'number' || !Number.isFinite(p.highWatermark)) return 'a replay page must carry a numeric highWatermark';
|
|
324
|
+
if (!Array.isArray(p.items)) return 'a replay page must carry an items array';
|
|
325
|
+
if (p.resetRequired) {
|
|
326
|
+
if (p.items.length !== 0 || p.next !== undefined) return 'a replay page with resetRequired must carry no items and no next';
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
if (p.items.length > bounds.limit) return `a replay page holds ${p.items.length} items over the ${bounds.limit} asked for`;
|
|
330
|
+
if (p.next !== undefined && (typeof p.next !== 'number' || !Number.isFinite(p.next) || p.next < after)) {
|
|
331
|
+
return 'a replay page must carry next as a number no lower than after';
|
|
332
|
+
}
|
|
333
|
+
let last = after;
|
|
334
|
+
let bytes = 0;
|
|
335
|
+
for (let i = 0; i < p.items.length; i++) {
|
|
336
|
+
const item = classifyEmission(p.items[i]);
|
|
337
|
+
if (item.kind !== 'patch') return `replay item ${i} is not { patch, seq }`;
|
|
338
|
+
if (item.seq <= last) return `replay item ${i} does not advance the seq (${item.seq} after ${last})`;
|
|
339
|
+
last = item.seq;
|
|
340
|
+
bytes += JSON.stringify(item.patch).length;
|
|
341
|
+
if (bytes > bounds.maxBytes) return `a replay page holds ${bytes} patch bytes over the ${bounds.maxBytes} asked for`;
|
|
342
|
+
}
|
|
343
|
+
if (p.next !== undefined && p.items.length > 0 && p.next < last) return 'a replay page\'s next is below its last item';
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
return `a replay page could not be read (${err instanceof Error ? err.message : String(err)})`;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
91
351
|
/**
|
|
92
352
|
* Run one subscription over a carrier. Emissions are forwarded
|
|
93
353
|
* verbatim (a patch is never mutated); an emission whose serialized
|
|
@@ -95,100 +355,222 @@ function readSnapshot(sub) {
|
|
|
95
355
|
* snapshot at that emission's seq (§18.1); an `{ error }` emission —
|
|
96
356
|
* and a hostile one — raises the `error` intent and ends the stream.
|
|
97
357
|
*
|
|
98
|
-
* Returns the stopper
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* own `stop()` and `close()` run exactly once either way.
|
|
358
|
+
* Returns the stopper and the completion signal ({@link StreamRunner}).
|
|
359
|
+
* The stopper is idempotent; the subscription's own `stop()` and
|
|
360
|
+
* `close()` run exactly once either way.
|
|
102
361
|
*
|
|
362
|
+
* @template F
|
|
103
363
|
* @param {PipelineRoute} route
|
|
104
364
|
* @param {SubscriptionLike} sub
|
|
105
|
-
* @param {StreamHooks} hooks
|
|
365
|
+
* @param {StreamHooks<F>} hooks
|
|
106
366
|
* @param {StreamOptions} options
|
|
107
|
-
* @returns {
|
|
367
|
+
* @returns {StreamRunner}
|
|
108
368
|
*/
|
|
109
369
|
export function runSubscription(route, sub, hooks, options) {
|
|
110
370
|
const policy = /** @type {NonNullable<import('../compile.js').CompiledPolicy['stream']>} */ (
|
|
111
371
|
route.op.policy.stream ?? { resume: 'snapshot', heartbeatMs: 15000, maxPatchBytes: null });
|
|
112
372
|
const maxPatchBytes = policy.maxPatchBytes;
|
|
373
|
+
const limits = options.limits ?? STREAM_LIMITS_DEFAULT;
|
|
113
374
|
let finished = false;
|
|
114
375
|
let ready = false;
|
|
115
|
-
/** @type {any[]} */
|
|
116
|
-
const
|
|
117
|
-
/** @type {(() =>
|
|
376
|
+
/** @type {any[]} live emissions that arrived before the initial events were decided */
|
|
377
|
+
const early = [];
|
|
378
|
+
/** @type {(() => unknown) | null} */
|
|
118
379
|
let stopSub = null;
|
|
119
380
|
let lastSeq = 0;
|
|
381
|
+
/** @type {{ promise: Promise<void>, resolve: (value?: void) => void }} */
|
|
382
|
+
const completion = Promise.withResolvers();
|
|
383
|
+
const done = completion.promise;
|
|
384
|
+
let releasing = false;
|
|
385
|
+
/** @type {'slow-consumer' | null} */
|
|
386
|
+
let terminalReason = null;
|
|
387
|
+
const abort = new AbortController();
|
|
120
388
|
|
|
121
389
|
/**
|
|
122
|
-
* The
|
|
123
|
-
*
|
|
124
|
-
*
|
|
390
|
+
* The ordered frame queue: one carrier write at a time, each behind
|
|
391
|
+
* the previous one's settlement. A write that throws or rejects fails
|
|
392
|
+
* the queue — the carrier can no longer deliver — and the runner
|
|
393
|
+
* releases silently; nothing queued behind the failure runs.
|
|
394
|
+
* @type {import('@jarenjs/core/async').AwaitedSink<F>}
|
|
125
395
|
*/
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
hooks.end(reason, lastSeq);
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
// the end event is best-effort on a dying carrier
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
release();
|
|
138
|
-
},
|
|
139
|
-
};
|
|
396
|
+
const queue = createAwaitedSink({ write: (frame) => hooks.write(frame) });
|
|
397
|
+
/** the frames charged to the queue: queued or in flight, not yet settled */
|
|
398
|
+
let queuedEvents = 0;
|
|
399
|
+
let queuedBytes = 0;
|
|
140
400
|
|
|
141
|
-
/**
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
401
|
+
/**
|
|
402
|
+
* Whether one more frame of `bytes` fits the bound; the terminal
|
|
403
|
+
* frame that reports an overflow is always admitted, so the peer that
|
|
404
|
+
* can still read hears why.
|
|
405
|
+
* @param {number} bytes
|
|
406
|
+
* @returns {boolean}
|
|
407
|
+
*/
|
|
408
|
+
function fits(bytes) {
|
|
409
|
+
return queuedEvents + 1 <= limits.queue.events && queuedBytes + bytes <= limits.queue.bytes;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Charge and queue one frame. Its count and bytes stay charged until
|
|
414
|
+
* the write settled.
|
|
415
|
+
* @param {F} frame
|
|
416
|
+
* @param {number} bytes
|
|
417
|
+
*/
|
|
418
|
+
function send(frame, bytes) {
|
|
419
|
+
queuedEvents++;
|
|
420
|
+
queuedBytes += bytes;
|
|
421
|
+
const settle = () => {
|
|
422
|
+
queuedEvents--;
|
|
423
|
+
queuedBytes -= bytes;
|
|
424
|
+
};
|
|
425
|
+
const answer = queue.write(frame);
|
|
426
|
+
if (answer === undefined) settle();
|
|
427
|
+
else answer.then(settle, () => { settle(); release(false); });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Render one event through its hook and queue the frame within the
|
|
432
|
+
* bounds; an overflow ends the stream with the slow-consumer intent.
|
|
433
|
+
* @param {() => F | null} render
|
|
434
|
+
* @returns {boolean} false when the stream ended instead
|
|
435
|
+
*/
|
|
436
|
+
function emit(render) {
|
|
437
|
+
if (finished) return false;
|
|
438
|
+
let frame;
|
|
154
439
|
try {
|
|
155
|
-
|
|
440
|
+
frame = render();
|
|
156
441
|
}
|
|
157
|
-
catch {
|
|
158
|
-
|
|
442
|
+
catch (err) {
|
|
443
|
+
fail('source', err);
|
|
444
|
+
return false;
|
|
159
445
|
}
|
|
446
|
+
if (frame === null) return true;
|
|
447
|
+
let bytes = 0;
|
|
160
448
|
try {
|
|
161
|
-
hooks.
|
|
449
|
+
bytes = Math.max(0, Number(hooks.size(frame)) || 0);
|
|
162
450
|
}
|
|
163
451
|
catch {
|
|
164
|
-
|
|
452
|
+
bytes = 0;
|
|
453
|
+
}
|
|
454
|
+
if (!fits(bytes)) {
|
|
455
|
+
overflow(bytes);
|
|
456
|
+
return false;
|
|
165
457
|
}
|
|
458
|
+
send(frame, bytes);
|
|
459
|
+
return true;
|
|
166
460
|
}
|
|
167
461
|
|
|
168
462
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* @param {
|
|
463
|
+
* The bounded queue would overflow: end with `JC2096`. The terminal
|
|
464
|
+
* frame is admitted past the bound, and the carrier is told to tear
|
|
465
|
+
* the sink down rather than wait for a consumer that stopped reading.
|
|
466
|
+
* @param {number} bytes - the bytes of the frame that did not fit
|
|
173
467
|
*/
|
|
174
|
-
function
|
|
468
|
+
function overflow(bytes) {
|
|
175
469
|
if (finished) return;
|
|
470
|
+
terminalReason = 'slow-consumer';
|
|
471
|
+
const cause = new Error(`the stream's bounded queue would exceed ${limits.queue.events} events or ${limits.queue.bytes} bytes `
|
|
472
|
+
+ `(${queuedEvents} queued, ${queuedBytes} bytes, ${bytes} more)`);
|
|
473
|
+
let frame = null;
|
|
176
474
|
try {
|
|
177
|
-
hooks.error(
|
|
475
|
+
frame = hooks.error('slow-consumer', cause, lastSeq, null);
|
|
178
476
|
}
|
|
179
477
|
catch {
|
|
180
|
-
|
|
478
|
+
frame = null;
|
|
479
|
+
}
|
|
480
|
+
if (frame !== null) {
|
|
481
|
+
let size = 0;
|
|
482
|
+
try {
|
|
483
|
+
size = Math.max(0, Number(hooks.size(frame)) || 0);
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
size = 0;
|
|
487
|
+
}
|
|
488
|
+
send(frame, size);
|
|
181
489
|
}
|
|
182
|
-
release();
|
|
490
|
+
release(false);
|
|
183
491
|
}
|
|
184
492
|
|
|
185
493
|
/**
|
|
186
|
-
*
|
|
494
|
+
* Release the subscription exactly once: the source's `stop()`, then
|
|
495
|
+
* its `close()`, then — after the queued frames settled (or, on a
|
|
496
|
+
* silent stop, without waiting for one still pending) — the carrier's
|
|
497
|
+
* `done` hook. A throwing stop/close/done is swallowed; termination is
|
|
498
|
+
* unconditional.
|
|
499
|
+
* @param {boolean} drain - wait for the frames already queued (an `end`
|
|
500
|
+
* or `error` event the peer should still receive) before `done`
|
|
501
|
+
* @returns {Promise<void>}
|
|
502
|
+
*/
|
|
503
|
+
function release(drain) {
|
|
504
|
+
if (releasing) return done;
|
|
505
|
+
releasing = true;
|
|
506
|
+
finished = true;
|
|
507
|
+
abort.abort();
|
|
508
|
+
(async () => {
|
|
509
|
+
const stop = stopSub;
|
|
510
|
+
stopSub = null;
|
|
511
|
+
if (stop !== null) {
|
|
512
|
+
try {
|
|
513
|
+
await stop();
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// a throwing stop never blocks the close
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
await sub.close();
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
// a throwing close never blocks termination
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
await (drain ? queue.end() : queue.abort(new Error('the stream was stopped')));
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
// a failed carrier queue is exactly what a silent release expects
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
await hooks.done(terminalReason);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
// the carrier's cleanup must not break termination
|
|
536
|
+
}
|
|
537
|
+
})().then(() => completion.resolve(), () => completion.resolve());
|
|
538
|
+
return done;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** @type {StreamRunner} */
|
|
542
|
+
const runner = {
|
|
543
|
+
stop: (reason) => {
|
|
544
|
+
if (finished) return;
|
|
545
|
+
if (reason !== null) {
|
|
546
|
+
emit(() => hooks.end(reason, lastSeq));
|
|
547
|
+
release(true);
|
|
548
|
+
}
|
|
549
|
+
else release(false);
|
|
550
|
+
},
|
|
551
|
+
done,
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* End with an error intent: the carrier renders the event (behind the
|
|
556
|
+
* frames still queued), then the subscription is released.
|
|
557
|
+
* @param {ErrorIntent} intent
|
|
558
|
+
* @param {unknown} cause
|
|
559
|
+
* @param {DeclaredStreamFailure | null} [declared]
|
|
560
|
+
*/
|
|
561
|
+
function fail(intent, cause, declared = null) {
|
|
562
|
+
if (finished) return;
|
|
563
|
+
emit(() => hooks.error(intent, cause, lastSeq, declared));
|
|
564
|
+
release(true);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Read, validate and queue a fresh snapshot at `seq`.
|
|
187
569
|
* @param {number} seq
|
|
188
|
-
* @param {boolean}
|
|
570
|
+
* @param {{ reset: boolean, earliestAvailable: number | null, highWatermark: number | null }} info
|
|
189
571
|
* @returns {boolean} false when the stream ended instead
|
|
190
572
|
*/
|
|
191
|
-
function emitSnapshot(seq,
|
|
573
|
+
function emitSnapshot(seq, info) {
|
|
192
574
|
const snap = readSnapshot(sub);
|
|
193
575
|
if (!snap.ok) {
|
|
194
576
|
fail('source', snap.cause);
|
|
@@ -202,105 +584,158 @@ export function runSubscription(route, sub, hooks, options) {
|
|
|
202
584
|
}
|
|
203
585
|
}
|
|
204
586
|
lastSeq = seq;
|
|
205
|
-
|
|
206
|
-
return
|
|
587
|
+
const value = snap.value;
|
|
588
|
+
return emit(() => hooks.snapshot(seq, {
|
|
589
|
+
value, resumed: false, reset: info.reset, earliestAvailable: info.earliestAvailable, highWatermark: info.highWatermark,
|
|
590
|
+
}));
|
|
207
591
|
}
|
|
208
592
|
|
|
209
593
|
/**
|
|
210
|
-
* One emission from the subscription, delivered or
|
|
594
|
+
* One emission from the subscription, delivered or held until the
|
|
595
|
+
* initial events are decided.
|
|
211
596
|
* @param {any} emission
|
|
212
597
|
*/
|
|
213
598
|
function deliver(emission) {
|
|
214
599
|
if (finished) return;
|
|
215
600
|
if (!ready) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
try {
|
|
223
|
-
sourceError = emission === null || typeof emission !== 'object' ? new TypeError('the subscription emitted a non-object') : undefined;
|
|
224
|
-
if (sourceError === undefined && emission.error !== undefined) sourceError = emission.error;
|
|
225
|
-
if (sourceError === undefined) {
|
|
226
|
-
patch = emission.patch;
|
|
227
|
-
seq = emission.seq;
|
|
601
|
+
// held until replay decided the stream's start; the bound applies
|
|
602
|
+
// here too — a source that outruns a page load is a slow consumer
|
|
603
|
+
// of its own log
|
|
604
|
+
if (early.length + 1 > limits.queue.events) {
|
|
605
|
+
overflow(0);
|
|
606
|
+
return;
|
|
228
607
|
}
|
|
229
|
-
|
|
230
|
-
catch (err) {
|
|
231
|
-
sourceError = err;
|
|
232
|
-
}
|
|
233
|
-
if (sourceError !== undefined) {
|
|
234
|
-
fail('source', sourceError);
|
|
608
|
+
early.push(emission);
|
|
235
609
|
return;
|
|
236
610
|
}
|
|
237
|
-
|
|
238
|
-
|
|
611
|
+
const item = classifyEmission(emission);
|
|
612
|
+
if (item.kind === 'error') {
|
|
613
|
+
// a source error whose code the operation declares crosses as
|
|
614
|
+
// that declared failure; anything else is the host fault
|
|
615
|
+
const declared = declaredFailureOf(item.cause, route);
|
|
616
|
+
fail(declared === null ? 'source' : 'declared', item.cause, declared);
|
|
239
617
|
return;
|
|
240
618
|
}
|
|
241
|
-
if (seq <= lastSeq && lastSeq !== 0) return; //
|
|
242
|
-
if (maxPatchBytes !== null && JSON.stringify(patch).length > maxPatchBytes) {
|
|
619
|
+
if (item.seq <= lastSeq && lastSeq !== 0) return; // already delivered, or reflected by a snapshot
|
|
620
|
+
if (maxPatchBytes !== null && JSON.stringify(item.patch).length > maxPatchBytes) {
|
|
243
621
|
// the consumer swaps its document instead of patching it (§18.1)
|
|
244
|
-
emitSnapshot(seq, false);
|
|
622
|
+
emitSnapshot(item.seq, { reset: false, earliestAvailable: null, highWatermark: null });
|
|
245
623
|
return;
|
|
246
624
|
}
|
|
247
|
-
lastSeq = seq;
|
|
248
|
-
|
|
625
|
+
lastSeq = item.seq;
|
|
626
|
+
const at = item.seq;
|
|
627
|
+
const patch = item.patch;
|
|
628
|
+
emit(() => hooks.patch(at, { patch, seq: at }));
|
|
249
629
|
}
|
|
250
630
|
|
|
251
631
|
// subscribe FIRST so nothing between the subscription and the initial
|
|
252
|
-
// events is lost; emissions
|
|
253
|
-
// In the synchronous snapshot path nothing can land in between — the
|
|
254
|
-
// buffer exists for the (possibly asynchronous) replay path.
|
|
632
|
+
// events is lost; emissions are held until the initial events are out
|
|
255
633
|
try {
|
|
256
634
|
stopSub = sub.subscribe(deliver);
|
|
257
635
|
}
|
|
258
636
|
catch (err) {
|
|
259
637
|
fail('source', err);
|
|
260
|
-
return
|
|
638
|
+
return runner;
|
|
261
639
|
}
|
|
262
640
|
if (typeof stopSub !== 'function') stopSub = null;
|
|
263
641
|
|
|
264
|
-
/** Flush what
|
|
642
|
+
/** Flush what was held while the initial events were decided. */
|
|
265
643
|
function flush() {
|
|
266
644
|
ready = true;
|
|
267
|
-
while (
|
|
645
|
+
while (early.length > 0 && !finished) deliver(early.shift());
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** The highest valid seq among the emissions held so far. */
|
|
649
|
+
function heldSeq() {
|
|
650
|
+
let top = 0;
|
|
651
|
+
for (let i = 0; i < early.length; i++) {
|
|
652
|
+
const item = classifyEmission(early[i]);
|
|
653
|
+
if (item.kind === 'patch' && item.seq > top) top = item.seq;
|
|
654
|
+
}
|
|
655
|
+
return top;
|
|
268
656
|
}
|
|
269
657
|
|
|
270
658
|
const wantsReplay = options.lastSeq !== null && policy.resume === 'replay' && typeof sub.replay === 'function';
|
|
271
|
-
if (wantsReplay) {
|
|
272
|
-
|
|
659
|
+
if (!wantsReplay) {
|
|
660
|
+
// a fresh stream, or a resume the policy answers with a snapshot
|
|
661
|
+
if (emitSnapshot(0, { reset: false, earliestAvailable: null, highWatermark: null })) flush();
|
|
662
|
+
return runner;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The page loop: one page per call from `after`, patches delivered in
|
|
667
|
+
* order, until the FIRST page's high watermark is reached or the log
|
|
668
|
+
* says there is no more; a `resetRequired` page discards the replay
|
|
669
|
+
* and re-seeds with a snapshot whose id covers every emission already
|
|
670
|
+
* held.
|
|
671
|
+
* @param {number} after
|
|
672
|
+
* @param {number | null} target
|
|
673
|
+
*/
|
|
674
|
+
function page(after, target) {
|
|
675
|
+
if (finished) return;
|
|
676
|
+
let answer;
|
|
273
677
|
try {
|
|
274
|
-
|
|
678
|
+
answer = /** @type {NonNullable<SubscriptionLike['replay']>} */ (sub.replay)(after,
|
|
679
|
+
{ limit: limits.replay.limit, maxBytes: limits.replay.maxBytes, signal: abort.signal });
|
|
275
680
|
}
|
|
276
681
|
catch (err) {
|
|
277
682
|
fail('source', err);
|
|
278
|
-
return
|
|
683
|
+
return;
|
|
279
684
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
flush();
|
|
685
|
+
/** @param {unknown} result */
|
|
686
|
+
const settle = (result) => {
|
|
687
|
+
if (finished) return; // stopped while the page loaded: a late page is ignored
|
|
688
|
+
if (result === null || result === undefined) {
|
|
689
|
+
// the source cannot replay this cursor at all: a fresh snapshot,
|
|
690
|
+
// resume refused (JC2095) — informational, never a fault
|
|
691
|
+
if (emitSnapshot(0, { reset: false, earliestAvailable: null, highWatermark: null })) flush();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const fault = pageFault(result, after, limits.replay);
|
|
695
|
+
if (fault !== null) {
|
|
696
|
+
fail('source', new TypeError(`the replay of '${route.op.id}' answered an invalid page: ${fault}`));
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const p = /** @type {ReplayPage} */ (result);
|
|
700
|
+
if (p.resetRequired) {
|
|
701
|
+
// a total refusal: no suffix, a fresh snapshot instead. Its id
|
|
702
|
+
// is the higher of the log's watermark and anything the live
|
|
703
|
+
// source already delivered into the hold, because the snapshot
|
|
704
|
+
// read now reflects every one of those emissions (§17.1)
|
|
705
|
+
const effective = Math.max(p.highWatermark, heldSeq());
|
|
706
|
+
if (emitSnapshot(effective, { reset: true, earliestAvailable: p.earliestAvailable, highWatermark: effective })) flush();
|
|
707
|
+
return;
|
|
287
708
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
709
|
+
const goal = target === null ? p.highWatermark : target;
|
|
710
|
+
lastSeq = after;
|
|
711
|
+
ready = true;
|
|
712
|
+
for (let i = 0; i < p.items.length && !finished; i++) {
|
|
713
|
+
const item = /** @type {{ patch: unknown[], seq: number }} */ (p.items[i]);
|
|
714
|
+
if (item.seq <= lastSeq) continue;
|
|
715
|
+
lastSeq = item.seq;
|
|
716
|
+
const at = item.seq;
|
|
717
|
+
const patch = item.patch;
|
|
718
|
+
if (!emit(() => hooks.patch(at, { patch, seq: at }))) return;
|
|
291
719
|
}
|
|
720
|
+
ready = false;
|
|
721
|
+
const next = p.next === undefined ? lastSeq : Math.max(p.next, lastSeq);
|
|
722
|
+
if (!p.hasMore || next >= goal || p.items.length === 0) {
|
|
723
|
+
// caught up to the target: what the live source delivered
|
|
724
|
+
// meanwhile follows, minus what the pages already covered
|
|
725
|
+
flush();
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
page(next, goal);
|
|
292
729
|
};
|
|
293
|
-
if (isThenable(
|
|
294
|
-
toPromise(
|
|
730
|
+
if (isThenable(answer)) {
|
|
731
|
+
toPromise(answer).then(settle, (/** @type {unknown} */ err) => {
|
|
732
|
+
if (!finished) fail('source', err);
|
|
733
|
+
});
|
|
295
734
|
}
|
|
296
|
-
else settle(
|
|
735
|
+
else settle(answer);
|
|
297
736
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (emitSnapshot(0, false)) flush();
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
return stopper;
|
|
737
|
+
page(/** @type {number} */ (options.lastSeq), null);
|
|
738
|
+
return runner;
|
|
304
739
|
}
|
|
305
740
|
|
|
306
741
|
export { STREAM_ERRORS, STREAM_EVENTS, STREAM_MEDIA, HEARTBEAT_LINE, encodeStreamEvent } from './sse.js';
|