@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.
Files changed (52) hide show
  1. package/README.md +191 -30
  2. package/dist/types/adapters/fetch.d.ts +11 -10
  3. package/dist/types/adapters/node.d.ts +24 -10
  4. package/dist/types/client/http.d.ts +77 -10
  5. package/dist/types/compat.d.ts +1 -1
  6. package/dist/types/errors.d.ts +3 -0
  7. package/dist/types/host.d.ts +179 -0
  8. package/dist/types/http/body.d.ts +147 -0
  9. package/dist/types/http/dispatch.d.ts +26 -2
  10. package/dist/types/http/serve.d.ts +46 -3
  11. package/dist/types/http/wire.d.ts +31 -17
  12. package/dist/types/ledger.d.ts +57 -12
  13. package/dist/types/local/index.d.ts +7 -1
  14. package/dist/types/messages.d.ts +2 -0
  15. package/dist/types/path.d.ts +4 -2
  16. package/dist/types/pipeline.d.ts +15 -1
  17. package/dist/types/port/client.d.ts +18 -1
  18. package/dist/types/port/serve.d.ts +38 -5
  19. package/dist/types/project/tools.d.ts +1 -1
  20. package/dist/types/project/typescript.d.ts +11 -0
  21. package/dist/types/runtime.d.ts +25 -0
  22. package/dist/types/stream/client.d.ts +14 -3
  23. package/dist/types/stream/server.d.ts +218 -44
  24. package/dist/types/stream/sse.d.ts +10 -0
  25. package/docs/APP-INTEGRATION.md +4 -2
  26. package/docs/CONTRACT-FORMAT.md +617 -145
  27. package/package.json +5 -5
  28. package/src/adapters/fetch.js +144 -25
  29. package/src/adapters/node.js +246 -82
  30. package/src/cli.js +22 -16
  31. package/src/client/http.js +588 -189
  32. package/src/compat.js +1 -1
  33. package/src/errors.js +3 -0
  34. package/src/host.js +319 -0
  35. package/src/http/body.js +337 -0
  36. package/src/http/dispatch.js +511 -75
  37. package/src/http/serve.js +39 -5
  38. package/src/http/wire.js +33 -14
  39. package/src/ledger.js +119 -36
  40. package/src/local/index.js +91 -35
  41. package/src/messages.js +2 -0
  42. package/src/path.js +9 -3
  43. package/src/pipeline.js +18 -1
  44. package/src/port/client.js +39 -6
  45. package/src/port/serve.js +207 -69
  46. package/src/project/tools.js +9 -2
  47. package/src/project/typescript.js +91 -1
  48. package/src/project/typescript.jtlt.json +39 -7
  49. package/src/runtime.js +36 -0
  50. package/src/stream/client.js +40 -6
  51. package/src/stream/server.js +573 -138
  52. package/src/stream/sse.js +2 -0
