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