@rindle/api-server 0.7.12 → 0.8.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.
@@ -0,0 +1,1047 @@
1
+ // LM stream checkpointing — the two-plane response path (designs/LM-STREAM-CHECKPOINT-DESIGN.md).
2
+ //
3
+ // A model response arrives as hundreds of tiny deltas per second. Every one wants to be on a screen
4
+ // immediately; none wants to be a durable write. So the response runs on TWO planes sharing ONE
5
+ // monotone coordinate (`seq`, in UTF-16 code units of the response text):
6
+ //
7
+ // - the LIVE plane (this module): process-local, volatile, every delta, straight to subscribers;
8
+ // - the DURABLE plane: the app's own tables, advanced at coarse boundaries — N chars, T ms, an
9
+ // explicit `flush()`, and always at close.
10
+ //
11
+ // The contract both planes stand on (§0):
12
+ //
13
+ // S (splice) durableText(f) ++ concat(frames delivered from f) == producedText, for every join
14
+ // offset f and every join time.
15
+ // P (prefix) durableText is always a PREFIX of producedText, and durableSeq is monotone.
16
+ //
17
+ // P is what makes S mechanical — two prefixes of one string merge by taking the longer (see
18
+ // {@link spliceStreamText}). Everything else here is bookkeeping in service of those two.
19
+ //
20
+ // TWO THINGS THIS DELIBERATELY IS NOT (both were wrong in the first cut of this module):
21
+ //
22
+ // 1. Checkpoints are SYSTEM WRITES, not mutation envelopes. `lmid` is client-authored and exists
23
+ // to release a client's optimistic rebase point; a server-authored checkpoint has no
24
+ // prediction to release, and CDC → IVM fanout needs no envelope at all. Checkpoints therefore
25
+ // go through `outsideSql` with no clientID/mid — the same rule the realtime lifecycle writes
26
+ // follow ("a system write must never advance an lmid").
27
+ // 2. A checkpoint INSERTS A CHUNK ROW; it never appends to a growing column. A normalized `edit`
28
+ // carries BOTH the old and the new full row, so advancing a `body` column would re-stream the
29
+ // whole message twice per checkpoint — O(n²) wire to deliver n characters. One row per chunk
30
+ // makes each checkpoint a single `add` carrying only its own slice.
31
+ //
32
+ // Durability is never overclaimed (§0, the RINDLE-REALTIME §8.1 "ack honesty" rule): a `chunk` frame
33
+ // says only "produced", a `durable` frame says "committed", an `end` frame says "sealed". Losing
34
+ // this plane entirely costs latency and the un-checkpointed tail — never consistency: a client with
35
+ // no live stream still sees the message grow through IVM at checkpoint granularity, which is why
36
+ // `absent`/`stale` are ordinary answers rather than errors.
37
+ // Type-only (erased at runtime) — no import cycle with ./index.ts.
38
+ import { STREAM_STATUS_STREAMING, frameResumePoint } from "@rindle/client";
39
+ // ------------------------------------------------------------------------------- the wire
40
+ //
41
+ // The frame shapes and the two pure reassembly functions live in `@rindle/client` — BOTH tiers need
42
+ // them, and a browser must never pull this package to read a stream. Re-exported here so the
43
+ // server-side import path is unchanged.
44
+ export { STREAM_STATUS_STREAMING, assembleDurableText, frameResumePoint, spliceStreamText, } from "@rindle/client";
45
+ // ------------------------------------------------------------------------------- defaults
46
+ const DEFAULT_CHECKPOINT_CHARS = 512;
47
+ const DEFAULT_CHECKPOINT_INTERVAL_MS = 750;
48
+ const DEFAULT_CHECKPOINT_RETRIES = 3;
49
+ const DEFAULT_RETAIN_CHARS = 64 * 1024;
50
+ const DEFAULT_LINGER_MS = 30_000;
51
+ const DEFAULT_MAX_QUEUED_FRAMES = 1024;
52
+ /** Retry backoff base — 50ms, 100ms, 200ms, … A checkpoint failure is usually a blip at the write
53
+ * authority; the text is safe in the buffer meanwhile, so there is nothing to rush. */
54
+ const RETRY_BACKOFF_MS = 50;
55
+ /** A SCHEDULING timer — the checkpoint cadence, the linger window, the SSE keep-alive. It never
56
+ * holds the process open: none of them is work in flight, and a stream plane must not keep a CLI
57
+ * alive. `unref` is Node-only — Workers' `setTimeout` has no such method, hence the guard. */
58
+ function timer(fn, ms) {
59
+ const t = setTimeout(fn, ms);
60
+ t.unref?.();
61
+ return t;
62
+ }
63
+ /** The retry backoff, and deliberately NOT unref'd: a checkpoint mid-retry IS work in flight, and a
64
+ * process that exits during it drops text the caller is still awaiting. */
65
+ function delay(ms) {
66
+ return new Promise((resolve) => setTimeout(resolve, ms));
67
+ }
68
+ function errText(err) {
69
+ return String(err?.message ?? err);
70
+ }
71
+ /** A per-plane stand-in for {@link RindleStreamOptions.hostId} when only the open CAS — not
72
+ * subscribe routing — needs a token. */
73
+ function randomOpenToken() {
74
+ const c = globalThis.crypto;
75
+ return c?.randomUUID?.() ?? `open-${Math.random().toString(36).slice(2)}`;
76
+ }
77
+ export function resolveStreamColumns(columns) {
78
+ return {
79
+ key: columns?.key ?? "id",
80
+ body: columns?.body ?? "body",
81
+ status: columns?.status ?? "status",
82
+ seq: columns?.seq ?? "seq",
83
+ ...(columns?.cancel !== undefined ? { cancel: columns.cancel } : {}),
84
+ ...(columns?.error !== undefined ? { error: columns.error } : {}),
85
+ ...(columns?.host !== undefined ? { host: columns.host } : {}),
86
+ chunkKey: columns?.chunkKey ?? "id",
87
+ chunkStream: columns?.chunkStream ?? "streamId",
88
+ chunkSeq: columns?.chunkSeq ?? "seq",
89
+ chunkText: columns?.chunkText ?? "text",
90
+ };
91
+ }
92
+ /** Quote an identifier for both dialects (`"x"` is standard in SQLite and Postgres alike). An
93
+ * embedded quote is refused rather than escaped: a mapping is app configuration, and a name that
94
+ * needs escaping is a mistake worth failing loudly on. */
95
+ function q(identifier) {
96
+ if (identifier.includes('"'))
97
+ throw new TypeError(`invalid stream column/table name: ${JSON.stringify(identifier)}`);
98
+ return `"${identifier}"`;
99
+ }
100
+ /** The chunk row's deterministic id: a replayed checkpoint collides with itself and is absorbed by
101
+ * `ON CONFLICT DO NOTHING` — idempotency without an envelope or a dedup ledger (§3.3). */
102
+ export function streamChunkId(streamId, seq) {
103
+ return `${streamId}:${seq}`;
104
+ }
105
+ /**
106
+ * The chunk table's DDL, for the app's migration. The app owns the message table (this only states
107
+ * the three columns the plane needs on it); the chunk table is entirely protocol-shaped, so it is
108
+ * generated rather than hand-written.
109
+ */
110
+ export function streamChunkTableDdl(tables, dialect) {
111
+ const c = resolveStreamColumns(tables.columns);
112
+ const text = dialect.name === "postgres" ? "text" : "TEXT";
113
+ const int = dialect.name === "postgres" ? "bigint" : "INTEGER";
114
+ return [
115
+ `CREATE TABLE IF NOT EXISTS ${q(tables.chunks)} (` +
116
+ `${q(c.chunkKey)} ${text} PRIMARY KEY, ` +
117
+ `${q(c.chunkStream)} ${text} NOT NULL, ` +
118
+ `${q(c.chunkSeq)} ${int} NOT NULL, ` +
119
+ `${q(c.chunkText)} ${text} NOT NULL)`,
120
+ // The read path is always "this message's chunks, in order" — and compaction deletes by stream.
121
+ `CREATE INDEX IF NOT EXISTS ${q(`${tables.chunks}_stream_seq`)} ` +
122
+ `ON ${q(tables.chunks)} (${q(c.chunkStream)}, ${q(c.chunkSeq)})`,
123
+ ];
124
+ }
125
+ /** The plane's SQL, one place, both dialects. */
126
+ class MappedTableSql {
127
+ tables;
128
+ cols;
129
+ dialect;
130
+ constructor(tables, dialect) {
131
+ this.tables = tables;
132
+ this.cols = resolveStreamColumns(tables.columns);
133
+ this.dialect = dialect;
134
+ }
135
+ p(i) {
136
+ return this.dialect.placeholder(i);
137
+ }
138
+ /** The open probe: the row must exist and be empty. A generation is never a resume — regenerating
139
+ * is a NEW message id — so an already-advanced row is a bug worth refusing loudly. */
140
+ probeOpen() {
141
+ const { key, seq, status } = this.cols;
142
+ return `SELECT ${q(seq)} AS seq, ${q(status)} AS status FROM ${q(this.tables.message)} WHERE ${q(key)} = ${this.p(1)}`;
143
+ }
144
+ /** The open write. With a `host` column mapped this is the CAS half of the single-flight guard:
145
+ * set the status AND this producer's token only if no producer holds the row, verified by the
146
+ * read-back in {@link probeHost}. Without one it is the plain flip the probe already vetted. */
147
+ markStreaming(streamId, hostToken) {
148
+ const { key, status, host } = this.cols;
149
+ if (host !== undefined) {
150
+ return {
151
+ sql: `UPDATE ${q(this.tables.message)} SET ${q(status)} = ${this.p(1)}, ${q(host)} = ${this.p(2)} ` +
152
+ `WHERE ${q(key)} = ${this.p(3)} AND ${q(status)} <> ${this.p(4)}`,
153
+ params: [STREAM_STATUS_STREAMING, hostToken, streamId, STREAM_STATUS_STREAMING],
154
+ };
155
+ }
156
+ return {
157
+ sql: `UPDATE ${q(this.tables.message)} SET ${q(status)} = ${this.p(1)} WHERE ${q(key)} = ${this.p(2)}`,
158
+ params: [STREAM_STATUS_STREAMING, streamId],
159
+ };
160
+ }
161
+ /** The read-back half of the open CAS, or `undefined` when no `host` column is mapped. */
162
+ probeHost() {
163
+ const { host, key } = this.cols;
164
+ if (host === undefined)
165
+ return undefined;
166
+ return `SELECT ${q(host)} AS host FROM ${q(this.tables.message)} WHERE ${q(key)} = ${this.p(1)}`;
167
+ }
168
+ /** ONE transaction: insert the slice, then CAS the message's durable length. BOTH statements are
169
+ * gated on the same `seq = :from` predicate: the CAS refuses to apply twice or out of order, and
170
+ * the guarded insert makes a STALE RE-COVER a no-op — after a committed-but-ack-lost append, the
171
+ * next slice's `from` lags the row, and an unguarded insert would land an OVERLAPPING chunk row
172
+ * (a different deterministic id, so `ON CONFLICT` alone cannot absorb it) and corrupt the
173
+ * assembled text until compaction. The `ON CONFLICT` clause stays as the belt under that
174
+ * guard's braces. Which case actually happened is decided by the read-back ({@link probeState}),
175
+ * never assumed (§3.2/§3.3). */
176
+ append(streamId, from, seq, text) {
177
+ const c = this.cols;
178
+ return [
179
+ {
180
+ sql: `INSERT INTO ${q(this.tables.chunks)} ` +
181
+ `(${q(c.chunkKey)}, ${q(c.chunkStream)}, ${q(c.chunkSeq)}, ${q(c.chunkText)}) ` +
182
+ `SELECT ${this.p(1)}, ${this.p(2)}, ${this.p(3)}, ${this.p(4)} ` +
183
+ `WHERE EXISTS (SELECT 1 FROM ${q(this.tables.message)} ` +
184
+ `WHERE ${q(c.key)} = ${this.p(5)} AND ${q(c.seq)} = ${this.p(6)}) ` +
185
+ `ON CONFLICT DO NOTHING`,
186
+ params: [streamChunkId(streamId, seq), streamId, seq, text, streamId, from],
187
+ },
188
+ {
189
+ sql: `UPDATE ${q(this.tables.message)} SET ${q(c.seq)} = ${this.p(1)} ` +
190
+ `WHERE ${q(c.key)} = ${this.p(2)} AND ${q(c.seq)} = ${this.p(3)}`,
191
+ params: [seq, streamId, from],
192
+ },
193
+ ];
194
+ }
195
+ /** Compaction (§3.4): write the whole body, seal the status, drop every chunk — atomically. Also
196
+ * the repair path: whatever the chunks did or did not capture, the body ends up correct. */
197
+ compact(streamId, body, status, error) {
198
+ const c = this.cols;
199
+ const sets = [`${q(c.body)} = ${this.p(1)}`, `${q(c.seq)} = ${this.p(2)}`, `${q(c.status)} = ${this.p(3)}`];
200
+ const params = [body, body.length, status];
201
+ if (c.error !== undefined) {
202
+ sets.push(`${q(c.error)} = ${this.p(params.length + 1)}`);
203
+ params.push(error ?? null);
204
+ }
205
+ params.push(streamId);
206
+ return [
207
+ {
208
+ sql: `UPDATE ${q(this.tables.message)} SET ${sets.join(", ")} WHERE ${q(c.key)} = ${this.p(params.length)}`,
209
+ params,
210
+ },
211
+ {
212
+ sql: `DELETE FROM ${q(this.tables.chunks)} WHERE ${q(c.chunkStream)} = ${this.p(1)}`,
213
+ params: [streamId],
214
+ },
215
+ ];
216
+ }
217
+ get hasCancel() {
218
+ return this.cols.cancel !== undefined;
219
+ }
220
+ /** The post-append read-back: the row's authoritative length — what CONFIRMS an append applied
221
+ * (and absorbs a committed-but-ack-lost one) — plus the cancel flag when mapped (§6). */
222
+ probeState() {
223
+ const { cancel, key, seq } = this.cols;
224
+ const picked = cancel === undefined ? `${q(seq)} AS seq` : `${q(seq)} AS seq, ${q(cancel)} AS cancel`;
225
+ return `SELECT ${picked} FROM ${q(this.tables.message)} WHERE ${q(key)} = ${this.p(1)}`;
226
+ }
227
+ }
228
+ // ------------------------------------------------------------------------------- subscribers
229
+ /** One attached reader. Frames queue; a reader that stops draining is bounded and then DROPPED with
230
+ * `stale` rather than allowed to pin the producer's memory — it resolves that by rejoining. */
231
+ class Subscriber {
232
+ queue = [];
233
+ waiter;
234
+ done = false;
235
+ cap;
236
+ onDetach;
237
+ constructor(cap, onDetach) {
238
+ this.cap = cap;
239
+ this.onDetach = onDetach;
240
+ }
241
+ /** @returns false when the queue overflowed (caller drops this subscriber). */
242
+ offer(frame) {
243
+ if (this.done)
244
+ return true;
245
+ if (this.waiter) {
246
+ const w = this.waiter;
247
+ this.waiter = undefined;
248
+ w({ value: frame, done: false });
249
+ return true;
250
+ }
251
+ if (this.queue.length >= this.cap)
252
+ return false;
253
+ this.queue.push(frame);
254
+ return true;
255
+ }
256
+ /** Deliver a terminal frame (bypassing the cap — it is the last one) and end the iterator. */
257
+ finish(frame) {
258
+ if (this.done)
259
+ return;
260
+ if (this.waiter) {
261
+ const w = this.waiter;
262
+ this.waiter = undefined;
263
+ this.done = true;
264
+ w({ value: frame, done: false });
265
+ return;
266
+ }
267
+ this.queue.push(frame);
268
+ this.done = true;
269
+ }
270
+ close() {
271
+ this.done = true;
272
+ this.queue.length = 0;
273
+ if (this.waiter) {
274
+ const w = this.waiter;
275
+ this.waiter = undefined;
276
+ w({ value: undefined, done: true });
277
+ }
278
+ this.onDetach(this);
279
+ }
280
+ frames() {
281
+ const self = this;
282
+ return {
283
+ [Symbol.asyncIterator]() {
284
+ return {
285
+ next() {
286
+ const queued = self.queue.shift();
287
+ if (queued !== undefined)
288
+ return Promise.resolve({ value: queued, done: false });
289
+ if (self.done) {
290
+ self.onDetach(self);
291
+ return Promise.resolve({ value: undefined, done: true });
292
+ }
293
+ return new Promise((resolve) => {
294
+ self.waiter = resolve;
295
+ });
296
+ },
297
+ return() {
298
+ self.close();
299
+ return Promise.resolve({ value: undefined, done: true });
300
+ },
301
+ };
302
+ },
303
+ };
304
+ }
305
+ }
306
+ class LiveStream {
307
+ /** Retained text; `buf[0]` sits at offset {@link bufFrom}. */
308
+ buf = "";
309
+ bufFrom = 0;
310
+ seq = 0;
311
+ durableSeq = 0;
312
+ ended = false;
313
+ cancelled = false;
314
+ streamId;
315
+ user;
316
+ meta;
317
+ plane;
318
+ subs = new Set();
319
+ sealing;
320
+ draining = false;
321
+ forced = false;
322
+ /** The last drain round left a slice uncommitted: wait out the interval instead of re-attempting
323
+ * immediately, so a down store costs a retry cadence and not a hot loop. */
324
+ stalled = false;
325
+ tick = null;
326
+ waiters = [];
327
+ sealed;
328
+ sealError;
329
+ constructor(streamId, user, meta, plane) {
330
+ this.streamId = streamId;
331
+ this.user = user;
332
+ this.meta = meta;
333
+ this.plane = plane;
334
+ }
335
+ // ---- producer side
336
+ push(text) {
337
+ if (this.sealing || this.ended)
338
+ throw new Error(`stream ${this.streamId} is closed`);
339
+ if (text.length === 0)
340
+ return;
341
+ const from = this.seq;
342
+ this.buf += text;
343
+ this.seq += text.length;
344
+ this.fanout({ type: "chunk", from, seq: this.seq, text });
345
+ if (this.seq - this.durableSeq >= this.plane.chars)
346
+ this.kick(true);
347
+ else
348
+ this.arm();
349
+ }
350
+ flush() {
351
+ const at = this.seq;
352
+ if (this.durableSeq >= at)
353
+ return Promise.resolve(this.durableSeq);
354
+ // After a failed seal the shortfall is PERMANENT (the stream ended; nothing will retry it), so a
355
+ // late flush rejects honestly instead of quietly re-running the seal.
356
+ if (this.ended) {
357
+ return Promise.reject(new Error(`stream ${this.streamId} ended with only ${this.durableSeq} of ${this.seq} characters durable`));
358
+ }
359
+ const p = new Promise((resolve, reject) => {
360
+ this.waiters.push({ at, resolve, reject });
361
+ });
362
+ this.kick(true);
363
+ return p;
364
+ }
365
+ /** Seal the stream. Resolves once the terminal checkpoint has committed; rejects if it could not
366
+ * (the stream still ENDS — subscribers always get their `end` frame — the caller just learns the
367
+ * store is short of what was produced). */
368
+ seal(status, error) {
369
+ // Already sealed: hand back the SAME settled promise (already marked handled below), so a second
370
+ // `close()`/`fail()` can neither re-seal nor mint a stray rejection.
371
+ if (this.ended)
372
+ return this.sealed?.promise ?? Promise.resolve();
373
+ if (!this.sealing) {
374
+ this.sealing = { status, error };
375
+ let resolve;
376
+ let reject;
377
+ const promise = new Promise((res, rej) => {
378
+ resolve = res;
379
+ reject = rej;
380
+ });
381
+ // A `fail()` nobody awaited must not become an unhandled rejection at process level. This
382
+ // marks the promise handled WITHOUT consuming it — a caller that does await still sees the
383
+ // rejection, because `await` attaches its own handler to the same promise.
384
+ promise.catch(() => { });
385
+ this.sealed = { resolve, reject, promise };
386
+ this.disarm();
387
+ this.kick(true);
388
+ }
389
+ return this.sealed.promise;
390
+ }
391
+ // ---- checkpoint driving
392
+ arm() {
393
+ if (this.tick || this.seq === this.durableSeq)
394
+ return;
395
+ this.tick = timer(() => {
396
+ this.tick = null;
397
+ this.kick(true);
398
+ }, this.plane.intervalMs);
399
+ }
400
+ disarm() {
401
+ if (this.tick)
402
+ clearTimeout(this.tick);
403
+ this.tick = null;
404
+ }
405
+ kick(force) {
406
+ if (force)
407
+ this.forced = true;
408
+ if (this.draining)
409
+ return;
410
+ this.draining = true;
411
+ void this.drain();
412
+ }
413
+ async drain() {
414
+ this.stalled = false;
415
+ try {
416
+ for (;;) {
417
+ // Sealing goes STRAIGHT to the seal: it writes the whole body (§3.4), so committing the
418
+ // outstanding tail as one more chunk first would be pure waste — and the seal repairs any
419
+ // earlier checkpoint that failed, which a tail commit could not.
420
+ if (this.sealing) {
421
+ await this.commitSeal();
422
+ break;
423
+ }
424
+ const behind = this.seq - this.durableSeq;
425
+ if (behind > 0 && (this.forced || behind >= this.plane.chars)) {
426
+ // Consume the force BEFORE the round, not after: a `flush()` that lands while this round
427
+ // is already in flight re-sets it and gets a round of its OWN — clearing it afterwards
428
+ // would swallow that request and leave the flush to the interval cadence.
429
+ this.forced = false;
430
+ this.disarm();
431
+ const failure = await this.commitTail();
432
+ if (failure) {
433
+ this.stalled = true;
434
+ // A seal requested WHILE this checkpoint was in flight outranks the retry cadence: the
435
+ // seal writes the whole body anyway, so it both supersedes and repairs the failed slice.
436
+ // Backing off here instead would make `close()` wait out `intervalMs` for nothing. An
437
+ // explicit re-force during the failed round likewise earns one immediate re-attempt —
438
+ // someone is waiting on it — while an unforced failure stalls to the cadence.
439
+ if (!this.sealing && !this.forced)
440
+ break;
441
+ }
442
+ continue;
443
+ }
444
+ this.forced = false;
445
+ break;
446
+ }
447
+ }
448
+ finally {
449
+ this.draining = false;
450
+ if (!this.ended) {
451
+ // A pending seal ALWAYS proceeds at once — including out of a stall (see the loop above);
452
+ // otherwise a stall waits out the interval, and deltas that landed mid-commit may already
453
+ // meet the threshold and deserve an immediate round.
454
+ if (this.sealing)
455
+ this.kick(false);
456
+ else if (this.stalled)
457
+ this.arm();
458
+ else if (this.seq - this.durableSeq >= this.plane.chars)
459
+ this.kick(false);
460
+ else
461
+ this.arm();
462
+ }
463
+ }
464
+ }
465
+ /** @returns the failure, or `undefined` when the slice committed. */
466
+ async commitTail() {
467
+ const from = this.durableSeq;
468
+ const to = this.seq;
469
+ const text = this.buf.slice(from - this.bufFrom, to - this.bufFrom);
470
+ let out;
471
+ try {
472
+ out = await this.plane.commit({ kind: "append", streamId: this.streamId, from, seq: to, text });
473
+ }
474
+ catch (err) {
475
+ // The store may hold MORE than we believe — an append that committed while its ack was lost,
476
+ // somewhere in the retry chain. The read-back is the truth, and adopting it is what keeps the
477
+ // NEXT slice contiguous instead of overlapping (§3.3).
478
+ const truth = await this.plane.probeDurableSeq(this.streamId);
479
+ if (truth !== undefined && truth > this.durableSeq)
480
+ this.advanceDurable(truth);
481
+ this.plane.reportCheckpointError(err, { streamId: this.streamId, from, seq: to });
482
+ // Waiters that asked for THIS prefix learn it did not land; later waiters keep waiting for a
483
+ // later attempt. A durability failure is never silent.
484
+ this.rejectWaiters(to, err);
485
+ return { err };
486
+ }
487
+ if (out?.cancelRequested)
488
+ this.cancelled = true;
489
+ // `durableSeq` advances to what the store CONFIRMED — the mapped path reads it back, a `commit`
490
+ // callback may report it — never to a plane-side assumption. A confirmation short of `to` means
491
+ // this slice's `from` was stale (a prior ack loss the read-back could not see at the time): the
492
+ // guarded statements made it a no-op, and the next round re-covers from the confirmed offset.
493
+ const confirmed = out?.seq ?? to;
494
+ if (confirmed > this.durableSeq)
495
+ this.advanceDurable(confirmed);
496
+ if (confirmed < to) {
497
+ const err = new Error(`stream ${this.streamId}: the store confirmed ${confirmed} of ${to} — re-covering from there`);
498
+ this.plane.reportCheckpointError(err, { streamId: this.streamId, from, seq: to });
499
+ this.rejectWaiters(to, err);
500
+ return { err };
501
+ }
502
+ return undefined;
503
+ }
504
+ /** The one place durable progress is recorded: position, `durable` frame, buffer trim, waiters. */
505
+ advanceDurable(seq) {
506
+ this.durableSeq = seq;
507
+ this.fanout({ type: "durable", seq });
508
+ this.trim();
509
+ this.resolveWaiters();
510
+ }
511
+ async commitSeal() {
512
+ const seal = this.sealing;
513
+ const from = this.durableSeq;
514
+ try {
515
+ await this.plane.commit({
516
+ kind: "close",
517
+ streamId: this.streamId,
518
+ from,
519
+ seq: this.seq,
520
+ // A trimmed buffer (only reachable in `commit`-callback mode, where the app opted into
521
+ // trimming) cannot supply the whole body: `bodyFrom` says where the retained text starts,
522
+ // so the app can still append exactly the outstanding tail (`body.slice(from - bodyFrom)`).
523
+ body: this.buf,
524
+ bodyFrom: this.bufFrom,
525
+ status: seal.status,
526
+ ...(seal.error !== undefined ? { error: seal.error } : {}),
527
+ });
528
+ this.durableSeq = this.seq;
529
+ }
530
+ catch (err) {
531
+ this.sealError = err;
532
+ this.plane.reportCheckpointError(err, { streamId: this.streamId, from, seq: this.seq });
533
+ }
534
+ this.ended = true;
535
+ this.disarm();
536
+ this.rejectWaiters(Number.POSITIVE_INFINITY, new Error(`stream ${this.streamId} ended with only ${this.durableSeq} of ${this.seq} characters durable`));
537
+ this.resolveWaiters();
538
+ const frame = {
539
+ type: "end",
540
+ seq: this.durableSeq,
541
+ status: seal.status,
542
+ ...(seal.error !== undefined ? { error: seal.error } : {}),
543
+ };
544
+ for (const sub of [...this.subs])
545
+ sub.finish(frame);
546
+ this.plane.retire(this.streamId);
547
+ if (this.sealError)
548
+ this.sealed?.reject(this.sealError);
549
+ else
550
+ this.sealed?.resolve();
551
+ }
552
+ /** Keep everything at or above `durableSeq`, plus a slack window below it so a client whose IVM
553
+ * view lags one checkpoint can still join without a `stale` round trip (§4). A `retainChars` of
554
+ * `Infinity` (the mapped-table default — compaction needs the whole text) never trims. */
555
+ trim() {
556
+ if (!Number.isFinite(this.plane.retainChars))
557
+ return;
558
+ const floor = Math.max(0, this.durableSeq - this.plane.retainChars);
559
+ if (floor <= this.bufFrom)
560
+ return;
561
+ this.buf = this.buf.slice(floor - this.bufFrom);
562
+ this.bufFrom = floor;
563
+ }
564
+ resolveWaiters() {
565
+ if (this.waiters.length === 0)
566
+ return;
567
+ const still = [];
568
+ for (const w of this.waiters) {
569
+ if (w.at <= this.durableSeq)
570
+ w.resolve(this.durableSeq);
571
+ else
572
+ still.push(w);
573
+ }
574
+ this.waiters = still;
575
+ }
576
+ rejectWaiters(upTo, err) {
577
+ if (this.waiters.length === 0)
578
+ return;
579
+ const still = [];
580
+ for (const w of this.waiters) {
581
+ if (w.at <= upTo && w.at > this.durableSeq)
582
+ w.reject(err);
583
+ else
584
+ still.push(w);
585
+ }
586
+ this.waiters = still;
587
+ }
588
+ // ---- subscriber side
589
+ fanout(frame) {
590
+ for (const sub of [...this.subs]) {
591
+ if (!sub.offer(frame)) {
592
+ // Bounded, then dropped: a reader that stopped draining costs itself a rejoin, never the
593
+ // producer's memory.
594
+ sub.finish({ type: "stale", floorSeq: this.bufFrom, durableSeq: this.durableSeq });
595
+ this.subs.delete(sub);
596
+ }
597
+ }
598
+ }
599
+ /** Attach a reader at `from`. Runs in ONE synchronous block — snapshot, replay, register — so no
600
+ * delta can slip between the replay and the live tail (the gap-free half of contract S). */
601
+ subscribe(from) {
602
+ const sub = new Subscriber(this.plane.maxQueuedFrames, (s) => this.subs.delete(s));
603
+ // Floored as well as clamped: a fractional `from` (a hand-built subscribe input) would otherwise
604
+ // produce a replay chunk whose `text.length !== seq - from`, breaking the frame invariant.
605
+ const at = Math.min(Math.max(Math.floor(from), 0), this.seq);
606
+ if (at < this.bufFrom) {
607
+ sub.finish({ type: "stale", floorSeq: this.bufFrom, durableSeq: this.durableSeq });
608
+ return this.wrap(sub);
609
+ }
610
+ sub.offer({ type: "open", streamId: this.streamId, from: at, seq: this.seq, durableSeq: this.durableSeq, ended: this.ended });
611
+ if (this.seq > at) {
612
+ sub.offer({ type: "chunk", from: at, seq: this.seq, text: this.buf.slice(at - this.bufFrom) });
613
+ }
614
+ if (this.durableSeq > at)
615
+ sub.offer({ type: "durable", seq: this.durableSeq });
616
+ if (this.ended) {
617
+ const seal = this.sealing ?? { status: "interrupted" };
618
+ sub.finish({
619
+ type: "end",
620
+ seq: this.durableSeq,
621
+ status: seal.status,
622
+ ...(seal.error !== undefined ? { error: seal.error } : {}),
623
+ });
624
+ return this.wrap(sub);
625
+ }
626
+ this.subs.add(sub);
627
+ return this.wrap(sub);
628
+ }
629
+ wrap(sub) {
630
+ return { streamId: this.streamId, frames: sub.frames(), close: () => sub.close() };
631
+ }
632
+ /** Drop every reader without sealing (process teardown — `drainStreams` seals first). */
633
+ detachAll() {
634
+ for (const sub of [...this.subs])
635
+ sub.close();
636
+ this.subs.clear();
637
+ this.disarm();
638
+ }
639
+ }
640
+ export class StreamPlane {
641
+ chars;
642
+ intervalMs;
643
+ retries;
644
+ retainChars;
645
+ lingerMs;
646
+ maxQueuedFrames;
647
+ live = new Map();
648
+ opts;
649
+ sink;
650
+ mapped;
651
+ tables;
652
+ /** The open CAS token (§5.1): `hostId`, or a random per-plane stand-in when only the CAS — not
653
+ * routing — needs it. */
654
+ openToken;
655
+ constructor(opts, sink) {
656
+ this.opts = opts;
657
+ this.sink = sink;
658
+ this.chars = opts.policy?.chars ?? DEFAULT_CHECKPOINT_CHARS;
659
+ this.intervalMs = opts.policy?.intervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS;
660
+ this.retries = opts.policy?.retries ?? DEFAULT_CHECKPOINT_RETRIES;
661
+ this.lingerMs = opts.lingerMs ?? DEFAULT_LINGER_MS;
662
+ this.maxQueuedFrames = opts.maxQueuedFrames ?? DEFAULT_MAX_QUEUED_FRAMES;
663
+ this.openToken = opts.hostId ?? randomOpenToken();
664
+ if ("tables" in opts.checkpoint) {
665
+ if (!sink)
666
+ throw new TypeError("streams.checkpoint.tables needs a SQL-capable mutation backend");
667
+ this.tables = opts.checkpoint.tables;
668
+ this.mapped = new MappedTableSql(opts.checkpoint.tables, sink.dialect);
669
+ // Options this mode cannot honour are refused loudly (the room-profile rule), never ignored.
670
+ if (opts.retainChars !== undefined) {
671
+ throw new TypeError("streams.retainChars is `commit`-mode only — mapped tables retain the whole text for compaction (§3.4)");
672
+ }
673
+ if (opts.hostId !== undefined && resolveStreamColumns(opts.checkpoint.tables.columns).host === undefined) {
674
+ throw new TypeError("streams.hostId does nothing in tables mode until columns.host names where to persist it");
675
+ }
676
+ // Compaction writes the WHOLE body at close, so the buffer is retained in full. `retainChars`
677
+ // is honoured only where the app owns persistence and may not need the tail.
678
+ this.retainChars = Number.POSITIVE_INFINITY;
679
+ }
680
+ else {
681
+ this.retainChars = opts.retainChars ?? DEFAULT_RETAIN_CHARS;
682
+ }
683
+ }
684
+ /** Open a stream on an EXISTING message row (§5): the app's own mutator wrote it, alongside the
685
+ * user's prompt, so the pointer is already durable and every client's query already shows the
686
+ * message. This verifies it and flips it to `streaming`. */
687
+ async open(input) {
688
+ if (this.live.has(input.streamId)) {
689
+ throw new Error(`stream ${input.streamId} is already open on this host`);
690
+ }
691
+ const stream = new LiveStream(input.streamId, input.user, input.meta, this);
692
+ this.live.set(input.streamId, stream);
693
+ try {
694
+ if (this.mapped)
695
+ await this.assertOpenable(input.streamId);
696
+ await this.commit({
697
+ kind: "open",
698
+ streamId: input.streamId,
699
+ meta: input.meta ?? null,
700
+ ...(this.opts.hostId !== undefined ? { hostId: this.opts.hostId } : {}),
701
+ startedAt: Date.now(),
702
+ });
703
+ }
704
+ catch (err) {
705
+ this.live.delete(input.streamId);
706
+ throw err;
707
+ }
708
+ return {
709
+ streamId: stream.streamId,
710
+ get seq() {
711
+ return stream.seq;
712
+ },
713
+ get durableSeq() {
714
+ return stream.durableSeq;
715
+ },
716
+ get cancelled() {
717
+ return stream.cancelled;
718
+ },
719
+ push: (text) => stream.push(text),
720
+ flush: () => stream.flush(),
721
+ pump: async (deltas) => {
722
+ // `for await` closes the iterator on `break`, which is what aborts the SDK's underlying
723
+ // request — the whole point of honouring cancellation here rather than in the caller.
724
+ for await (const d of deltas) {
725
+ stream.push(d);
726
+ if (stream.cancelled)
727
+ break;
728
+ }
729
+ },
730
+ close: () => stream.seal(stream.cancelled ? "cancelled" : "complete"),
731
+ fail: (error) => stream.seal("error", errText(error)),
732
+ };
733
+ }
734
+ /**
735
+ * The read-only precondition on the app's message row, checked ONCE per `open` (§5.1). Read-only on
736
+ * purpose: it is a decision about the app's data, so re-deciding it per write attempt would let a
737
+ * lost ack turn a success into a refusal.
738
+ *
739
+ * The `streaming` check is the **single-flight guard**, and it lives at the ROW rather than in this
740
+ * process's map because the thing it defends against is distributed: the kick that starts a
741
+ * generation is an at-least-once effect (a retried mutation envelope re-runs its post-commit code,
742
+ * §10.5), so a second kick can land on another instance, where the in-memory map is empty. Two
743
+ * producers on one `streamId` would interleave: both CAS the same length, one stalls, and whichever
744
+ * closes last overwrites the body with ITS buffer. Cheap to refuse; expensive to debug.
745
+ *
746
+ * This probe alone is check-then-act — it and the open write are separate round trips, so two
747
+ * SIMULTANEOUS kicks can both pass it. Mapping a `host` column closes that window: the open write
748
+ * turns conditional and {@link verifyOpenWinner}'s read-back names the winner.
749
+ */
750
+ async assertOpenable(streamId) {
751
+ const cols = resolveStreamColumns(this.tables.columns);
752
+ const rows = await this.sink.sql.query(this.mapped.probeOpen(), [streamId]);
753
+ const row = rows[0];
754
+ if (!row) {
755
+ throw new StreamOpenRefused(`stream ${streamId}: no row in "${this.tables.message}" — the app's own mutator must write the message ` +
756
+ `(with its chatId/role/…) BEFORE opening the stream on it`);
757
+ }
758
+ // A NULL length is refused as loudly as an advanced one, and for a subtler reason: it is the CAS
759
+ // column, and `WHERE seq = 0` can never match NULL. A nullable column would let every chunk
760
+ // insert land while its length CAS silently matched nothing — the one failure mode this plane
761
+ // cannot detect (`batch` reports no row count) and one compaction would paper over (§5.1).
762
+ if (typeof row.seq !== "number") {
763
+ throw new StreamOpenRefused(`stream ${streamId}: "${this.tables.message}"."${cols.seq}" is ${row.seq === null ? "NULL" : typeof row.seq} — ` +
764
+ `the length column must be NOT NULL DEFAULT 0 (it is compare-and-swapped on every checkpoint, and no ` +
765
+ `comparison matches NULL)`);
766
+ }
767
+ if (row.seq !== 0) {
768
+ throw new StreamOpenRefused(`stream ${streamId}: the message row already holds ${row.seq} characters — a regeneration is a NEW message ` +
769
+ `id, never a resume of an advanced row`);
770
+ }
771
+ if (row.status === STREAM_STATUS_STREAMING) {
772
+ throw new StreamOpenRefused(`stream ${streamId}: "${cols.status}" is already "${STREAM_STATUS_STREAMING}" — another producer holds this ` +
773
+ `stream. Two producers on one stream interleave their checkpoints; if the first one's host died, the ` +
774
+ `sweeper marking it "interrupted" is what releases the row (§7)`);
775
+ }
776
+ }
777
+ async subscribe(input) {
778
+ const stream = this.live.get(input.streamId);
779
+ const from = input.from ?? 0;
780
+ // Authorize BEFORE existence is consulted: a denial must not double as an existence oracle.
781
+ const verdict = await this.opts.authorize({
782
+ user: input.user,
783
+ streamId: input.streamId,
784
+ from,
785
+ meta: stream === undefined ? undefined : stream.meta,
786
+ request: input.request,
787
+ });
788
+ if (verdict === false)
789
+ throw new StreamForbidden(input.streamId);
790
+ if (!stream)
791
+ return oneFrame(input.streamId, { type: "absent" });
792
+ return stream.subscribe(from);
793
+ }
794
+ /** Seal every live stream `interrupted`. In mapped-table mode the seal IS the compaction, so a
795
+ * graceful drain loses nothing PRODUCED; the status still says the response was cut short rather
796
+ * than claiming completion (§5). Wire it to SIGTERM. */
797
+ async drainStreams() {
798
+ await Promise.allSettled([...this.live.values()].map((s) => s.seal("interrupted")));
799
+ }
800
+ /** Teardown: drop readers and timers WITHOUT a durable write (that is `drainStreams`). */
801
+ closeSync() {
802
+ for (const s of this.live.values())
803
+ s.detachAll();
804
+ this.live.clear();
805
+ }
806
+ /** A sealed stream stays joinable for the linger window, so a subscribe that races the last token
807
+ * still gets `end` (and the tail it missed) rather than a bare `absent`. */
808
+ retire(streamId) {
809
+ timer(() => {
810
+ const entry = this.live.get(streamId);
811
+ if (entry?.ended) {
812
+ this.live.delete(streamId);
813
+ entry.detachAll();
814
+ }
815
+ }, this.lingerMs);
816
+ }
817
+ reportCheckpointError(err, info) {
818
+ // Reporting runs INSIDE the drain loop, so a throwing hook would take the loop down with it and
819
+ // wedge the stream — the one failure mode a diagnostic must never cause.
820
+ try {
821
+ if (this.opts.onCheckpointError)
822
+ this.opts.onCheckpointError(err, info);
823
+ else
824
+ console.error(`[rindle api-server] stream ${info.streamId}: checkpoint ${info.from}→${info.seq} failed:`, err);
825
+ }
826
+ catch (hookErr) {
827
+ console.error(`[rindle api-server] stream ${info.streamId}: onCheckpointError itself threw:`, hookErr);
828
+ }
829
+ }
830
+ /** Drive ONE checkpoint, retrying on failure. Every statement it emits is idempotent under replay
831
+ * — the chunk insert dedups on its deterministic id, the length CAS refuses to apply twice, the
832
+ * compaction is a whole-row overwrite — so "retry until it sticks" needs no dedup ledger and no
833
+ * `lmid` (§3.3). */
834
+ async commit(input) {
835
+ let lastErr;
836
+ for (let attempt = 0; attempt <= this.retries; attempt++) {
837
+ if (attempt > 0)
838
+ await delay(RETRY_BACKOFF_MS * 2 ** (attempt - 1));
839
+ try {
840
+ return await this.commitOnce(input);
841
+ }
842
+ catch (err) {
843
+ if (err instanceof StreamOpenRefused)
844
+ throw err; // a settled verdict, not a blip
845
+ lastErr = err;
846
+ }
847
+ }
848
+ throw lastErr;
849
+ }
850
+ async commitOnce(input) {
851
+ const target = this.opts.checkpoint;
852
+ if ("commit" in target)
853
+ return (await target.commit(input)) ?? undefined;
854
+ const sql = this.sink.sql;
855
+ const mapped = this.mapped;
856
+ switch (input.kind) {
857
+ case "open": {
858
+ // The PRECONDITION is checked once, in `assertOpenable`, before this write — never here.
859
+ // Re-reading it per attempt would make a lost ack on the write below refuse its own success
860
+ // (the row would already say `streaming`).
861
+ await sql.batch([mapped.markStreaming(input.streamId, this.openToken)]);
862
+ await this.verifyOpenWinner(input.streamId);
863
+ return undefined;
864
+ }
865
+ case "append": {
866
+ try {
867
+ await sql.batch(mapped.append(input.streamId, input.from, input.seq, input.text));
868
+ }
869
+ catch (err) {
870
+ // The batch may have COMMITTED with its ack lost. The read-back is the truth: a row at
871
+ // (or past) this slice's end means it landed, and the throw was only the reply.
872
+ const truth = await this.readAppendState(input.streamId).catch(() => undefined);
873
+ if (truth === undefined || truth.seq < input.seq)
874
+ throw err;
875
+ return truth;
876
+ }
877
+ // The read-back CONFIRMS the append (the guarded statements report no row count). If it
878
+ // throws, the retry is safe: a replay of an applied slice no-ops on the guard and confirms
879
+ // on its own read-back.
880
+ return await this.readAppendState(input.streamId);
881
+ }
882
+ case "close": {
883
+ await sql.batch(mapped.compact(input.streamId, input.body, input.status, input.error));
884
+ return undefined;
885
+ }
886
+ }
887
+ }
888
+ /** The read-back half of the open CAS, run only when a `host` column is mapped. The probe in
889
+ * `assertOpenable` and the open write are separate round trips, so bare check-then-act leaves a
890
+ * window where two simultaneous kicks both pass the probe; the conditional `markStreaming`
891
+ * matches nothing when a rival got there first, and whichever token the row now holds names the
892
+ * winner — a true CAS with no row counts needed. A replayed open (lost ack) reads back its OWN
893
+ * token and proceeds. */
894
+ async verifyOpenWinner(streamId) {
895
+ const probe = this.mapped.probeHost();
896
+ if (probe === undefined)
897
+ return;
898
+ const rows = await this.sink.sql.query(probe, [streamId]);
899
+ if (rows[0]?.host !== this.openToken) {
900
+ throw new StreamOpenRefused(`stream ${streamId}: another producer won the open race (the row's host is ` +
901
+ `${JSON.stringify(rows[0]?.host ?? null)}) — two producers on one stream interleave their checkpoints, ` +
902
+ `so the loser stands down`);
903
+ }
904
+ }
905
+ /** The post-append read-back (§3.2): the row's authoritative length — which both CONFIRMS an
906
+ * append (the guarded batch reports no row count) and absorbs a committed-but-ack-lost one —
907
+ * plus the reader's cancel flag when mapped (§6), riding the same indexed point read. This read
908
+ * is LOAD-BEARING: a failure here fails the append attempt (retried, then stalled), because
909
+ * claiming durability the store did not confirm is the one dishonesty this plane refuses. */
910
+ async readAppendState(streamId) {
911
+ const rows = await this.sink.sql.query(this.mapped.probeState(), [
912
+ streamId,
913
+ ]);
914
+ const row = rows[0];
915
+ if (row === undefined || typeof row.seq !== "number") {
916
+ throw new Error(`stream ${streamId}: the read-back found no usable message row`);
917
+ }
918
+ return { seq: row.seq, ...(this.mapped.hasCancel ? { cancelRequested: Boolean(row.cancel) } : {}) };
919
+ }
920
+ /** The store's word on how much is durable — for resynchronizing after a FAILED append, where a
921
+ * lost ack may have left the store ahead of the plane. `undefined` in `commit` mode (no readable
922
+ * authority) or when the read itself fails (the next attempt retries the resync too). */
923
+ async probeDurableSeq(streamId) {
924
+ if (!this.mapped)
925
+ return undefined;
926
+ try {
927
+ return (await this.readAppendState(streamId)).seq;
928
+ }
929
+ catch {
930
+ return undefined;
931
+ }
932
+ }
933
+ }
934
+ /** The open probe's verdict: the message row is missing or already advanced. Not retried — it is a
935
+ * settled statement about the app's data, and retrying re-asks a question already answered. */
936
+ export class StreamOpenRefused extends Error {
937
+ constructor(message) {
938
+ super(message);
939
+ this.name = "StreamOpenRefused";
940
+ }
941
+ }
942
+ /** Refused by {@link RindleStreamOptions.authorize}. Distinct from the api-server's own
943
+ * `RindleApiError` so this module stays importable without the server (no cycle); the server
944
+ * translates it to a 403 at the handler seam. */
945
+ export class StreamForbidden extends Error {
946
+ streamId;
947
+ constructor(streamId) {
948
+ super(`stream ${streamId}: forbidden`);
949
+ this.name = "StreamForbidden";
950
+ this.streamId = streamId;
951
+ }
952
+ }
953
+ function oneFrame(streamId, frame) {
954
+ let taken = false;
955
+ return {
956
+ streamId,
957
+ frames: {
958
+ [Symbol.asyncIterator]() {
959
+ return {
960
+ next() {
961
+ if (taken)
962
+ return Promise.resolve({ value: undefined, done: true });
963
+ taken = true;
964
+ return Promise.resolve({ value: frame, done: false });
965
+ },
966
+ };
967
+ },
968
+ },
969
+ close: () => {
970
+ taken = true;
971
+ },
972
+ };
973
+ }
974
+ // ------------------------------------------------------------------------------- SSE transport
975
+ /** Headers for the SSE response. `x-accel-buffering` is the nginx-family opt-out — without it a
976
+ * buffering proxy holds the tokens and hands the user a paragraph at a time. */
977
+ export const STREAM_SSE_HEADERS = {
978
+ "content-type": "text/event-stream; charset=utf-8",
979
+ "cache-control": "no-cache, no-transform",
980
+ connection: "keep-alive",
981
+ "x-accel-buffering": "no",
982
+ };
983
+ /** Pull a subscribe request out of a fetch-style GET: `?streamId=…&from=…`, with `Last-Event-ID`
984
+ * winning over an explicit `from` (a reconnecting `EventSource` knows better than its own URL —
985
+ * the URL is the ORIGINAL join point, the header is where it actually got to). */
986
+ export function streamRequestFromHttp(req) {
987
+ const url = new URL(req.url);
988
+ const streamId = url.searchParams.get("streamId") ?? "";
989
+ const lastEventId = req.headers.get("last-event-id");
990
+ const raw = lastEventId ?? url.searchParams.get("from") ?? "0";
991
+ const from = Number.parseInt(raw, 10);
992
+ return { streamId, from: Number.isFinite(from) && from > 0 ? from : 0 };
993
+ }
994
+ /**
995
+ * Encode a subscription as an SSE body. Each positional frame carries `id: <seq>`, so a browser
996
+ * `EventSource` that drops the connection resumes at exactly the right offset with no application
997
+ * code — its own `Last-Event-ID` header is the `from` of the next subscribe ({@link
998
+ * streamRequestFromHttp}).
999
+ *
1000
+ * The reader must close the `EventSource` on the `end` frame: `EventSource` reconnects on ANY close,
1001
+ * including a clean one.
1002
+ */
1003
+ export function streamFramesToSse(sub, opts) {
1004
+ const encoder = new TextEncoder();
1005
+ const keepAliveMs = opts?.keepAliveMs ?? 15_000;
1006
+ let ping = null;
1007
+ let cancelled = false;
1008
+ let iter;
1009
+ // PULL-based on purpose: a frame is taken from the subscription only when the consumer has
1010
+ // demand, so a slow HTTP client backs frames up in the SUBSCRIBER's bounded queue — where
1011
+ // `maxQueuedFrames` drops it with `stale` (§4) — instead of unboundedly in this stream's own.
1012
+ return new ReadableStream({
1013
+ start(controller) {
1014
+ iter = sub.frames[Symbol.asyncIterator]();
1015
+ const beat = () => {
1016
+ if (cancelled)
1017
+ return;
1018
+ // A comment line: keeps intermediaries from reaping an idle connection, costs 8 bytes.
1019
+ controller.enqueue(encoder.encode(": ping\n\n"));
1020
+ ping = timer(beat, keepAliveMs);
1021
+ };
1022
+ ping = timer(beat, keepAliveMs);
1023
+ },
1024
+ async pull(controller) {
1025
+ const next = await iter.next();
1026
+ if (cancelled)
1027
+ return;
1028
+ if (next.done) {
1029
+ if (ping)
1030
+ clearTimeout(ping);
1031
+ sub.close();
1032
+ controller.close();
1033
+ return;
1034
+ }
1035
+ const id = frameResumePoint(next.value);
1036
+ const head = id === undefined ? "" : `id: ${id}\n`;
1037
+ controller.enqueue(encoder.encode(`${head}data: ${JSON.stringify(next.value)}\n\n`));
1038
+ },
1039
+ cancel() {
1040
+ cancelled = true;
1041
+ if (ping)
1042
+ clearTimeout(ping);
1043
+ sub.close();
1044
+ },
1045
+ });
1046
+ }
1047
+ //# sourceMappingURL=streams.js.map