@@ -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(seq)`), decide
7
- * resumption, read and validate the snapshot, forward each emission,
8
- * and guarantee `stop()` then `close()` run **exactly once** however
9
- * the stream ends — peer disconnect, unsubscribe, server shutdown, an
10
- * error emission, an invalid snapshot. The HTTP dispatcher renders the
11
- * intents this module raises as SSE events; the port server renders
12
- * them as push frames — the sequencing is decided here once so the two
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` — every one settles into the
18
- * `fault` intent (the cause for the binding's `onError`, never the
19
- * wire) or is swallowed at close, and the stream still terminates.
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 duck-typed subscription of docs/CONTRACT-FORMAT.md §17.1.
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) => (() => void),
34
- * close: () => void, replay?: (seq: number) => unknown, mode?: unknown }} SubscriptionLike
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
- * What the carrier renders. Every hook is called at most once per
39
- * event, in wire order; after `error` or `end` no further hook fires.
40
- * `error` carries the intent (`'invalid-snapshot'` send `JC2091`;
41
- * `'source'` the subscription emitted `{ error }`, send the host
42
- * fault code) and the cause for the observer. `done` fires exactly once
43
- * after the stream terminated for any reason the carrier releases
44
- * its resources (timers, registries) there.
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, value: unknown, resumed: boolean) => void} snapshot
47
- * @property {(seq: number, emission: { patch: unknown[], seq: number }) => void} patch
48
- * @property {(intent: 'invalid-snapshot' | 'source', cause: unknown, lastSeq: number) => void} error
49
- * @property {(reason: string, lastSeq: number) => void} end
50
- * @property {() => void} done
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: `stop(reason)` with a string emits the `end`
99
- * event with that reason first (a server shutdown); `stop(null)` is
100
- * silent (the peer is gone, or asked). Idempotent; the subscription's
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 {{ stop: (reason: string | null) => void }}
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 buffered = [];
117
- /** @type {(() => void) | null} */
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 one stopper every path returns: on a finished stream it is a
123
- * no-op, so an early-failed stream hands back the same shape.
124
- * @type {{ stop: (reason: string | null) => void }}
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 stopper = {
127
- stop: (reason) => {
128
- if (finished) return;
129
- if (reason !== null) {
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
- /** Release the subscription exactly once; a throwing stop/close is swallowed. */
142
- function release() {
143
- if (finished) return;
144
- finished = true;
145
- if (stopSub !== null) {
146
- try {
147
- stopSub();
148
- }
149
- catch {
150
- // a throwing stop never blocks the close
151
- }
152
- stopSub = null;
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
- sub.close();
440
+ frame = render();
156
441
  }
157
- catch {
158
- // a throwing close never blocks termination
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.done();
449
+ bytes = Math.max(0, Number(hooks.size(frame)) || 0);
162
450
  }
163
451
  catch {
164
- // the carrier's cleanup must not break termination
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
- * End with an error intent: the carrier renders the event, then the
170
- * subscription is released.
171
- * @param {'invalid-snapshot' | 'source'} intent
172
- * @param {unknown} cause
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 fail(intent, cause) {
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(intent, cause, lastSeq);
475
+ frame = hooks.error('slow-consumer', cause, lastSeq, null);
178
476
  }
179
477
  catch {
180
- // a carrier that cannot render still releases
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
- * Read, validate and emit a fresh snapshot at `seq`.
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} resumed
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, resumed) {
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
- hooks.snapshot(seq, snap.value, resumed);
206
- return true;
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 buffered.
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
- buffered.push(emission);
217
- return;
218
- }
219
- let patch;
220
- let seq;
221
- let sourceError;
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
- if (!Array.isArray(patch) || !Number.isFinite(seq)) {
238
- fail('source', new TypeError('the subscription emitted a value that is not { patch, seq }'));
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; // an already-delivered record (a replay overlap)
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
- hooks.patch(seq, { patch, seq });
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 buffer until the initial events are out.
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 stopper;
638
+ return runner;
261
639
  }
262
640
  if (typeof stopSub !== 'function') stopSub = null;
263
641
 
264
- /** Flush what buffered while the initial events were decided. */
642
+ /** Flush what was held while the initial events were decided. */
265
643
  function flush() {
266
644
  ready = true;
267
- while (buffered.length > 0 && !finished) deliver(buffered.shift());
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
- let replayed;
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
- replayed = /** @type {NonNullable<SubscriptionLike['replay']>} */ (sub.replay)(/** @type {number} */ (options.lastSeq));
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 stopper;
683
+ return;
279
684
  }
280
- const settle = (/** @type {unknown} */ entries) => {
281
- if (finished) return;
282
- if (Array.isArray(entries)) {
283
- lastSeq = /** @type {number} */ (options.lastSeq);
284
- ready = true;
285
- for (let i = 0; i < entries.length && !finished; i++) deliver(entries[i]);
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
- else {
289
- // the handler cannot replay: a fresh snapshot, resume refused (JC2095)
290
- if (emitSnapshot(0, false)) flush();
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(replayed)) {
294
- toPromise(replayed).then(settle, (/** @type {unknown} */ err) => fail('source', err));
730
+ if (isThenable(answer)) {
731
+ toPromise(answer).then(settle, (/** @type {unknown} */ err) => {
732
+ if (!finished) fail('source', err);
733
+ });
295
734
  }
296
- else settle(replayed);
735
+ else settle(answer);
297
736
  }
298
- else {
299
- // a fresh stream, or a resume the policy answers with a snapshot
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';