Rindle

API index and search · Build metadata

Source snapshot

packages/optimistic/src/system-streams.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 §4 lifecycle SYSTEM-STREAM plane, client half (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md2// Slice I-iii): the four daemon-registered system tables the api-server mints subscriptions over3// when its realtime `lifecycle` config is present — the doorbell (`_rindle_scope_sessions`, §4.1)4// on every labeled lease, and the fence bundle (`_rindle_room_watermark` §4.2 +5// `_rindle_room_client_mutations` §7.1 + `_rindle_room_mutation_outcomes` §3.3) on every6// room-served lease.7//8// This module holds the leaf vocabulary the backend and the client share:9//   - the table NAMES + wire schemas (the daemon DDL is the source of truth —10//     `rust/rindle-replica/src/mutations.rs` `realtime_lifecycle_ddl()` / the room-ledger DDL in11//     `enable_client_mutations` — mirrored here exactly like `CLIENT_MUTATIONS_SCHEMA` mirrors the12//     lmid table, so the system subscriptions' hellos pass CRIT#4 validation);13//   - the retain SPEC (`SystemStreamSpec`) a system subscription carries so the backend knows,14//     at fold time, which table a system qid serves and which scope/doc it was minted for;15//   - the reserved client-side query NAME system retains subscribe under. Like16//     `LMID_QUERY_NAME`, it is part of the client wire bookkeeping, NOT an app query: the17//     api-server never resolves it by name — the subscribe presents the minted lease's18//     `leaseToken`, and a RE-resolution re-leases the PARENT labeled query and picks the matching19//     entry out of the fresh `lifecycle` block (client.ts).20//21// The schemas live HERE (not `@rindle/client`) deliberately: the system plane is an22// optimistic-store concern — no other backend composition consumes these tables.2324import type { NormalizedTableSchema, WireValue } from "@rindle/client";2526/** §4.1 occupancy: `(scope, client_id, expires_at)`, PK `(scope, client_id)`. The row delta IS27 *  the upgrade doorbell (Slice I-iv reacts; I-iii only folds the map). */28export const SCOPE_SESSIONS_TABLE = "_rindle_scope_sessions";29/** §4.2 downgrade fence: `(doc, flush_seq)`, PK `doc` — monotone, co-committed per room flush. */30export const ROOM_WATERMARK_TABLE = "_rindle_room_watermark";31/** §7.1 domain-scoped room ledger: `(doc, client_id, last_mutation_id)`, PK `(doc, client_id)` —32 *  the FIRST daemon-carried room-lmid path (the named invariant's enforcement point). */33export const ROOM_CLIENT_MUTATIONS_TABLE = "_rindle_room_client_mutations";34/** §3.3 durable outcome rows: `(doc, client_id, mid, kind, reason, name, args)`, PK35 *  `(doc, client_id, mid)` — the H-iv-b `mutationOutcome` frame's durable twin (Slice I-ii36 *  co-commits one per NON-applied mid; an absent row under a covering lmid means `applied`). */37export const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";3839/** The four wire schemas, mirroring the daemon DDL column-for-column (order and PK by name are40 *  what `validateAgainstClientSchema` checks at each system hello — CRIT#4). Appended to the41 *  backend's expected-schema base unconditionally: extra CLIENT-side entries are inert (validation42 *  only checks tables a server actually advertises), so a client that never receives a lifecycle43 *  block behaves byte-identically. */44export const SCOPE_SESSIONS_SCHEMA: NormalizedTableSchema = {45  name: SCOPE_SESSIONS_TABLE,46  columns: ["scope", "client_id", "expires_at"],47  primaryKey: [0, 1],48};49export const ROOM_WATERMARK_SCHEMA: NormalizedTableSchema = {50  name: ROOM_WATERMARK_TABLE,51  columns: ["doc", "flush_seq"],52  primaryKey: [0],53};54export const ROOM_CLIENT_MUTATIONS_SCHEMA: NormalizedTableSchema = {55  name: ROOM_CLIENT_MUTATIONS_TABLE,56  columns: ["doc", "client_id", "last_mutation_id"],57  primaryKey: [0, 1],58};59export const ROOM_MUTATION_OUTCOMES_SCHEMA: NormalizedTableSchema = {60  name: ROOM_MUTATION_OUTCOMES_TABLE,61  columns: ["doc", "client_id", "mid", "kind", "reason", "name", "args"],62  primaryKey: [0, 1, 2],63};6465export const LIFECYCLE_TABLE_SCHEMAS: readonly NormalizedTableSchema[] = [66  SCOPE_SESSIONS_SCHEMA,67  ROOM_WATERMARK_SCHEMA,68  ROOM_CLIENT_MUTATIONS_SCHEMA,69  ROOM_MUTATION_OUTCOMES_SCHEMA,70];7172/** The tables a system retain may serve — the fold category discriminant. */73export type SystemStreamTable =74  | typeof SCOPE_SESSIONS_TABLE75  | typeof ROOM_WATERMARK_TABLE76  | typeof ROOM_CLIENT_MUTATIONS_TABLE77  | typeof ROOM_MUTATION_OUTCOMES_TABLE;7879/** What a system retain declares about itself (`OptimisticBackend.retainSystemQuery`): which80 *  system table its frames carry, and the scope/doc its minted predicate was scoped to. The fold81 *  filters rows against this spec (AND against the client's own `clientID`) as DEFENSE IN DEPTH —82 *  the server predicate is the tight path, but a server that couldn't scope (no `clientId` on the83 *  lease request) legitimately delivers other clients' rows, and a confused server must never84 *  invent verdicts for us. */85export interface SystemStreamSpec {86  table: SystemStreamTable;87  /** The §4.1 occupancy scope the doorbell was minted for (`_rindle_scope_sessions` only). */88  scope?: string;89  /** The room doc the fence entry was minted for (the three room tables). */90  doc?: string;91}9293/** The reserved name system retains subscribe under (never an app query — see the module doc).94 *  Its `args` carry the {@link SystemStreamSpec} identity fields PLUS the parent labeled query's95 *  `(name, args)`, so a RE-resolution (reconnect / gate overflow) can re-lease the parent and pick96 *  the matching lifecycle entry — renewal-as-reauthorization, the room-token precedent. */97export const LIFECYCLE_QUERY_NAME = "_rindle/lifecycle";9899/** The domain/gate key a room doc's daemon-carried confirms fold into — the SAME key the lease100 *  wire mints for the room source (`api-server: sourceKey = "room:" + doc`), so a daemon-carried101 *  lmid and a live room socket's lmid stream land on ONE watermark entry. */102export function roomDomainKey(doc: string): string {103  return `room:${doc}`;104}105106/** One outcome row, decoded (positional per {@link ROOM_MUTATION_OUTCOMES_SCHEMA}). */107export interface OutcomeRow {108  doc: string;109  clientId: string;110  mid: number;111  kind: "deopt" | "rejected";112  reason?: string;113  name?: string;114  args?: unknown;115}116117/** Decode one `_rindle_room_mutation_outcomes` wire row; `undefined` for a malformed one (fail118 *  CLOSED into "not a verdict" — a garbled row must not invent a deopt/rejection; the absent-row119 *  default of the covering lmid then treats the mid as applied, which is I-ii's sound default). */120export function decodeOutcomeRow(row: readonly WireValue[]): OutcomeRow | undefined {121  const [doc, clientId, mid, kind, reason, name, args] = row;122  if (typeof doc !== "string" || typeof clientId !== "string") return undefined;123  const midNum = Number(mid);124  if (!Number.isFinite(midNum)) return undefined;125  if (kind !== "deopt" && kind !== "rejected") return undefined;126  const out: OutcomeRow = { doc, clientId, mid: midNum, kind };127  if (typeof reason === "string") out.reason = reason;128  if (typeof name === "string") out.name = name;129  if (typeof args === "string") {130    // The row echoes `args` as JSON TEXT (I-ii). A frame synthesized without parseable args still131    // resolves a PENDING entry (the flip re-uses the entry's own args); only the already-retired132    // re-invoke arm needs them — it drops a frame that is not self-contained, the H-v contract.133    try {134      out.args = JSON.parse(args);135    } catch {136      /* not self-contained — see above */137    }138  }139  return out;140}141