Rindle

API index and search · Build metadata

Source snapshot

packages/client/src/stream.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
1// The LM stream plane's SHARED contract (designs-implemented/LM-STREAM-CHECKPOINT-DESIGN.md): the frame shapes2// both ends speak, plus the two pure functions that reassemble a response from its two planes.3//4// This lives in `@rindle/client` rather than `@rindle/api-server` because BOTH tiers need it and the5// browser must never pull the server package (sql-client, daemon-client, …) to read a stream.6// `@rindle/api-server` re-exports every name here, so the server-side import path is unchanged.7//8// Nothing in this module has state, I/O, or a React dependency — the hook that drives a subscription9// is `useStreamedText` in `@rindle/react`; the transport is the app's choice.1011/**12 * How a stream ended.13 * - `complete` — the model finished.14 * - `cancelled` — the reader asked it to stop and the producer honoured it.15 * - `error` — the generation threw.16 * - `interrupted` — the host went away mid-generation. The one status that implies the store may be17 *   short of what was produced.18 */19export type StreamStatus = "complete" | "cancelled" | "error" | "interrupted";2021/** The value in the mapped status column while a stream is live. */22export const STREAM_STATUS_STREAMING = "streaming";2324/**25 * One frame of a subscription. A subscription always begins with `open` and always ends with exactly26 * one terminal frame — `end`, `stale`, or `absent` — after which the iterator completes.27 *28 * `stale` and `absent` are the two "you are on the durable plane now" answers, and both are SAFE:29 * the store holds everything below `floorSeq` and everything through `durableSeq`, so the reader's30 * IVM view converges without the stream. Neither is an error.31 */32export type StreamFrame =33  /** Join accepted. `from` is the (clamped) offset the replay starts at. */34  | { type: "open"; streamId: string; from: number; seq: number; durableSeq: number; ended: boolean }35  /** PRODUCED text — not a durability claim. `text.length === seq - from`, always. */36  | { type: "chunk"; from: number; seq: number; text: string }37  /** The store now holds the prefix through `seq`. */38  | { type: "durable"; seq: number }39  /** Sealed. No further frames. */40  | { type: "end"; seq: number; status: StreamStatus; error?: string }41  /** `from` is below the producer's retained buffer floor (or the subscriber fell too far behind):42   *  read the store. (A raw `EventSource` rejoins automatically on its reconnect; `useStreamedText`43   *  deliberately stays on the durable plane instead — correct, at checkpoint granularity.) */44  | { type: "stale"; floorSeq: number; durableSeq: number }45  /** The process serving this subscribe is not hosting the stream (wrong instance, already evicted,46   *  or it never existed): the store is the whole truth. */47  | { type: "absent" };4849/** The resume point a frame implies — what rides an SSE `id:` line so a reconnecting `EventSource`50 *  hands it straight back as `Last-Event-ID`. `undefined` for frames that are not a position. */51export function frameResumePoint(frame: StreamFrame): number | undefined {52  switch (frame.type) {53    case "open":54      return frame.from;55    case "chunk":56    case "durable":57    case "end":58      return frame.seq;59    default:60      return undefined;61  }62}6364/**65 * Merge the durable plane with the live tail.66 *67 * `durable` is what the IVM view shows; `produced` is what a subscription has accumulated (the prefix68 * it joined at, plus every `chunk`). Both are prefixes of the same response, so the merge is "take69 * the longer" — no diffing, no overlap handling, no ranges.70 *71 * The length comparison is the whole algorithm, which is why a caller MUST seed its accumulator with72 * the text it joined at: a tail carrying only the chunks it received would read as shorter than the73 * durable text and be discarded. `useStreamedText` does that for you.74 */75export function spliceStreamText(durable: string, produced: string): string {76  return produced.length > durable.length ? durable + produced.slice(durable.length) : durable;77}7879/**80 * The durable half of the splice for the mapped-table layout: the compacted `body` followed by81 * whatever chunk rows have not been folded into it yet.82 *83 * Chunks are ALWAYS the suffix after `body` — the closing checkpoint rewrites `body` and drops the84 * chunks it absorbed in ONE transaction — so a reader never observes a torn state where a chunk both85 * is and is not in the body.86 */87export function assembleDurableText(88  message: { body?: string | null } | null | undefined,89  chunks: ReadonlyArray<{ seq: number; text: string }> = [],90): string {91  const ordered = [...chunks].sort((a, b) => a.seq - b.seq);92  return (message?.body ?? "") + ordered.map((c) => c.text).join("");93}94