@rindle/api-server 0.7.12 → 0.9.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.
package/src/streams.ts ADDED
@@ -0,0 +1,1341 @@
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
+
38
+ // Type-only (erased at runtime) — no import cycle with ./index.ts.
39
+ import { STREAM_STATUS_STREAMING, frameResumePoint } from "@rindle/client";
40
+ import type { StreamFrame, StreamStatus } from "@rindle/client";
41
+ import type { SqlStatement } from "@rindle/daemon-client";
42
+
43
+ import type { Authorizer, ServerSql, SqlDialect } from "./index.ts";
44
+
45
+ // ------------------------------------------------------------------------------- the wire
46
+ //
47
+ // The frame shapes and the two pure reassembly functions live in `@rindle/client` — BOTH tiers need
48
+ // them, and a browser must never pull this package to read a stream. Re-exported here so the
49
+ // server-side import path is unchanged.
50
+
51
+ export {
52
+ STREAM_STATUS_STREAMING,
53
+ assembleDurableText,
54
+ frameResumePoint,
55
+ spliceStreamText,
56
+ } from "@rindle/client";
57
+ export type { StreamFrame, StreamStatus } from "@rindle/client";
58
+
59
+ // ------------------------------------------------------------------------------- the durable seam
60
+
61
+ /** What a checkpoint hands the durable plane when the app supplies its own {@link StreamCommit}. */
62
+ export type StreamCommitInput =
63
+ /** The pointer is marked live. The app's own mutator created the row (it owns `chatId`, `role`,
64
+ * the model name…); this only flips it to `streaming`. */
65
+ | { kind: "open"; streamId: string; meta: unknown; hostId?: string; startedAt: number }
66
+ /** A prefix advance carrying ONLY its own slice. `text.length === seq - from`, and `from` is the
67
+ * last append the PLANE saw confirmed — so appends are contiguous in the fault-free run, but an
68
+ * append that committed while its ack was lost makes the next one RE-COVER (its `from` lags what
69
+ * the app already applied). See {@link StreamCommit} for the two ways to stay idempotent. */
70
+ | { kind: "append"; streamId: string; from: number; seq: number; text: string }
71
+ /** The seal. `body` is the producer's retained text and `bodyFrom` its absolute start offset:
72
+ * `bodyFrom === 0` — and `body` is the WHOLE response — unless the app opted into trimming via
73
+ * `retainChars` (`commit` mode only). A compacting app requires `bodyFrom === 0` and writes
74
+ * `body` wholesale; a non-compacting app appends the outstanding tail —
75
+ * `body.slice(from - bodyFrom)` — as a final chunk. `seq === bodyFrom + body.length`, always. */
76
+ | { kind: "close"; streamId: string; from: number; seq: number; body: string; bodyFrom: number; status: StreamStatus; error?: string };
77
+
78
+ /** The escape hatch: persist the checkpoint however the app likes. Retried on throw (§3.3), so it
79
+ * must be idempotent under a repeated `(from, seq)` — AND under a RE-COVER: an append whose write
80
+ * committed but whose ack was lost leaves the plane believing less than the store holds, so the
81
+ * next append's `(from, seq)` can OVERLAP text already applied. Apply only the unseen suffix
82
+ * (`text.slice(applied - from)` when `applied > from`), or better, return `{seq: applied}` — the
83
+ * authoritative applied length — and the plane resynchronizes instead of re-covering at all.
84
+ * Return `{cancelRequested: true}` to tell the producer the reader asked it to stop (§6). */
85
+ export type StreamCommit = (
86
+ input: StreamCommitInput,
87
+ ) => Promise<void | { cancelRequested?: boolean; seq?: number }>;
88
+
89
+ /** Which columns of the app's own tables the plane reads and writes. Every entry has a default
90
+ * except `cancel`, `error`, and `host`, which are opt-in BY NAMING: the plane never emits SQL
91
+ * against a column the app did not ask it to use. */
92
+ export interface StreamColumns {
93
+ /** The message row's primary key, matched against `streamId`. Default `id`. */
94
+ key?: string;
95
+ /** The compacted response text. Default `body`. */
96
+ body?: string;
97
+ /** {@link STREAM_STATUS_STREAMING} then a {@link StreamStatus}. Default `status`. */
98
+ status?: string;
99
+ /** Total durable length — `length(body) + Σ chunk lengths`. The CAS column (§3.2). Default `seq`. */
100
+ seq?: string;
101
+ /** Opt-in (§6): a truthy value here stops the generation at the next checkpoint. No default —
102
+ * naming it is what turns cancellation on. */
103
+ cancel?: string;
104
+ /** Opt-in: where a failed generation's message is recorded. No default. */
105
+ error?: string;
106
+ /** Opt-in: where the open write records this producer's identity ({@link RindleStreamOptions.hostId}).
107
+ * Naming it upgrades the row-level single-flight guard from check-then-act to a true
108
+ * compare-and-swap — the open write turns conditional and a read-back names the winner (§5.1) —
109
+ * and gives multi-instance subscribe routing a column to read (§4). No default. */
110
+ host?: string;
111
+ /** Chunk primary key; the plane writes the deterministic `"<streamId>:<seq>"`. Default `id`. */
112
+ chunkKey?: string;
113
+ /** Chunk → message reference. Default `streamId`. */
114
+ chunkStream?: string;
115
+ /** The chunk's END offset — the ordering key. Default `seq`. */
116
+ chunkSeq?: string;
117
+ /** The chunk's slice of the response. Default `text`. */
118
+ chunkText?: string;
119
+ }
120
+
121
+ /**
122
+ * The app's tables (§5). The app authors and migrates BOTH — the message row is unambiguously
123
+ * app-owned (it has `chatId`, `role`, token counts) and the chunk row must be reachable from the
124
+ * app's own query as a `related` subquery, which a Rindle system table would make awkward. The plane
125
+ * only needs to be told where things live. Use {@link streamChunkTableDdl} for the chunk table's
126
+ * migration.
127
+ *
128
+ * The message table carries WHATEVER ELSE the app wants; the plane's hard requirements are only:
129
+ *
130
+ * | mapped column | requirement | why |
131
+ * | --- | --- | --- |
132
+ * | `key` | UNIQUE (normally the pk) | every checkpoint targets one row by it |
133
+ * | `seq` | integer, **NOT NULL DEFAULT 0** | the CAS column — nothing matches NULL (§3.2) |
134
+ * | `body` | text, empty at open | compaction overwrites it with the whole response |
135
+ * | `status` | text accepting `streaming`/`complete`/`cancelled`/`error`/`interrupted` | a CHECK
136
+ * constraint that omits one of these turns a seal into an infra failure |
137
+ * | `cancel` | truthy-readable, if mapped | read on the checkpoint round-trip (§6) |
138
+ * | `error` | nullable text, if mapped | compaction writes `NULL` when there is no error |
139
+ * | `host` | text, if mapped | written at open with the producer's token; the read-back decides the open race (§5.1) |
140
+ *
141
+ * `body` is PLANE-OWNED and always a bare string. Rich content (an array of content blocks, tool
142
+ * calls, attachments) belongs in SIBLING columns the app's own mutators write — `flush()` orders the
143
+ * text before them. For genuinely multi-block streaming, point `message` at a per-BLOCK table
144
+ * instead: `streamId` is just an app key, so one stream per block needs nothing from this plane.
145
+ */
146
+ export interface StreamTables {
147
+ /** The app's message table. Must already contain the row when {@link StreamPlane.open} runs. */
148
+ message: string;
149
+ /** The append-only chunk table. */
150
+ chunks: string;
151
+ columns?: StreamColumns;
152
+ }
153
+
154
+ export type StreamCheckpointTarget = { tables: StreamTables } | { commit: StreamCommit };
155
+
156
+ // ------------------------------------------------------------------------------- configuration
157
+
158
+ /** When a checkpoint fires — the FIRST of these wins (§3.1). Checkpoints are serialized, so a slow
159
+ * store degrades to fewer, larger checkpoints, never to a queue of them. */
160
+ export interface StreamCheckpointPolicy {
161
+ /** Produced-but-uncommitted characters that force a checkpoint. Default 512. */
162
+ chars?: number;
163
+ /** Milliseconds since the last checkpoint that force one. Default 750. */
164
+ intervalMs?: number;
165
+ /** Retries for a failing commit before the slice is left for the next trigger. Default 3. */
166
+ retries?: number;
167
+ }
168
+
169
+ export interface AuthorizeStreamInput<User> {
170
+ user: User;
171
+ streamId: string;
172
+ /** Where the subscriber claims to be. */
173
+ from: number;
174
+ /** The `meta` this stream was opened with — `undefined` when the stream is not hosted here, which
175
+ * is precisely when the app must decide from `streamId` and its own durable state. */
176
+ meta: unknown;
177
+ request?: unknown;
178
+ }
179
+
180
+ export interface RindleStreamOptions<User> {
181
+ /** Where checkpoints land: the app's tables (the default path) or a raw `commit` callback. */
182
+ checkpoint: StreamCheckpointTarget;
183
+ /** REQUIRED. Subscribing to a stream is reading someone's chat, so there is no default-allow.
184
+ * Runs BEFORE existence is checked, so a denial cannot be used to probe for stream ids. */
185
+ authorize: Authorizer<AuthorizeStreamInput<User>>;
186
+ policy?: StreamCheckpointPolicy;
187
+ /** This process's identity — it must be UNIQUE per producer process, because the open CAS trusts
188
+ * it to distinguish rivals (§5.1). In `tables` mode, map {@link StreamColumns.host} and the open
189
+ * write persists it on the message row (so the app can route later subscribers to the hosting
190
+ * instance, §4) and uses it as the single-flight token; setting it WITHOUT a mapped `host` column
191
+ * is refused at construction. In `commit` mode it rides the `open` input. When a `host` column is
192
+ * mapped and no hostId is given, a random per-plane token is used — the CAS still holds, routing
193
+ * just has no stable name to read. */
194
+ hostId?: string;
195
+ /** Slack retained BELOW `durableSeq` so a client whose IVM view lags a checkpoint can still join
196
+ * without a `stale` round trip. Text at or above `durableSeq` is never trimmed. Default 64 KiB.
197
+ * `commit`-mode only — REFUSED (a construction-time `TypeError`) in `tables` mode, where
198
+ * compaction needs the whole produced text at close (§3.4), so the buffer is retained in full. */
199
+ retainChars?: number;
200
+ /** How long a sealed stream stays joinable before eviction. Default 30s. */
201
+ lingerMs?: number;
202
+ /** Per-subscriber frame queue cap; overflow drops that subscriber with `stale` (§4). Default 1024. */
203
+ maxQueuedFrames?: number;
204
+ /** A checkpoint that exhausted its retries. The stream keeps streaming — this is a durability
205
+ * stall, not a stream stall — so the error must not vanish. Absent ⇒ `console.error`. */
206
+ onCheckpointError?: (err: unknown, info: { streamId: string; from: number; seq: number }) => void;
207
+ }
208
+
209
+ // ------------------------------------------------------------------------------- producer handle
210
+
211
+ export interface OpenStreamInput<User> {
212
+ user: User;
213
+ /** The app's message-row id: the durable pointer AND the live plane's key. The row must already
214
+ * exist (the app's own mutator wrote it, alongside the user's prompt) with `seq = 0`. */
215
+ streamId: string;
216
+ /** Opaque app payload, passed through to a `commit` callback. Unused in `tables` mode. */
217
+ meta?: unknown;
218
+ request?: unknown;
219
+ }
220
+
221
+ /** The producer's handle (§2). One writer per stream, by construction. */
222
+ export interface StreamHandle {
223
+ readonly streamId: string;
224
+ /** Total produced code units. */
225
+ readonly seq: number;
226
+ /** Total code units the store has committed. Never exceeds {@link seq} (contract P). */
227
+ readonly durableSeq: number;
228
+ /** True once a checkpoint round-trip has seen the reader's cancel flag (§6). `pump` stops on it;
229
+ * a hand-rolled generation loop should check it. */
230
+ readonly cancelled: boolean;
231
+ /** Append a delta: fanned to subscribers synchronously, checkpointed on policy. */
232
+ push(text: string): void;
233
+ /** Force a checkpoint and resolve once the store holds every character produced so far. This is
234
+ * the ORDERING primitive: `await flush()` before writing a discrete row (a tool call, a stop
235
+ * reason) so the text precedes it in the store (§1). Rejects if the checkpoint cannot commit. */
236
+ flush(): Promise<number>;
237
+ /** Drain a delta iterable into the stream (the shape every LLM SDK's text stream already has),
238
+ * stopping early — and closing the iterator, which aborts the underlying request — once the
239
+ * reader has cancelled. */
240
+ pump(deltas: AsyncIterable<string>): Promise<void>;
241
+ /** Seal the stream: `cancelled` if the reader asked it to stop, else `complete`. In `tables` mode
242
+ * this is the compaction (§3.4) — it writes the whole body and drops the chunk rows in one
243
+ * transaction, so it also REPAIRS any checkpoint that failed along the way. */
244
+ close(): Promise<void>;
245
+ /** Seal `error` at whatever was produced. Never throws for the reason it is sealing. */
246
+ fail(error: unknown): Promise<void>;
247
+ }
248
+
249
+ export interface SubscribeStreamInput<User> {
250
+ user: User;
251
+ streamId: string;
252
+ /** "I already have this many characters" — from the client's IVM view, or a `Last-Event-ID`.
253
+ * A non-negative integer; default 0. */
254
+ from?: number;
255
+ request?: unknown;
256
+ }
257
+
258
+ export interface StreamSubscription {
259
+ readonly streamId: string;
260
+ /** Terminates after exactly one of `end` / `stale` / `absent`. */
261
+ readonly frames: AsyncIterable<StreamFrame>;
262
+ /** Detach early (a disconnected client). Idempotent. */
263
+ close(): void;
264
+ }
265
+
266
+ // ------------------------------------------------------------------------------- defaults
267
+
268
+ const DEFAULT_CHECKPOINT_CHARS = 512;
269
+ const DEFAULT_CHECKPOINT_INTERVAL_MS = 750;
270
+ const DEFAULT_CHECKPOINT_RETRIES = 3;
271
+ const DEFAULT_RETAIN_CHARS = 64 * 1024;
272
+ const DEFAULT_LINGER_MS = 30_000;
273
+ const DEFAULT_MAX_QUEUED_FRAMES = 1024;
274
+ /** Retry backoff base — 50ms, 100ms, 200ms, … A checkpoint failure is usually a blip at the write
275
+ * authority; the text is safe in the buffer meanwhile, so there is nothing to rush. */
276
+ const RETRY_BACKOFF_MS = 50;
277
+
278
+ /** A SCHEDULING timer — the checkpoint cadence, the linger window, the SSE keep-alive. It never
279
+ * holds the process open: none of them is work in flight, and a stream plane must not keep a CLI
280
+ * alive. `unref` is Node-only — Workers' `setTimeout` has no such method, hence the guard. */
281
+ function timer(fn: () => void, ms: number): ReturnType<typeof setTimeout> {
282
+ const t = setTimeout(fn, ms);
283
+ (t as { unref?: () => void }).unref?.();
284
+ return t;
285
+ }
286
+
287
+ /** The retry backoff, and deliberately NOT unref'd: a checkpoint mid-retry IS work in flight, and a
288
+ * process that exits during it drops text the caller is still awaiting. */
289
+ function delay(ms: number): Promise<void> {
290
+ return new Promise((resolve) => setTimeout(resolve, ms));
291
+ }
292
+
293
+ function errText(err: unknown): string {
294
+ return String((err as Error)?.message ?? err);
295
+ }
296
+
297
+ /** A per-plane stand-in for {@link RindleStreamOptions.hostId} when only the open CAS — not
298
+ * subscribe routing — needs a token. */
299
+ function randomOpenToken(): string {
300
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
301
+ return c?.randomUUID?.() ?? `open-${Math.random().toString(36).slice(2)}`;
302
+ }
303
+
304
+ // ------------------------------------------------------------------------------- mapped-table SQL
305
+
306
+ /** Every mapped column, defaults applied. `cancel`/`error` stay `undefined` unless named. */
307
+ export interface ResolvedStreamColumns {
308
+ key: string;
309
+ body: string;
310
+ status: string;
311
+ seq: string;
312
+ cancel?: string;
313
+ error?: string;
314
+ host?: string;
315
+ chunkKey: string;
316
+ chunkStream: string;
317
+ chunkSeq: string;
318
+ chunkText: string;
319
+ }
320
+
321
+ export function resolveStreamColumns(columns: StreamColumns | undefined): ResolvedStreamColumns {
322
+ return {
323
+ key: columns?.key ?? "id",
324
+ body: columns?.body ?? "body",
325
+ status: columns?.status ?? "status",
326
+ seq: columns?.seq ?? "seq",
327
+ ...(columns?.cancel !== undefined ? { cancel: columns.cancel } : {}),
328
+ ...(columns?.error !== undefined ? { error: columns.error } : {}),
329
+ ...(columns?.host !== undefined ? { host: columns.host } : {}),
330
+ chunkKey: columns?.chunkKey ?? "id",
331
+ chunkStream: columns?.chunkStream ?? "streamId",
332
+ chunkSeq: columns?.chunkSeq ?? "seq",
333
+ chunkText: columns?.chunkText ?? "text",
334
+ };
335
+ }
336
+
337
+ /** Quote an identifier for both dialects (`"x"` is standard in SQLite and Postgres alike). An
338
+ * embedded quote is refused rather than escaped: a mapping is app configuration, and a name that
339
+ * needs escaping is a mistake worth failing loudly on. */
340
+ function q(identifier: string): string {
341
+ if (identifier.includes('"')) throw new TypeError(`invalid stream column/table name: ${JSON.stringify(identifier)}`);
342
+ return `"${identifier}"`;
343
+ }
344
+
345
+ /** The chunk row's deterministic id: a replayed checkpoint collides with itself and is absorbed by
346
+ * `ON CONFLICT DO NOTHING` — idempotency without an envelope or a dedup ledger (§3.3). */
347
+ export function streamChunkId(streamId: string, seq: number): string {
348
+ return `${streamId}:${seq}`;
349
+ }
350
+
351
+ /**
352
+ * The chunk table's DDL, for the app's migration. The app owns the message table (this only states
353
+ * the three columns the plane needs on it); the chunk table is entirely protocol-shaped, so it is
354
+ * generated rather than hand-written.
355
+ */
356
+ export function streamChunkTableDdl(tables: StreamTables, dialect: SqlDialect): string[] {
357
+ const c = resolveStreamColumns(tables.columns);
358
+ const text = dialect.name === "postgres" ? "text" : "TEXT";
359
+ const int = dialect.name === "postgres" ? "bigint" : "INTEGER";
360
+ return [
361
+ `CREATE TABLE IF NOT EXISTS ${q(tables.chunks)} (` +
362
+ `${q(c.chunkKey)} ${text} PRIMARY KEY, ` +
363
+ `${q(c.chunkStream)} ${text} NOT NULL, ` +
364
+ `${q(c.chunkSeq)} ${int} NOT NULL, ` +
365
+ `${q(c.chunkText)} ${text} NOT NULL)`,
366
+ // The read path is always "this message's chunks, in order" — and compaction deletes by stream.
367
+ `CREATE INDEX IF NOT EXISTS ${q(`${tables.chunks}_stream_seq`)} ` +
368
+ `ON ${q(tables.chunks)} (${q(c.chunkStream)}, ${q(c.chunkSeq)})`,
369
+ ];
370
+ }
371
+
372
+ /** The plane's SQL, one place, both dialects. */
373
+ class MappedTableSql {
374
+ private readonly tables: StreamTables;
375
+ private readonly cols: ResolvedStreamColumns;
376
+ private readonly dialect: SqlDialect;
377
+
378
+ constructor(tables: StreamTables, dialect: SqlDialect) {
379
+ this.tables = tables;
380
+ this.cols = resolveStreamColumns(tables.columns);
381
+ this.dialect = dialect;
382
+ }
383
+
384
+ private p(i: number): string {
385
+ return this.dialect.placeholder(i);
386
+ }
387
+
388
+ /** The open probe: the row must exist and be empty. A generation is never a resume — regenerating
389
+ * is a NEW message id — so an already-advanced row is a bug worth refusing loudly. */
390
+ probeOpen(): string {
391
+ const { key, seq, status } = this.cols;
392
+ return `SELECT ${q(seq)} AS seq, ${q(status)} AS status FROM ${q(this.tables.message)} WHERE ${q(key)} = ${this.p(1)}`;
393
+ }
394
+
395
+ /** The open write. With a `host` column mapped this is the CAS half of the single-flight guard:
396
+ * set the status AND this producer's token only if no producer holds the row, verified by the
397
+ * read-back in {@link probeHost}. Without one it is the plain flip the probe already vetted. */
398
+ markStreaming(streamId: string, hostToken: string): SqlStatement {
399
+ const { key, status, host } = this.cols;
400
+ if (host !== undefined) {
401
+ return {
402
+ sql:
403
+ `UPDATE ${q(this.tables.message)} SET ${q(status)} = ${this.p(1)}, ${q(host)} = ${this.p(2)} ` +
404
+ `WHERE ${q(key)} = ${this.p(3)} AND ${q(status)} <> ${this.p(4)}`,
405
+ params: [STREAM_STATUS_STREAMING, hostToken, streamId, STREAM_STATUS_STREAMING],
406
+ };
407
+ }
408
+ return {
409
+ sql: `UPDATE ${q(this.tables.message)} SET ${q(status)} = ${this.p(1)} WHERE ${q(key)} = ${this.p(2)}`,
410
+ params: [STREAM_STATUS_STREAMING, streamId],
411
+ };
412
+ }
413
+
414
+ /** The read-back half of the open CAS, or `undefined` when no `host` column is mapped. */
415
+ probeHost(): string | undefined {
416
+ const { host, key } = this.cols;
417
+ if (host === undefined) return undefined;
418
+ return `SELECT ${q(host)} AS host FROM ${q(this.tables.message)} WHERE ${q(key)} = ${this.p(1)}`;
419
+ }
420
+
421
+ /** ONE transaction: insert the slice, then CAS the message's durable length. BOTH statements are
422
+ * gated on the same `seq = :from` predicate: the CAS refuses to apply twice or out of order, and
423
+ * the guarded insert makes a STALE RE-COVER a no-op — after a committed-but-ack-lost append, the
424
+ * next slice's `from` lags the row, and an unguarded insert would land an OVERLAPPING chunk row
425
+ * (a different deterministic id, so `ON CONFLICT` alone cannot absorb it) and corrupt the
426
+ * assembled text until compaction. The `ON CONFLICT` clause stays as the belt under that
427
+ * guard's braces. Which case actually happened is decided by the read-back ({@link probeState}),
428
+ * never assumed (§3.2/§3.3). */
429
+ append(streamId: string, from: number, seq: number, text: string): SqlStatement[] {
430
+ const c = this.cols;
431
+ return [
432
+ {
433
+ sql:
434
+ `INSERT INTO ${q(this.tables.chunks)} ` +
435
+ `(${q(c.chunkKey)}, ${q(c.chunkStream)}, ${q(c.chunkSeq)}, ${q(c.chunkText)}) ` +
436
+ `SELECT ${this.p(1)}, ${this.p(2)}, ${this.p(3)}, ${this.p(4)} ` +
437
+ `WHERE EXISTS (SELECT 1 FROM ${q(this.tables.message)} ` +
438
+ `WHERE ${q(c.key)} = ${this.p(5)} AND ${q(c.seq)} = ${this.p(6)}) ` +
439
+ `ON CONFLICT DO NOTHING`,
440
+ params: [streamChunkId(streamId, seq), streamId, seq, text, streamId, from],
441
+ },
442
+ {
443
+ sql:
444
+ `UPDATE ${q(this.tables.message)} SET ${q(c.seq)} = ${this.p(1)} ` +
445
+ `WHERE ${q(c.key)} = ${this.p(2)} AND ${q(c.seq)} = ${this.p(3)}`,
446
+ params: [seq, streamId, from],
447
+ },
448
+ ];
449
+ }
450
+
451
+ /** Compaction (§3.4): write the whole body, seal the status, drop every chunk — atomically. Also
452
+ * the repair path: whatever the chunks did or did not capture, the body ends up correct. */
453
+ compact(streamId: string, body: string, status: StreamStatus, error: string | undefined): SqlStatement[] {
454
+ const c = this.cols;
455
+ const sets = [`${q(c.body)} = ${this.p(1)}`, `${q(c.seq)} = ${this.p(2)}`, `${q(c.status)} = ${this.p(3)}`];
456
+ const params: Array<string | number | null> = [body, body.length, status];
457
+ if (c.error !== undefined) {
458
+ sets.push(`${q(c.error)} = ${this.p(params.length + 1)}`);
459
+ params.push(error ?? null);
460
+ }
461
+ params.push(streamId);
462
+ return [
463
+ {
464
+ sql: `UPDATE ${q(this.tables.message)} SET ${sets.join(", ")} WHERE ${q(c.key)} = ${this.p(params.length)}`,
465
+ params,
466
+ },
467
+ {
468
+ sql: `DELETE FROM ${q(this.tables.chunks)} WHERE ${q(c.chunkStream)} = ${this.p(1)}`,
469
+ params: [streamId],
470
+ },
471
+ ];
472
+ }
473
+
474
+ get hasCancel(): boolean {
475
+ return this.cols.cancel !== undefined;
476
+ }
477
+
478
+ /** The post-append read-back: the row's authoritative length — what CONFIRMS an append applied
479
+ * (and absorbs a committed-but-ack-lost one) — plus the cancel flag when mapped (§6). */
480
+ probeState(): string {
481
+ const { cancel, key, seq } = this.cols;
482
+ const picked = cancel === undefined ? `${q(seq)} AS seq` : `${q(seq)} AS seq, ${q(cancel)} AS cancel`;
483
+ return `SELECT ${picked} FROM ${q(this.tables.message)} WHERE ${q(key)} = ${this.p(1)}`;
484
+ }
485
+ }
486
+
487
+ // ------------------------------------------------------------------------------- subscribers
488
+
489
+ /** One attached reader. Frames queue; a reader that stops draining is bounded and then DROPPED with
490
+ * `stale` rather than allowed to pin the producer's memory — it resolves that by rejoining. */
491
+ class Subscriber {
492
+ private queue: StreamFrame[] = [];
493
+ private waiter?: (r: IteratorResult<StreamFrame>) => void;
494
+ private done = false;
495
+ private readonly cap: number;
496
+ private readonly onDetach: (s: Subscriber) => void;
497
+
498
+ constructor(cap: number, onDetach: (s: Subscriber) => void) {
499
+ this.cap = cap;
500
+ this.onDetach = onDetach;
501
+ }
502
+
503
+ /** @returns false when the queue overflowed (caller drops this subscriber). */
504
+ offer(frame: StreamFrame): boolean {
505
+ if (this.done) return true;
506
+ if (this.waiter) {
507
+ const w = this.waiter;
508
+ this.waiter = undefined;
509
+ w({ value: frame, done: false });
510
+ return true;
511
+ }
512
+ if (this.queue.length >= this.cap) return false;
513
+ this.queue.push(frame);
514
+ return true;
515
+ }
516
+
517
+ /** Deliver a terminal frame (bypassing the cap — it is the last one) and end the iterator. */
518
+ finish(frame: StreamFrame): void {
519
+ if (this.done) return;
520
+ if (this.waiter) {
521
+ const w = this.waiter;
522
+ this.waiter = undefined;
523
+ this.done = true;
524
+ w({ value: frame, done: false });
525
+ return;
526
+ }
527
+ this.queue.push(frame);
528
+ this.done = true;
529
+ }
530
+
531
+ close(): void {
532
+ this.done = true;
533
+ this.queue.length = 0;
534
+ if (this.waiter) {
535
+ const w = this.waiter;
536
+ this.waiter = undefined;
537
+ w({ value: undefined as never, done: true });
538
+ }
539
+ this.onDetach(this);
540
+ }
541
+
542
+ frames(): AsyncIterable<StreamFrame> {
543
+ const self = this;
544
+ return {
545
+ [Symbol.asyncIterator](): AsyncIterator<StreamFrame> {
546
+ return {
547
+ next(): Promise<IteratorResult<StreamFrame>> {
548
+ const queued = self.queue.shift();
549
+ if (queued !== undefined) return Promise.resolve({ value: queued, done: false });
550
+ if (self.done) {
551
+ self.onDetach(self);
552
+ return Promise.resolve({ value: undefined as never, done: true });
553
+ }
554
+ return new Promise((resolve) => {
555
+ self.waiter = resolve;
556
+ });
557
+ },
558
+ return(): Promise<IteratorResult<StreamFrame>> {
559
+ self.close();
560
+ return Promise.resolve({ value: undefined as never, done: true });
561
+ },
562
+ };
563
+ },
564
+ };
565
+ }
566
+ }
567
+
568
+ // ------------------------------------------------------------------------------- the live stream
569
+
570
+ interface Waiter {
571
+ at: number;
572
+ resolve: (seq: number) => void;
573
+ reject: (err: unknown) => void;
574
+ }
575
+
576
+ class LiveStream<User> {
577
+ /** Retained text; `buf[0]` sits at offset {@link bufFrom}. */
578
+ private buf = "";
579
+ private bufFrom = 0;
580
+ seq = 0;
581
+ durableSeq = 0;
582
+ ended = false;
583
+ cancelled = false;
584
+
585
+ readonly streamId: string;
586
+ readonly user: User;
587
+ readonly meta: unknown;
588
+ private readonly plane: StreamPlane<User>;
589
+
590
+ private readonly subs = new Set<Subscriber>();
591
+ private sealing?: { status: StreamStatus; error?: string };
592
+ private draining = false;
593
+ private forced = false;
594
+ /** The last drain round left a slice uncommitted: wait out the interval instead of re-attempting
595
+ * immediately, so a down store costs a retry cadence and not a hot loop. */
596
+ private stalled = false;
597
+ private tick: ReturnType<typeof setTimeout> | null = null;
598
+ private waiters: Waiter[] = [];
599
+ private sealed?: { resolve: () => void; reject: (e: unknown) => void; promise: Promise<void> };
600
+ private sealError: unknown;
601
+
602
+ constructor(streamId: string, user: User, meta: unknown, plane: StreamPlane<User>) {
603
+ this.streamId = streamId;
604
+ this.user = user;
605
+ this.meta = meta;
606
+ this.plane = plane;
607
+ }
608
+
609
+ // ---- producer side
610
+
611
+ push(text: string): void {
612
+ if (this.sealing || this.ended) throw new Error(`stream ${this.streamId} is closed`);
613
+ if (text.length === 0) return;
614
+ const from = this.seq;
615
+ this.buf += text;
616
+ this.seq += text.length;
617
+ this.fanout({ type: "chunk", from, seq: this.seq, text });
618
+ if (this.seq - this.durableSeq >= this.plane.chars) this.kick(true);
619
+ else this.arm();
620
+ }
621
+
622
+ flush(): Promise<number> {
623
+ const at = this.seq;
624
+ if (this.durableSeq >= at) return Promise.resolve(this.durableSeq);
625
+ // After a failed seal the shortfall is PERMANENT (the stream ended; nothing will retry it), so a
626
+ // late flush rejects honestly instead of quietly re-running the seal.
627
+ if (this.ended) {
628
+ return Promise.reject(
629
+ new Error(`stream ${this.streamId} ended with only ${this.durableSeq} of ${this.seq} characters durable`),
630
+ );
631
+ }
632
+ const p = new Promise<number>((resolve, reject) => {
633
+ this.waiters.push({ at, resolve, reject });
634
+ });
635
+ this.kick(true);
636
+ return p;
637
+ }
638
+
639
+ /** Seal the stream. Resolves once the terminal checkpoint has committed; rejects if it could not
640
+ * (the stream still ENDS — subscribers always get their `end` frame — the caller just learns the
641
+ * store is short of what was produced). */
642
+ seal(status: StreamStatus, error?: string): Promise<void> {
643
+ // Already sealed: hand back the SAME settled promise (already marked handled below), so a second
644
+ // `close()`/`fail()` can neither re-seal nor mint a stray rejection.
645
+ if (this.ended) return this.sealed?.promise ?? Promise.resolve();
646
+ if (!this.sealing) {
647
+ this.sealing = { status, error };
648
+ let resolve!: () => void;
649
+ let reject!: (e: unknown) => void;
650
+ const promise = new Promise<void>((res, rej) => {
651
+ resolve = res;
652
+ reject = rej;
653
+ });
654
+ // A `fail()` nobody awaited must not become an unhandled rejection at process level. This
655
+ // marks the promise handled WITHOUT consuming it — a caller that does await still sees the
656
+ // rejection, because `await` attaches its own handler to the same promise.
657
+ promise.catch(() => {});
658
+ this.sealed = { resolve, reject, promise };
659
+ this.disarm();
660
+ this.kick(true);
661
+ }
662
+ return this.sealed!.promise;
663
+ }
664
+
665
+ // ---- checkpoint driving
666
+
667
+ private arm(): void {
668
+ if (this.tick || this.seq === this.durableSeq) return;
669
+ this.tick = timer(() => {
670
+ this.tick = null;
671
+ this.kick(true);
672
+ }, this.plane.intervalMs);
673
+ }
674
+
675
+ private disarm(): void {
676
+ if (this.tick) clearTimeout(this.tick);
677
+ this.tick = null;
678
+ }
679
+
680
+ private kick(force: boolean): void {
681
+ if (force) this.forced = true;
682
+ if (this.draining) return;
683
+ this.draining = true;
684
+ void this.drain();
685
+ }
686
+
687
+ private async drain(): Promise<void> {
688
+ this.stalled = false;
689
+ try {
690
+ for (;;) {
691
+ // Sealing goes STRAIGHT to the seal: it writes the whole body (§3.4), so committing the
692
+ // outstanding tail as one more chunk first would be pure waste — and the seal repairs any
693
+ // earlier checkpoint that failed, which a tail commit could not.
694
+ if (this.sealing) {
695
+ await this.commitSeal();
696
+ break;
697
+ }
698
+ const behind = this.seq - this.durableSeq;
699
+ if (behind > 0 && (this.forced || behind >= this.plane.chars)) {
700
+ // Consume the force BEFORE the round, not after: a `flush()` that lands while this round
701
+ // is already in flight re-sets it and gets a round of its OWN — clearing it afterwards
702
+ // would swallow that request and leave the flush to the interval cadence.
703
+ this.forced = false;
704
+ this.disarm();
705
+ const failure = await this.commitTail();
706
+ if (failure) {
707
+ this.stalled = true;
708
+ // A seal requested WHILE this checkpoint was in flight outranks the retry cadence: the
709
+ // seal writes the whole body anyway, so it both supersedes and repairs the failed slice.
710
+ // Backing off here instead would make `close()` wait out `intervalMs` for nothing. An
711
+ // explicit re-force during the failed round likewise earns one immediate re-attempt —
712
+ // someone is waiting on it — while an unforced failure stalls to the cadence.
713
+ if (!this.sealing && !this.forced) break;
714
+ }
715
+ continue;
716
+ }
717
+ this.forced = false;
718
+ break;
719
+ }
720
+ } finally {
721
+ this.draining = false;
722
+ if (!this.ended) {
723
+ // A pending seal ALWAYS proceeds at once — including out of a stall (see the loop above);
724
+ // otherwise a stall waits out the interval, and deltas that landed mid-commit may already
725
+ // meet the threshold and deserve an immediate round.
726
+ if (this.sealing) this.kick(false);
727
+ else if (this.stalled) this.arm();
728
+ else if (this.seq - this.durableSeq >= this.plane.chars) this.kick(false);
729
+ else this.arm();
730
+ }
731
+ }
732
+ }
733
+
734
+ /** @returns the failure, or `undefined` when the slice committed. */
735
+ private async commitTail(): Promise<{ err: unknown } | undefined> {
736
+ const from = this.durableSeq;
737
+ const to = this.seq;
738
+ const text = this.buf.slice(from - this.bufFrom, to - this.bufFrom);
739
+ let out: { cancelRequested?: boolean; seq?: number } | void;
740
+ try {
741
+ out = await this.plane.commit({ kind: "append", streamId: this.streamId, from, seq: to, text });
742
+ } catch (err) {
743
+ // The store may hold MORE than we believe — an append that committed while its ack was lost,
744
+ // somewhere in the retry chain. The read-back is the truth, and adopting it is what keeps the
745
+ // NEXT slice contiguous instead of overlapping (§3.3).
746
+ const truth = await this.plane.probeDurableSeq(this.streamId);
747
+ if (truth !== undefined && truth > this.durableSeq) this.advanceDurable(truth);
748
+ this.plane.reportCheckpointError(err, { streamId: this.streamId, from, seq: to });
749
+ // Waiters that asked for THIS prefix learn it did not land; later waiters keep waiting for a
750
+ // later attempt. A durability failure is never silent.
751
+ this.rejectWaiters(to, err);
752
+ return { err };
753
+ }
754
+ if (out?.cancelRequested) this.cancelled = true;
755
+ // `durableSeq` advances to what the store CONFIRMED — the mapped path reads it back, a `commit`
756
+ // callback may report it — never to a plane-side assumption. A confirmation short of `to` means
757
+ // this slice's `from` was stale (a prior ack loss the read-back could not see at the time): the
758
+ // guarded statements made it a no-op, and the next round re-covers from the confirmed offset.
759
+ const confirmed = out?.seq ?? to;
760
+ if (confirmed > this.durableSeq) this.advanceDurable(confirmed);
761
+ if (confirmed < to) {
762
+ const err = new Error(
763
+ `stream ${this.streamId}: the store confirmed ${confirmed} of ${to} — re-covering from there`,
764
+ );
765
+ this.plane.reportCheckpointError(err, { streamId: this.streamId, from, seq: to });
766
+ this.rejectWaiters(to, err);
767
+ return { err };
768
+ }
769
+ return undefined;
770
+ }
771
+
772
+ /** The one place durable progress is recorded: position, `durable` frame, buffer trim, waiters. */
773
+ private advanceDurable(seq: number): void {
774
+ this.durableSeq = seq;
775
+ this.fanout({ type: "durable", seq });
776
+ this.trim();
777
+ this.resolveWaiters();
778
+ }
779
+
780
+ private async commitSeal(): Promise<void> {
781
+ const seal = this.sealing!;
782
+ const from = this.durableSeq;
783
+ try {
784
+ await this.plane.commit({
785
+ kind: "close",
786
+ streamId: this.streamId,
787
+ from,
788
+ seq: this.seq,
789
+ // A trimmed buffer (only reachable in `commit`-callback mode, where the app opted into
790
+ // trimming) cannot supply the whole body: `bodyFrom` says where the retained text starts,
791
+ // so the app can still append exactly the outstanding tail (`body.slice(from - bodyFrom)`).
792
+ body: this.buf,
793
+ bodyFrom: this.bufFrom,
794
+ status: seal.status,
795
+ ...(seal.error !== undefined ? { error: seal.error } : {}),
796
+ });
797
+ this.durableSeq = this.seq;
798
+ } catch (err) {
799
+ this.sealError = err;
800
+ this.plane.reportCheckpointError(err, { streamId: this.streamId, from, seq: this.seq });
801
+ }
802
+ this.ended = true;
803
+ this.disarm();
804
+ this.rejectWaiters(
805
+ Number.POSITIVE_INFINITY,
806
+ new Error(`stream ${this.streamId} ended with only ${this.durableSeq} of ${this.seq} characters durable`),
807
+ );
808
+ this.resolveWaiters();
809
+ const frame: StreamFrame = {
810
+ type: "end",
811
+ seq: this.durableSeq,
812
+ status: seal.status,
813
+ ...(seal.error !== undefined ? { error: seal.error } : {}),
814
+ };
815
+ for (const sub of [...this.subs]) sub.finish(frame);
816
+ this.plane.retire(this.streamId);
817
+ if (this.sealError) this.sealed?.reject(this.sealError);
818
+ else this.sealed?.resolve();
819
+ }
820
+
821
+ /** Keep everything at or above `durableSeq`, plus a slack window below it so a client whose IVM
822
+ * view lags one checkpoint can still join without a `stale` round trip (§4). A `retainChars` of
823
+ * `Infinity` (the mapped-table default — compaction needs the whole text) never trims. */
824
+ private trim(): void {
825
+ if (!Number.isFinite(this.plane.retainChars)) return;
826
+ const floor = Math.max(0, this.durableSeq - this.plane.retainChars);
827
+ if (floor <= this.bufFrom) return;
828
+ this.buf = this.buf.slice(floor - this.bufFrom);
829
+ this.bufFrom = floor;
830
+ }
831
+
832
+ private resolveWaiters(): void {
833
+ if (this.waiters.length === 0) return;
834
+ const still: Waiter[] = [];
835
+ for (const w of this.waiters) {
836
+ if (w.at <= this.durableSeq) w.resolve(this.durableSeq);
837
+ else still.push(w);
838
+ }
839
+ this.waiters = still;
840
+ }
841
+
842
+ private rejectWaiters(upTo: number, err: unknown): void {
843
+ if (this.waiters.length === 0) return;
844
+ const still: Waiter[] = [];
845
+ for (const w of this.waiters) {
846
+ if (w.at <= upTo && w.at > this.durableSeq) w.reject(err);
847
+ else still.push(w);
848
+ }
849
+ this.waiters = still;
850
+ }
851
+
852
+ // ---- subscriber side
853
+
854
+ private fanout(frame: StreamFrame): void {
855
+ for (const sub of [...this.subs]) {
856
+ if (!sub.offer(frame)) {
857
+ // Bounded, then dropped: a reader that stopped draining costs itself a rejoin, never the
858
+ // producer's memory.
859
+ sub.finish({ type: "stale", floorSeq: this.bufFrom, durableSeq: this.durableSeq });
860
+ this.subs.delete(sub);
861
+ }
862
+ }
863
+ }
864
+
865
+ /** Attach a reader at `from`. Runs in ONE synchronous block — snapshot, replay, register — so no
866
+ * delta can slip between the replay and the live tail (the gap-free half of contract S). */
867
+ subscribe(from: number): StreamSubscription {
868
+ const sub = new Subscriber(this.plane.maxQueuedFrames, (s) => this.subs.delete(s));
869
+ // Floored as well as clamped: a fractional `from` (a hand-built subscribe input) would otherwise
870
+ // produce a replay chunk whose `text.length !== seq - from`, breaking the frame invariant.
871
+ const at = Math.min(Math.max(Math.floor(from), 0), this.seq);
872
+ if (at < this.bufFrom) {
873
+ sub.finish({ type: "stale", floorSeq: this.bufFrom, durableSeq: this.durableSeq });
874
+ return this.wrap(sub);
875
+ }
876
+ sub.offer({ type: "open", streamId: this.streamId, from: at, seq: this.seq, durableSeq: this.durableSeq, ended: this.ended });
877
+ if (this.seq > at) {
878
+ sub.offer({ type: "chunk", from: at, seq: this.seq, text: this.buf.slice(at - this.bufFrom) });
879
+ }
880
+ if (this.durableSeq > at) sub.offer({ type: "durable", seq: this.durableSeq });
881
+ if (this.ended) {
882
+ const seal = this.sealing ?? { status: "interrupted" as StreamStatus };
883
+ sub.finish({
884
+ type: "end",
885
+ seq: this.durableSeq,
886
+ status: seal.status,
887
+ ...(seal.error !== undefined ? { error: seal.error } : {}),
888
+ });
889
+ return this.wrap(sub);
890
+ }
891
+ this.subs.add(sub);
892
+ return this.wrap(sub);
893
+ }
894
+
895
+ private wrap(sub: Subscriber): StreamSubscription {
896
+ return { streamId: this.streamId, frames: sub.frames(), close: () => sub.close() };
897
+ }
898
+
899
+ /** Drop every reader without sealing (process teardown — `drainStreams` seals first). */
900
+ detachAll(): void {
901
+ for (const sub of [...this.subs]) sub.close();
902
+ this.subs.clear();
903
+ this.disarm();
904
+ }
905
+ }
906
+
907
+ // ------------------------------------------------------------------------------- the plane
908
+
909
+ /** What the plane needs from the api-server to write a checkpoint: the backend's OUTSIDE-transaction
910
+ * SQL surface (`batch` is one transaction on every backend) and its dialect. Deliberately narrow so
911
+ * `streams.ts` never imports the server (no cycle). */
912
+ export interface StreamSqlSink {
913
+ readonly dialect: SqlDialect;
914
+ readonly sql: ServerSql;
915
+ }
916
+
917
+ export class StreamPlane<User> {
918
+ readonly chars: number;
919
+ readonly intervalMs: number;
920
+ readonly retries: number;
921
+ readonly retainChars: number;
922
+ readonly lingerMs: number;
923
+ readonly maxQueuedFrames: number;
924
+
925
+ private readonly live = new Map<string, LiveStream<User>>();
926
+ private readonly opts: RindleStreamOptions<User>;
927
+ private readonly sink: StreamSqlSink | undefined;
928
+ private readonly mapped: MappedTableSql | undefined;
929
+ private readonly tables: StreamTables | undefined;
930
+ /** The open CAS token (§5.1): `hostId`, or a random per-plane stand-in when only the CAS — not
931
+ * routing — needs it. */
932
+ private readonly openToken: string;
933
+
934
+ constructor(opts: RindleStreamOptions<User>, sink?: StreamSqlSink) {
935
+ this.opts = opts;
936
+ this.sink = sink;
937
+ this.chars = opts.policy?.chars ?? DEFAULT_CHECKPOINT_CHARS;
938
+ this.intervalMs = opts.policy?.intervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS;
939
+ this.retries = opts.policy?.retries ?? DEFAULT_CHECKPOINT_RETRIES;
940
+ this.lingerMs = opts.lingerMs ?? DEFAULT_LINGER_MS;
941
+ this.maxQueuedFrames = opts.maxQueuedFrames ?? DEFAULT_MAX_QUEUED_FRAMES;
942
+ this.openToken = opts.hostId ?? randomOpenToken();
943
+ if ("tables" in opts.checkpoint) {
944
+ if (!sink) throw new TypeError("streams.checkpoint.tables needs a SQL-capable mutation backend");
945
+ this.tables = opts.checkpoint.tables;
946
+ this.mapped = new MappedTableSql(opts.checkpoint.tables, sink.dialect);
947
+ // Options this mode cannot honour are refused loudly (the room-profile rule), never ignored.
948
+ if (opts.retainChars !== undefined) {
949
+ throw new TypeError(
950
+ "streams.retainChars is `commit`-mode only — mapped tables retain the whole text for compaction (§3.4)",
951
+ );
952
+ }
953
+ if (opts.hostId !== undefined && resolveStreamColumns(opts.checkpoint.tables.columns).host === undefined) {
954
+ throw new TypeError("streams.hostId does nothing in tables mode until columns.host names where to persist it");
955
+ }
956
+ // Compaction writes the WHOLE body at close, so the buffer is retained in full. `retainChars`
957
+ // is honoured only where the app owns persistence and may not need the tail.
958
+ this.retainChars = Number.POSITIVE_INFINITY;
959
+ } else {
960
+ this.retainChars = opts.retainChars ?? DEFAULT_RETAIN_CHARS;
961
+ }
962
+ }
963
+
964
+ /** Open a stream on an EXISTING message row (§5): the app's own mutator wrote it, alongside the
965
+ * user's prompt, so the pointer is already durable and every client's query already shows the
966
+ * message. This verifies it and flips it to `streaming`. */
967
+ async open(input: OpenStreamInput<User>): Promise<StreamHandle> {
968
+ if (this.live.has(input.streamId)) {
969
+ throw new Error(`stream ${input.streamId} is already open on this host`);
970
+ }
971
+ const stream = new LiveStream<User>(input.streamId, input.user, input.meta, this);
972
+ this.live.set(input.streamId, stream);
973
+ try {
974
+ if (this.mapped) await this.assertOpenable(input.streamId);
975
+ await this.commit({
976
+ kind: "open",
977
+ streamId: input.streamId,
978
+ meta: input.meta ?? null,
979
+ ...(this.opts.hostId !== undefined ? { hostId: this.opts.hostId } : {}),
980
+ startedAt: Date.now(),
981
+ });
982
+ } catch (err) {
983
+ this.live.delete(input.streamId);
984
+ throw err;
985
+ }
986
+ return {
987
+ streamId: stream.streamId,
988
+ get seq() {
989
+ return stream.seq;
990
+ },
991
+ get durableSeq() {
992
+ return stream.durableSeq;
993
+ },
994
+ get cancelled() {
995
+ return stream.cancelled;
996
+ },
997
+ push: (text) => stream.push(text),
998
+ flush: () => stream.flush(),
999
+ pump: async (deltas) => {
1000
+ // `for await` closes the iterator on `break`, which is what aborts the SDK's underlying
1001
+ // request — the whole point of honouring cancellation here rather than in the caller.
1002
+ for await (const d of deltas) {
1003
+ stream.push(d);
1004
+ if (stream.cancelled) break;
1005
+ }
1006
+ },
1007
+ close: () => stream.seal(stream.cancelled ? "cancelled" : "complete"),
1008
+ fail: (error) => stream.seal("error", errText(error)),
1009
+ };
1010
+ }
1011
+
1012
+ /**
1013
+ * The read-only precondition on the app's message row, checked ONCE per `open` (§5.1). Read-only on
1014
+ * purpose: it is a decision about the app's data, so re-deciding it per write attempt would let a
1015
+ * lost ack turn a success into a refusal.
1016
+ *
1017
+ * The `streaming` check is the **single-flight guard**, and it lives at the ROW rather than in this
1018
+ * process's map because the thing it defends against is distributed: the kick that starts a
1019
+ * generation is an at-least-once effect (a retried mutation envelope re-runs its post-commit code,
1020
+ * §10.5), so a second kick can land on another instance, where the in-memory map is empty. Two
1021
+ * producers on one `streamId` would interleave: both CAS the same length, one stalls, and whichever
1022
+ * closes last overwrites the body with ITS buffer. Cheap to refuse; expensive to debug.
1023
+ *
1024
+ * This probe alone is check-then-act — it and the open write are separate round trips, so two
1025
+ * SIMULTANEOUS kicks can both pass it. Mapping a `host` column closes that window: the open write
1026
+ * turns conditional and {@link verifyOpenWinner}'s read-back names the winner.
1027
+ */
1028
+ private async assertOpenable(streamId: string): Promise<void> {
1029
+ const cols = resolveStreamColumns(this.tables!.columns);
1030
+ const rows = await this.sink!.sql.query<{ seq: number | null; status: string | null }>(
1031
+ this.mapped!.probeOpen(),
1032
+ [streamId],
1033
+ );
1034
+ const row = rows[0];
1035
+ if (!row) {
1036
+ throw new StreamOpenRefused(
1037
+ `stream ${streamId}: no row in "${this.tables!.message}" — the app's own mutator must write the message ` +
1038
+ `(with its chatId/role/…) BEFORE opening the stream on it`,
1039
+ );
1040
+ }
1041
+ // A NULL length is refused as loudly as an advanced one, and for a subtler reason: it is the CAS
1042
+ // column, and `WHERE seq = 0` can never match NULL. A nullable column would let every chunk
1043
+ // insert land while its length CAS silently matched nothing — the one failure mode this plane
1044
+ // cannot detect (`batch` reports no row count) and one compaction would paper over (§5.1).
1045
+ if (typeof row.seq !== "number") {
1046
+ throw new StreamOpenRefused(
1047
+ `stream ${streamId}: "${this.tables!.message}"."${cols.seq}" is ${row.seq === null ? "NULL" : typeof row.seq} — ` +
1048
+ `the length column must be NOT NULL DEFAULT 0 (it is compare-and-swapped on every checkpoint, and no ` +
1049
+ `comparison matches NULL)`,
1050
+ );
1051
+ }
1052
+ if (row.seq !== 0) {
1053
+ throw new StreamOpenRefused(
1054
+ `stream ${streamId}: the message row already holds ${row.seq} characters — a regeneration is a NEW message ` +
1055
+ `id, never a resume of an advanced row`,
1056
+ );
1057
+ }
1058
+ if (row.status === STREAM_STATUS_STREAMING) {
1059
+ throw new StreamOpenRefused(
1060
+ `stream ${streamId}: "${cols.status}" is already "${STREAM_STATUS_STREAMING}" — another producer holds this ` +
1061
+ `stream. Two producers on one stream interleave their checkpoints; if the first one's host died, the ` +
1062
+ `sweeper marking it "interrupted" is what releases the row (§7)`,
1063
+ );
1064
+ }
1065
+ }
1066
+
1067
+ async subscribe(input: SubscribeStreamInput<User>): Promise<StreamSubscription> {
1068
+ const stream = this.live.get(input.streamId);
1069
+ const from = input.from ?? 0;
1070
+ // Authorize BEFORE existence is consulted: a denial must not double as an existence oracle.
1071
+ const verdict = await this.opts.authorize({
1072
+ user: input.user,
1073
+ streamId: input.streamId,
1074
+ from,
1075
+ meta: stream === undefined ? undefined : stream.meta,
1076
+ request: input.request,
1077
+ });
1078
+ if (verdict === false) throw new StreamForbidden(input.streamId);
1079
+ if (!stream) return oneFrame(input.streamId, { type: "absent" });
1080
+ return stream.subscribe(from);
1081
+ }
1082
+
1083
+ /** Seal every live stream `interrupted`. In mapped-table mode the seal IS the compaction, so a
1084
+ * graceful drain loses nothing PRODUCED; the status still says the response was cut short rather
1085
+ * than claiming completion (§5). Wire it to SIGTERM. */
1086
+ async drainStreams(): Promise<void> {
1087
+ await Promise.allSettled([...this.live.values()].map((s) => s.seal("interrupted")));
1088
+ }
1089
+
1090
+ /** Teardown: drop readers and timers WITHOUT a durable write (that is `drainStreams`). */
1091
+ closeSync(): void {
1092
+ for (const s of this.live.values()) s.detachAll();
1093
+ this.live.clear();
1094
+ }
1095
+
1096
+ /** A sealed stream stays joinable for the linger window, so a subscribe that races the last token
1097
+ * still gets `end` (and the tail it missed) rather than a bare `absent`. */
1098
+ retire(streamId: string): void {
1099
+ timer(() => {
1100
+ const entry = this.live.get(streamId);
1101
+ if (entry?.ended) {
1102
+ this.live.delete(streamId);
1103
+ entry.detachAll();
1104
+ }
1105
+ }, this.lingerMs);
1106
+ }
1107
+
1108
+ reportCheckpointError(err: unknown, info: { streamId: string; from: number; seq: number }): void {
1109
+ // Reporting runs INSIDE the drain loop, so a throwing hook would take the loop down with it and
1110
+ // wedge the stream — the one failure mode a diagnostic must never cause.
1111
+ try {
1112
+ if (this.opts.onCheckpointError) this.opts.onCheckpointError(err, info);
1113
+ else console.error(`[rindle api-server] stream ${info.streamId}: checkpoint ${info.from}→${info.seq} failed:`, err);
1114
+ } catch (hookErr) {
1115
+ console.error(`[rindle api-server] stream ${info.streamId}: onCheckpointError itself threw:`, hookErr);
1116
+ }
1117
+ }
1118
+
1119
+ /** Drive ONE checkpoint, retrying on failure. Every statement it emits is idempotent under replay
1120
+ * — the chunk insert dedups on its deterministic id, the length CAS refuses to apply twice, the
1121
+ * compaction is a whole-row overwrite — so "retry until it sticks" needs no dedup ledger and no
1122
+ * `lmid` (§3.3). */
1123
+ async commit(input: StreamCommitInput): Promise<{ cancelRequested?: boolean; seq?: number } | void> {
1124
+ let lastErr: unknown;
1125
+ for (let attempt = 0; attempt <= this.retries; attempt++) {
1126
+ if (attempt > 0) await delay(RETRY_BACKOFF_MS * 2 ** (attempt - 1));
1127
+ try {
1128
+ return await this.commitOnce(input);
1129
+ } catch (err) {
1130
+ if (err instanceof StreamOpenRefused) throw err; // a settled verdict, not a blip
1131
+ lastErr = err;
1132
+ }
1133
+ }
1134
+ throw lastErr;
1135
+ }
1136
+
1137
+ private async commitOnce(input: StreamCommitInput): Promise<{ cancelRequested?: boolean; seq?: number } | void> {
1138
+ const target = this.opts.checkpoint;
1139
+ if ("commit" in target) return (await target.commit(input)) ?? undefined;
1140
+ const sql = this.sink!.sql;
1141
+ const mapped = this.mapped!;
1142
+ switch (input.kind) {
1143
+ case "open": {
1144
+ // The PRECONDITION is checked once, in `assertOpenable`, before this write — never here.
1145
+ // Re-reading it per attempt would make a lost ack on the write below refuse its own success
1146
+ // (the row would already say `streaming`).
1147
+ await sql.batch([mapped.markStreaming(input.streamId, this.openToken)]);
1148
+ await this.verifyOpenWinner(input.streamId);
1149
+ return undefined;
1150
+ }
1151
+ case "append": {
1152
+ try {
1153
+ await sql.batch(mapped.append(input.streamId, input.from, input.seq, input.text));
1154
+ } catch (err) {
1155
+ // The batch may have COMMITTED with its ack lost. The read-back is the truth: a row at
1156
+ // (or past) this slice's end means it landed, and the throw was only the reply.
1157
+ const truth = await this.readAppendState(input.streamId).catch(() => undefined);
1158
+ if (truth === undefined || truth.seq < input.seq) throw err;
1159
+ return truth;
1160
+ }
1161
+ // The read-back CONFIRMS the append (the guarded statements report no row count). If it
1162
+ // throws, the retry is safe: a replay of an applied slice no-ops on the guard and confirms
1163
+ // on its own read-back.
1164
+ return await this.readAppendState(input.streamId);
1165
+ }
1166
+ case "close": {
1167
+ await sql.batch(mapped.compact(input.streamId, input.body, input.status, input.error));
1168
+ return undefined;
1169
+ }
1170
+ }
1171
+ }
1172
+
1173
+ /** The read-back half of the open CAS, run only when a `host` column is mapped. The probe in
1174
+ * `assertOpenable` and the open write are separate round trips, so bare check-then-act leaves a
1175
+ * window where two simultaneous kicks both pass the probe; the conditional `markStreaming`
1176
+ * matches nothing when a rival got there first, and whichever token the row now holds names the
1177
+ * winner — a true CAS with no row counts needed. A replayed open (lost ack) reads back its OWN
1178
+ * token and proceeds. */
1179
+ private async verifyOpenWinner(streamId: string): Promise<void> {
1180
+ const probe = this.mapped!.probeHost();
1181
+ if (probe === undefined) return;
1182
+ const rows = await this.sink!.sql.query<{ host: unknown }>(probe, [streamId]);
1183
+ if (rows[0]?.host !== this.openToken) {
1184
+ throw new StreamOpenRefused(
1185
+ `stream ${streamId}: another producer won the open race (the row's host is ` +
1186
+ `${JSON.stringify(rows[0]?.host ?? null)}) — two producers on one stream interleave their checkpoints, ` +
1187
+ `so the loser stands down`,
1188
+ );
1189
+ }
1190
+ }
1191
+
1192
+ /** The post-append read-back (§3.2): the row's authoritative length — which both CONFIRMS an
1193
+ * append (the guarded batch reports no row count) and absorbs a committed-but-ack-lost one —
1194
+ * plus the reader's cancel flag when mapped (§6), riding the same indexed point read. This read
1195
+ * is LOAD-BEARING: a failure here fails the append attempt (retried, then stalled), because
1196
+ * claiming durability the store did not confirm is the one dishonesty this plane refuses. */
1197
+ private async readAppendState(streamId: string): Promise<{ seq: number; cancelRequested?: boolean }> {
1198
+ const rows = await this.sink!.sql.query<{ seq: unknown; cancel?: unknown }>(this.mapped!.probeState(), [
1199
+ streamId,
1200
+ ]);
1201
+ const row = rows[0];
1202
+ if (row === undefined || typeof row.seq !== "number") {
1203
+ throw new Error(`stream ${streamId}: the read-back found no usable message row`);
1204
+ }
1205
+ return { seq: row.seq, ...(this.mapped!.hasCancel ? { cancelRequested: Boolean(row.cancel) } : {}) };
1206
+ }
1207
+
1208
+ /** The store's word on how much is durable — for resynchronizing after a FAILED append, where a
1209
+ * lost ack may have left the store ahead of the plane. `undefined` in `commit` mode (no readable
1210
+ * authority) or when the read itself fails (the next attempt retries the resync too). */
1211
+ async probeDurableSeq(streamId: string): Promise<number | undefined> {
1212
+ if (!this.mapped) return undefined;
1213
+ try {
1214
+ return (await this.readAppendState(streamId)).seq;
1215
+ } catch {
1216
+ return undefined;
1217
+ }
1218
+ }
1219
+ }
1220
+
1221
+ /** The open probe's verdict: the message row is missing or already advanced. Not retried — it is a
1222
+ * settled statement about the app's data, and retrying re-asks a question already answered. */
1223
+ export class StreamOpenRefused extends Error {
1224
+ constructor(message: string) {
1225
+ super(message);
1226
+ this.name = "StreamOpenRefused";
1227
+ }
1228
+ }
1229
+
1230
+ /** Refused by {@link RindleStreamOptions.authorize}. Distinct from the api-server's own
1231
+ * `RindleApiError` so this module stays importable without the server (no cycle); the server
1232
+ * translates it to a 403 at the handler seam. */
1233
+ export class StreamForbidden extends Error {
1234
+ readonly streamId: string;
1235
+
1236
+ constructor(streamId: string) {
1237
+ super(`stream ${streamId}: forbidden`);
1238
+ this.name = "StreamForbidden";
1239
+ this.streamId = streamId;
1240
+ }
1241
+ }
1242
+
1243
+ function oneFrame(streamId: string, frame: StreamFrame): StreamSubscription {
1244
+ let taken = false;
1245
+ return {
1246
+ streamId,
1247
+ frames: {
1248
+ [Symbol.asyncIterator](): AsyncIterator<StreamFrame> {
1249
+ return {
1250
+ next(): Promise<IteratorResult<StreamFrame>> {
1251
+ if (taken) return Promise.resolve({ value: undefined as never, done: true });
1252
+ taken = true;
1253
+ return Promise.resolve({ value: frame, done: false });
1254
+ },
1255
+ };
1256
+ },
1257
+ },
1258
+ close: () => {
1259
+ taken = true;
1260
+ },
1261
+ };
1262
+ }
1263
+
1264
+ // ------------------------------------------------------------------------------- SSE transport
1265
+
1266
+ /** Headers for the SSE response. `x-accel-buffering` is the nginx-family opt-out — without it a
1267
+ * buffering proxy holds the tokens and hands the user a paragraph at a time. */
1268
+ export const STREAM_SSE_HEADERS: Record<string, string> = {
1269
+ "content-type": "text/event-stream; charset=utf-8",
1270
+ "cache-control": "no-cache, no-transform",
1271
+ connection: "keep-alive",
1272
+ "x-accel-buffering": "no",
1273
+ };
1274
+
1275
+ /** Pull a subscribe request out of a fetch-style GET: `?streamId=…&from=…`, with `Last-Event-ID`
1276
+ * winning over an explicit `from` (a reconnecting `EventSource` knows better than its own URL —
1277
+ * the URL is the ORIGINAL join point, the header is where it actually got to). */
1278
+ export function streamRequestFromHttp(req: {
1279
+ url: string;
1280
+ headers: { get(name: string): string | null };
1281
+ }): { streamId: string; from: number } {
1282
+ const url = new URL(req.url);
1283
+ const streamId = url.searchParams.get("streamId") ?? "";
1284
+ const lastEventId = req.headers.get("last-event-id");
1285
+ const raw = lastEventId ?? url.searchParams.get("from") ?? "0";
1286
+ const from = Number.parseInt(raw, 10);
1287
+ return { streamId, from: Number.isFinite(from) && from > 0 ? from : 0 };
1288
+ }
1289
+
1290
+ /**
1291
+ * Encode a subscription as an SSE body. Each positional frame carries `id: <seq>`, so a browser
1292
+ * `EventSource` that drops the connection resumes at exactly the right offset with no application
1293
+ * code — its own `Last-Event-ID` header is the `from` of the next subscribe ({@link
1294
+ * streamRequestFromHttp}).
1295
+ *
1296
+ * The reader must close the `EventSource` on the `end` frame: `EventSource` reconnects on ANY close,
1297
+ * including a clean one.
1298
+ */
1299
+ export function streamFramesToSse(
1300
+ sub: StreamSubscription,
1301
+ opts?: { keepAliveMs?: number },
1302
+ ): ReadableStream<Uint8Array> {
1303
+ const encoder = new TextEncoder();
1304
+ const keepAliveMs = opts?.keepAliveMs ?? 15_000;
1305
+ let ping: ReturnType<typeof setTimeout> | null = null;
1306
+ let cancelled = false;
1307
+ let iter: AsyncIterator<StreamFrame>;
1308
+ // PULL-based on purpose: a frame is taken from the subscription only when the consumer has
1309
+ // demand, so a slow HTTP client backs frames up in the SUBSCRIBER's bounded queue — where
1310
+ // `maxQueuedFrames` drops it with `stale` (§4) — instead of unboundedly in this stream's own.
1311
+ return new ReadableStream<Uint8Array>({
1312
+ start(controller) {
1313
+ iter = sub.frames[Symbol.asyncIterator]();
1314
+ const beat = (): void => {
1315
+ if (cancelled) return;
1316
+ // A comment line: keeps intermediaries from reaping an idle connection, costs 8 bytes.
1317
+ controller.enqueue(encoder.encode(": ping\n\n"));
1318
+ ping = timer(beat, keepAliveMs);
1319
+ };
1320
+ ping = timer(beat, keepAliveMs);
1321
+ },
1322
+ async pull(controller) {
1323
+ const next = await iter.next();
1324
+ if (cancelled) return;
1325
+ if (next.done) {
1326
+ if (ping) clearTimeout(ping);
1327
+ sub.close();
1328
+ controller.close();
1329
+ return;
1330
+ }
1331
+ const id = frameResumePoint(next.value);
1332
+ const head = id === undefined ? "" : `id: ${id}\n`;
1333
+ controller.enqueue(encoder.encode(`${head}data: ${JSON.stringify(next.value)}\n\n`));
1334
+ },
1335
+ cancel() {
1336
+ cancelled = true;
1337
+ if (ping) clearTimeout(ping);
1338
+ sub.close();
1339
+ },
1340
+ });
1341
+ }