Rindle

API index and search · Build metadata

Source snapshot

packages/client/src/types.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// Wire + protocol types for the flat-change client core (WASM-CLIENT-DESIGN.md §2, §6).2//3// Cells cross the boundary BARE (the host has a typed schema, so per-column types are4// known here, not per-cell). JSON columns cross as their raw JSON string.56import type { Ast } from "./ast.ts";78/** A bare wire cell. (JSON columns arrive as their raw JSON string.) */9export type WireValue = number | string | boolean | null;1011/** Declared column type (from the typed schema) — drives the comparator + JSON parsing.12 *  `"int64"` is the exact-i64 column plane (design 226): the vocabulary exists from13 *  Stage C4 (codegen can name it), but no exact integer cell crosses this wire — and14 *  `WireValue` gains no `bigint` — until Stage E lands the browser bigint plane; until15 *  then the daemon refuses IVM queries whose footprint touches such a column. */16export type ColType = "string" | "number" | "boolean" | "json" | "int64";1718/** A scalar-projection annotation on a relationship slot (REDUCE-DESIGN.md §9): a19 *  relationship aggregate (`issue { commentCount: count(comments) }`) lives as a singular20 *  one-row relationship whose child is the aggregate row; this tells the receiver to unwrap21 *  it into a scalar field — `commentCount: 5` instead of `commentCount: [{ count: 5 }]`. */22export interface WireProjection {23  /** Which CHILD column to surface as the scalar value (the aggregate column). */24  col: number;25  /** The value to emit when the relationship is empty (a childless parent) — the26   *  aggregate identity (`0` for count, `null` for sum/avg). */27  identity: WireValue;28}2930export interface WireRel {31  name: string;32  slot: number;33  /** `null` ⇒ an out-of-view / gating slot (the in-view gate drops `Child`s addressed here). */34  child: WireSchema | null;35  /** Non-null ⇒ a scalar-projected relationship aggregate the receiver unwraps to a scalar36   *  (REDUCE-DESIGN.md §9). Absent/`null` for an ordinary (plural or `.one()`) relationship. */37  project?: WireProjection | null;38}3940export interface WireSchema {41  columns: string[];42  primaryKey: number[];43  /** Resolved, PK-completed sort: `[columnIndex, ascending]` pairs (the comparator input). */44  sort: [number, boolean][];45  singular: boolean;46  relationships: WireRel[];47}4849export interface WireNode {50  row: WireValue[];51  rels: { rel: number; children: WireNode[] }[];52}5354export interface PathSeg {55  rel: number;56  parentRow: WireValue[];57}5859/** A positional change at one level of the view tree. A `remove` ships only the leaving `row`60 *  (the receiver already holds the subtree and locates it by key) — `node` is NEVER on the wire;61 *  it is an OPTIONAL client-side enrichment carrying the full removed subtree, attached by the62 *  ArrayView when a change consumer opts in (`Store.subscribeChanges(_, { removedSubtree: true })`),63 *  so a narrator can resolve a removed row's nested subs just as it can on an `add`. */64export type FlatOp =65  | { tag: "add"; node: WireNode }66  | { tag: "remove"; row: WireValue[]; node?: WireNode }67  | { tag: "edit"; old: WireValue[]; new: WireValue[] };6869export interface FlatChange {70  path: PathSeg[];71  op: FlatOp;72}7374/**75 * What a {@link Backend} pushes per query: the handshake (`hello`), the (possibly76 * chunked) hydrate `snapshot`, then incremental `batch`es. The core builds an ArrayView77 * on `hello`, hydrates on `snapshot`, folds on `batch` — identically for any backend.78 */79export type ChangeEvent =80  | { type: "hello"; schema: WireSchema; comparatorVersion: number }81  | { type: "snapshot"; adds: FlatChange[]; last: boolean }82  // `catchUp` marks a batch that is really a query's INITIAL hydration delivered as a delta — the83  // optimistic backend hydrates a fresh query through its reconcile cycle (a `serverBatchEnd`), so84  // the whole first result set arrives as a `batch`, not a `snapshot`. The Store maps a catch-up85  // batch to the `snapshot` change-phase so a narrator's "what CHANGED" default ignores the initial86  // rows instead of narrating every one as a fresh add. A normal incremental batch omits it.87  | { type: "batch"; events: FlatChange[]; catchUp?: boolean };8889export type Mutation =90  | { op: "add"; table: string; row: WireValue[] }91  | { op: "remove"; table: string; row: WireValue[] }92  | { op: "edit"; table: string; old: WireValue[]; new: WireValue[] };9394/**95 * A table-tagged, path-free row delta — the **normalized** wire payload (the path-free twin96 * of {@link FlatChange}; NORMALIZED-CHANGES-DESIGN.md §3). Rows are positional (bare cells; a97 * json column is its raw JSON string). `op` is the discriminant. Lives here (not in98 * `@rindle/normalized`) so both the protocol (`@rindle/remote`) and the sync layer share one type.99 */100export type NormalizedOp =101  | { table: string; op: "add"; row: WireValue[] }102  | { table: string; op: "remove"; row: WireValue[] }103  | { table: string; op: "edit"; old: WireValue[]; new: WireValue[] };104105/** One base table's flat schema on the normalized `hello` (§3): column names (in order) +106 *  primary-key column indices. Wire rows are positional against `columns`. */107export interface NormalizedTableSchema {108  name: string;109  columns: string[];110  primaryKey: number[];111}112113/**114 * A per-query NORMALIZED stream event — the path-free twin of {@link ChangeEvent}. The115 * `hello` carries the flat per-table schemas (no nested view schema, §3); `snapshot`/`batch`116 * carry table-tagged {@link NormalizedOp}s. The `NormalizedSync` layer folds these into the117 * local engine's base tables.118 *119 * `cv` (the global commit version the frame's data reflects — OPTIMISTIC-WRITES-DESIGN.md120 * §8.3/§8.6) is present on sources that speak the optimistic protocol; the plain normalized121 * path may omit it.122 */123export type NormalizedEvent =124  | { type: "hello"; tables: NormalizedTableSchema[]; comparatorVersion: number; normalizedFp: string }125  | { type: "snapshot"; ops: NormalizedOp[]; cv?: number }126  | { type: "batch"; ops: NormalizedOp[]; cv?: number };127128export type QueryId = number;129130/** A dev-only authoritative server delta exposed by backends that can distinguish the upstream131 *  server stream from their local view/IVM stream. Flat remote backends surface clean132 *  {@link ChangeEvent}s; local-first normalized/optimistic backends surface the path-free133 *  {@link NormalizedEvent}s before they are folded into the local engine. */134export type BackendServerDelta =135  | { format: "flat"; event: ChangeEvent }136  | { format: "normalized"; event: NormalizedEvent };137138/** Dev-only passive tap over a backend's authoritative server stream. Optional because in-process139 *  backends have no server stream, and older/custom backends may only expose the Store tap. */140export interface BackendDevObserver {141  onServerDelta?(qid: QueryId, ev: BackendServerDelta): void;142}143144/** A query's SERVER-CHANNEL state, surfaced on its {@link ArrayView} (FOLDED-MUTATIONS-DESIGN §7 —145 *  formerly conflated with pending-ness, OPTIMISTIC-WRITES-DESIGN.md §6):146 *   - `unknown`  — not hydrated: the server has not produced a first result for this query yet;147 *   - `complete` — the server has answered. STAYS `complete` while a local mutation is pending (the148 *                  prediction is the client's best current answer); reversion on rejection is an149 *                  event (`onRejected`), not a downgrade of completeness;150 *   - `error`    — RESERVED for a future server-side, query-level error signal151 *                  (see `designs/101-QUERY-ERRORS-DESIGN.md`); no longer produced by a pending mutation.152 *  "Is a prediction pending here?" is now a separate reactive axis (the backend's `pending(qid)` /153 *  `onPending`), not folded into this type. A backend with no server lifecycle (the in-process154 *  engine) leaves every view `complete`. */155export type ResultType = "unknown" | "complete" | "error";156157/** The network identity for a named query subscription. The local AST remains local; remote158 *  normalized/optimistic sources use only this `(name,args)` pair upstream. */159export interface RemoteQuery {160  name: string;161  args: unknown;162}163164/**165 * The seam the core talks to. Backend-agnostic: the same interface for the in-process166 * WASM backend and a remote (network) backend. The core never knows which. A backend167 * pushes a per-query {@link ChangeEvent} stream via {@link Backend.onEvent}; the remote168 * backend additionally owns the epoch/seq/gap protocol and emits only clean, in-order169 * events (so the ArrayView never sees the wire protocol).170 */171export interface Backend {172  registerQuery(queryId: QueryId, ast: unknown, remote?: RemoteQuery): void;173  unregisterQuery(queryId: QueryId): void;174  /** Optional split path for local-first backends: retain/release a named remote footprint175   *  without creating another local materialized view. `localQueryId`, when provided, names176   *  the local AST view this remote footprint feeds. `ast`, when provided, gives the backend177   *  the local schema context for sync-only retains that still need aggregate/table setup. */178  retainRemoteQuery?(queryId: QueryId, remote: RemoteQuery, localQueryId?: QueryId, ast?: Ast): void;179  releaseRemoteQuery?(queryId: QueryId): void;180  /** Local: applies now (changes flow back on the stream). Remote: sends to the server. */181  mutate(mutations: Mutation[]): Promise<void>;182  /** Optional DIRECT-COMMIT path for LOCAL-only tables (`201-LOCAL-ONLY-TABLES-DESIGN.md` §6):183   *  applies the writes straight to the engine, OUTSIDE the optimistic pending stack (a local184   *  table is untracked, so it never rebases). The backend rejects any synced/tracked table (M2).185   *  Backends with no local-table support (a plain remote sync backend) omit it.186   *187   *  `onCommitted` (207 §5.1) runs after the engine commit is applied but BEFORE subscriber188   *  delivery: a subscriber throwing during delivery re-raises out of this call, and a caller189   *  that must stay coherent with the engine (the persistence tap) cannot tell that throw from190   *  a pre-commit rejection — the callback can, because it fires exactly when the commit is in. */191  writeLocal?(mutations: Mutation[], onCommitted?: () => void): void;192  onEvent(handler: (queryId: QueryId, event: ChangeEvent) => void): void;193  /** Optional: the backend pushes per-query {@link ResultType} changes here and the core routes194   *  each to the matching view (so `view.resultType` tracks it). Backends with no lifecycle (the195   *  in-process engine) omit it and every view stays `complete`. */196  onResultType?(handler: (queryId: QueryId, resultType: ResultType) => void): void;197  /** Optional: brackets one commit's coherent multi-query delivery so the Store can fold every198   *  affected view BEFORE notifying any subscriber — cross-view-atomic notification. The199   *  in-process engine derives the whole commit's per-query deltas against one consistent post-200   *  commit snapshot, then dispatches them one query at a time; without a barrier a subscriber on201   *  the first-dispatched view, re-reading a sibling view in its callback, would observe that202   *  sibling's PRE-commit state (it has not folded yet). The backend calls the handler with203   *  `"begin"` before delivering a commit's per-query `batch` events (via {@link onEvent}) and204   *  `"end"` after the last one; between them the Store folds each view but DEFERS its205   *  notification, firing every affected view's subscribers together at `"end"`. Backends with no206   *  synchronous multi-query commit boundary (a plain remote backend, where each query's frame207   *  arrives in its own message) omit it — the Store then notifies per event, exactly as before. */208  onCommitBoundary?(handler: (phase: "begin" | "end") => void): void;209  /** Optional dev-only authoritative server stream tap. It is additive, like210   *  `Store.subscribeChanges`, and must not displace the backend's normal event handler. */211  __attachDevtoolsServerDeltas?(observer: BackendDevObserver): () => void;212}213214/**215 * The server side of the **normalized** local-first path (NORMALIZED-CHANGES-DESIGN.md §5/§7):216 * registers queries and pushes each one's normalized footprint stream ({@link NormalizedEvent}s).217 * Both the in-process native (`@rindle/replica`) source and the ws (`@rindle/remote`218 * `RemoteNormalizedSource`) implement it; `@rindle/normalized`'s `NormalizedBackend` consumes one,219 * never knowing which — a sibling seam of {@link Backend} for the normalized composition.220 */221export interface NormalizedSource {222  registerQuery(queryId: QueryId, remote: RemoteQuery): void;223  unregisterQuery(queryId: QueryId): void;224  /** Send base-table writes to the server; the authoritative stream flows back. */225  mutate(mutations: Mutation[]): Promise<void>;226  onNormalized(handler: (queryId: QueryId, event: NormalizedEvent) => void): void;227  /** Optional: the backend hands its OWN typed per-table schemas so the source can validate228   *  each server `hello` against them (column order / PK by name) and reject a schema skew229   *  rather than silently transposing positional cells (CRIT#4 / §3 "drift ⇒ re-subscribe").230   *  Sources that can't skew (the in-process native source) may omit it. */231  expectClientSchema?(tables: NormalizedTableSchema[]): void;232}233234/** The upstream named-mutator envelope (OPTIMISTIC-WRITES-DESIGN.md §8.1): the wire235 *  carries the name + JSON args, never code; `mid` totally orders a client's mutations. */236export interface MutationEnvelope {237  clientID: string;238  mid: number;239  name: string;240  args: unknown;241}242243/** A room authority's verdict for a NON-applied mutation (RINDLE-REALTIME-QUERY-ENABLEMENT244 *  §3.3, the deopt handshake; Slice H-iv-b server half / H-v client half). Sent on the author's245 *  own socket for every mutation the room did NOT apply, always BEFORE the lmid ack that burns246 *  the `mid` (same-socket ordering only — the ack may reach the client through another path247 *  first, e.g. a replayed lmid snapshot). Applied mutations send NOTHING: silence + lmid248 *  coverage ⇒ applied.249 *250 *  - `kind: "deopt"` — the room's §3.3 commit gate refused the routed mutation (or its251 *    environment fell short, e.g. `tx.query`); the mid is burnt in the ROOM ledger with zero252 *    effects and the client re-enqueues the same logical mutation onto the daemon stream.253 *    `name`/`args` are echoed so the frame is SELF-CONTAINED: a client that already retired the254 *    entry (the burnt-mid confirm won the race, or the frame is a replay re-answer) re-invokes255 *    from the frame alone.256 *  - `kind: "rejected"` — FINAL (authz/validation): the mid is burnt the same way and the257 *    prediction snaps back on the ordinary lmid release; no re-route.258 *259 *  `reason` may be absent on a re-answered frame whose record was seeded from journal replay260 *  (the verdict is journaled; the reason is not). */261export interface MutationOutcomeFrame {262  mid: number;263  kind: "deopt" | "rejected";264  reason?: string;265  /** Echoed on DEOPT frames only (self-contained re-invoke — see above). */266  name?: string;267  args?: unknown;268}269270/** The connection-level progress frame (§8.6): advances the coherent-apply release point271 *  (`cvMin`). A pure release signal — mutation confirmation does NOT ride it: `lmid` is a272 *  row in {@link CLIENT_MUTATIONS_TABLE}, delivered through the client's own per-client273 *  system query ({@link LMID_QUERY_NAME}) like any data, so it is released by the same274 *  `cvMin` as the commit's effects (transactionally coherent by construction). */275export interface ProgressFrame {276  cvMin: number;277}278279/** The replicated bookkeeping table carrying each client's high-water mutation id280 *  (`lmid`). Engine-hosted and served like any base table; reserved (never part of a281 *  user schema). Columns: `[client_id, last_mutation_id]`, PK `client_id`. */282export const CLIENT_MUTATIONS_TABLE = "_rindle_client_mutations";283284/** The reserved server-query name every optimistic client subscribes at connect: the285 *  one-row system query `CLIENT_MUTATIONS_TABLE WHERE client_id = <me>`. The server286 *  derives the identity from the connection (args are ignored). */287export const LMID_QUERY_NAME = "_rindle/clientLmid";288289/** The system table's wire schema — appended to the client's expected schemas so the290 *  lmid query's `hello` passes CRIT#4 validation. */291export const CLIENT_MUTATIONS_SCHEMA: NormalizedTableSchema = {292  name: CLIENT_MUTATIONS_TABLE,293  columns: ["client_id", "last_mutation_id"],294  primaryKey: [0],295};296297/**298 * The server side of the OPTIMISTIC path (OPTIMISTIC-WRITES-DESIGN.md §8): the299 * {@link NormalizedSource} stream with `cv`-tagged data frames, plus the connection-level300 * {@link ProgressFrame} channel and the named-mutator upstream. The client buffers data301 * frames by `cv` and applies all `cv ≤ cvMin` as one coherent release (§8.5).302 */303export interface OptimisticSource {304  registerQuery(queryId: QueryId, remote: RemoteQuery): void;305  unregisterQuery(queryId: QueryId): void;306  /** Ship one named-mutator invocation upstream (§8.1). Confirmation rides the progress frames. */307  pushMutation(envelope: MutationEnvelope): Promise<void>;308  onNormalized(handler: (queryId: QueryId, event: NormalizedEvent) => void): void;309  onProgress(handler: (frame: ProgressFrame) => void): void;310  /** Optional: the backend hands its OWN typed per-table schemas so the source validates each311   *  server `hello` against them and rejects a schema skew (CRIT#4); see {@link NormalizedSource}. */312  expectClientSchema?(tables: NormalizedTableSchema[]): void;313  /** Optional: fired when the server restarts (a transport that can detect it, e.g. via a daemon314   *  boot id). The backend resets its `cv` watermark so the server's reset `cv` sequence is315   *  accepted rather than dropped as stale. In-process sources never restart and omit it. */316  onRestart?(handler: () => void): void;317  /** Optional (Slice H-v): the channel's {@link MutationOutcomeFrame} stream — the room deopt318   *  handshake's client half. **OUT-OF-BAND BY DESIGN**: the frame carries no `cv` and the source319   *  MUST dispatch it immediately on arrival, never behind the cv buffer — a deopt has to migrate320   *  its pending entry BEFORE the buffered lmid release that would otherwise retire it as a321   *  success (and the §7.3 hold-back trigger, keyed on the entry's confirming domain, would then322   *  park its staged writes the wrong way). Sources whose authority never deopts (the in-process323   *  native source, a plain daemon) omit it. */324  onMutationOutcome?(handler: (frame: MutationOutcomeFrame) => void): void;325  /** Optional (Slice H-v, the §7.5 rule-3 crash-window closer): fired when the transport326   *  RE-establishes its session (reconnect → re-`init`), BEFORE any post-reconnect frame is327   *  processed — the ordering is load-bearing: the re-subscribed lmid stream's fresh snapshot may328   *  cover a mid whose `mutationOutcome` frame died with the old socket, and once that release329   *  retires the entry as an apparent success there is nothing left to re-send. The backend330   *  re-sends this domain's unconfirmed pending envelopes with their ORIGINAL mids, in mid order;331   *  the source may DEFER their delivery until the session is re-authorized (a room's332   *  `pushMutation` requires the lease-token subscribe's subject). Idempotent under the domain's333   *  own ledger — a processed mid dedups silently (silence + lmid coverage ⇒ applied), a334   *  non-applied mid is re-answered from the authority's recorded-outcome map (resolving even an335   *  already-retired entry through the handshake's not-found arm). Distinct from336   *  {@link onRestart} (a NEW server incarnation): a same-incarnation socket drop re-syncs337   *  without restarting, and envelopes sent into the dead socket are exactly what this recovers.338   *  In-process sources never drop a session and omit it. */339  onResync?(handler: () => void): void;340}341