API index and search · Build metadata
Source snapshot
packages/optimistic/src/system-streams.ts
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