API index and search · Build metadata
Source snapshot
packages/remote/src/normalized.ts
1// The NORMALIZED subscription protocol — the wire contract shared by the server2// (`@rindle/server`, which relays the native engine's already-stamped frames) and the client3// (`RemoteNormalizedSource`, which validates via {@link NormalizedSubscriber}). The path-free4// twin of `./protocol.ts`: same epoch/seq/gap envelope, normalized payloads (table-tagged5// `NormalizedOp`s), and a slim per-table-schema hello (NORMALIZED-CHANGES-DESIGN.md §3).6//7// As with the flat path, the Subscriber does ONLY validation/sequencing and hands clean8// `NormalizedEvent`s up — the `NormalizedSync` layer (in `@rindle/normalized`) folds them.910import { COMPARATOR_VERSION } from "@rindle/client";11import type { NormalizedEvent, NormalizedOp, NormalizedTableSchema } from "@rindle/client";1213import { Fnv } from "./protocol.ts";14import { ProtocolError } from "./protocol.ts";1516// ----------------------------- normalized fingerprint -----------------------------1718/** A `tables` set's content fingerprint — FNV-1a 64 over the canonical, length-prefixed byte19 * stream of `rindle-replica::normalize_protocol::normalized_fp` (PK resolved to column NAMES),20 * as 16-char lowercase hex (=== the Rust hex). `tables` MUST be sorted by name (the server's21 * `NormalizedPublisher` guarantees it) so the fingerprint is order-stable. */22export function normalizedFp(tables: NormalizedTableSchema[]): string {23 const f = new Fnv();24 f.u32(tables.length);25 for (const t of tables) {26 f.s(t.name);27 f.u32(t.columns.length);28 for (const c of t.columns) f.s(c);29 f.u32(t.primaryKey.length);30 for (const pk of t.primaryKey) f.s(t.columns[pk]); // PK by NAME31 }32 return f.h.toString(16).padStart(16, "0");33}3435/** Is `sub` a subsequence of `sup` by NAME — every entry of `sub` present in `sup`, in order?36 * Accepts a projection (server ⊆ client) or an expansion (client ⊆ server); a rename/reorder37 * satisfies neither direction. */38function isSubsequenceByName(sub: readonly string[], sup: readonly string[]): boolean {39 let i = 0;40 for (const name of sup) if (i < sub.length && sub[i] === name) i++;41 return i === sub.length;42}4344/** Validate the tables a `hello` advertises against the CLIENT's own typed schema, by name45 * (column order + PK indices). `hello.tables` is the query's table subtree, so each one must46 * be column-compatible with a client table. Throws {@link ProtocolError} `"schema-mismatch"`47 * on the first unknown table / column-order skew / PK skew — the §3 "drift ⇒ re-subscribe"48 * guard (CRIT#4). */49function validateAgainstClientSchema(50 serverTables: NormalizedTableSchema[],51 clientTables: NormalizedTableSchema[],52): void {53 const byName = new Map(clientTables.map((t) => [t.name, t]));54 const eq = (a: readonly (string | number)[], b: readonly (string | number)[]) =>55 a.length === b.length && a.every((x, i) => x === b[i]);56 for (const st of serverTables) {57 const ct = byName.get(st.name);58 if (!ct) {59 throw new ProtocolError("schema-mismatch", `server advertises table "${st.name}" not in the client schema`);60 }61 // The server may advertise FEWER columns than the client (a projected query —62 // PROJECTION-SUPPORT-DESIGN.md §5.2/§7) OR MORE (an EXPANDED server table mid an63 // `expand-then-contract` migration — the client drops the columns it doesn't yet have).64 // Both are safe: the client maps every advertised column to a base position BY NAME, so65 // the relative order is preserved either way. Accept when one column list is a SUBSEQUENCE66 // of the other by name — a narrowing (projection) or a widening (expand) — while still67 // rejecting a genuine skew, where NEITHER is a subsequence of the other: a renamed column68 // or a reordered one (the CRIT#4 guard). A server with more columns than the client is no69 // longer drift; without this, `expand-then-contract` is impossible.70 if (!isSubsequenceByName(st.columns, ct.columns) && !isSubsequenceByName(ct.columns, st.columns)) {71 throw new ProtocolError(72 "schema-mismatch",73 `column drift on "${st.name}": server [${st.columns}] is neither a projection nor an expansion of client [${ct.columns}] (column order skew)`,74 );75 }76 // PK compared by NAME (the server forces the PK into every projection, so it is always77 // present): resolve each side's PK indices to names and require equality.78 const serverPk = st.primaryKey.map((i) => st.columns[i]);79 const clientPk = ct.primaryKey.map((i) => ct.columns[i]);80 if (!eq(serverPk, clientPk)) {81 throw new ProtocolError(82 "schema-mismatch",83 `primary-key drift on "${st.name}": client [${clientPk}] != server [${serverPk}]`,84 );85 }86 }87}8889// ----------------------------- the wire frames -----------------------------9091/** The slim normalized handshake (§3), sent once before any {@link NormalizedBatch}. */92export interface NormalizedHello {93 epoch: number;94 comparatorVersion: number;95 tables: NormalizedTableSchema[];96 normalizedFp: string;97}9899/** One committed transaction's normalized ops (or the seq-0 hydrate snapshot). `cv` (the100 * global commit version the frame reflects) is stamped by optimistic-protocol servers and101 * rides through to the `NormalizedEvent` (OPTIMISTIC-WRITES-DESIGN.md §8.3). */102export interface NormalizedBatch {103 epoch: number;104 seq: number;105 normalizedFp: string;106 ops: NormalizedOp[];107 cv?: number;108}109110// ----------------------------- Subscriber (client side) -----------------------------111112/** Receiver side: validates a normalized frame stream (comparator at `hello`; per batch —113 * epoch match, fingerprint match, strict in-order seq) and emits clean `NormalizedEvent`s.114 * It does NOT fold (the `NormalizedSync` layer does). Mirrors the flat {@link Subscriber}. */115export class NormalizedSubscriber {116 readonly epoch: number;117 readonly normalizedFp: string;118 private readonly emit: (ev: NormalizedEvent) => void;119 private phase: "snapshot" | "live" = "snapshot";120 private lastSeq = 0;121122 /**123 * @param hello the server's normalized handshake.124 * @param emit clean-event sink (the `NormalizedSync` fold).125 * @param clientTables the CLIENT's own typed per-table schemas (all tables). When given,126 * each table the hello advertises is validated by NAME against it (column order + PK127 * indices). The hello's `tables` is a per-query SUBSET (the query's table tree), so this128 * checks each advertised table rather than one global fingerprint. A mismatch (routine129 * deployment / schema skew) is rejected here — without it, positional rows aligned to the130 * SERVER's column order are stored verbatim under the CLIENT's order, silently swapping131 * cells and mis-keying the refcount/GC (CRIT#4 / §3 "drift ⇒ re-subscribe").132 */133 constructor(134 hello: NormalizedHello,135 emit: (ev: NormalizedEvent) => void,136 clientTables?: NormalizedTableSchema[],137 ) {138 this.emit = emit;139 if (hello.comparatorVersion !== COMPARATOR_VERSION) {140 throw new ProtocolError(141 "comparator-mismatch",142 `comparator version ${hello.comparatorVersion} != ${COMPARATOR_VERSION}`,143 );144 }145 const computed = normalizedFp(hello.tables);146 if (computed !== hello.normalizedFp) {147 throw new ProtocolError("schema-mismatch", `advertised fp ${hello.normalizedFp} != computed ${computed}`);148 }149 if (clientTables) validateAgainstClientSchema(hello.tables, clientTables);150 this.epoch = hello.epoch;151 this.normalizedFp = hello.normalizedFp;152 emit({153 type: "hello",154 tables: hello.tables,155 comparatorVersion: hello.comparatorVersion,156 normalizedFp: hello.normalizedFp,157 });158 }159160 /** Apply one normalized batch (or the seq-0 snapshot). Returns `"duplicate"` for an161 * already-applied seq; throws {@link ProtocolError} on a gap / epoch / fp mismatch (the162 * caller re-hydrates under a new epoch). */163 apply(batch: NormalizedBatch): "applied" | "duplicate" {164 if (batch.epoch !== this.epoch) {165 throw new ProtocolError("epoch-mismatch", `expected epoch ${this.epoch}, got ${batch.epoch}`);166 }167 if (batch.normalizedFp !== this.normalizedFp) {168 throw new ProtocolError("schema-mismatch", `expected fp ${this.normalizedFp}, got ${batch.normalizedFp}`);169 }170 if (this.phase === "snapshot") {171 if (batch.seq === 0) {172 this.phase = "live";173 this.lastSeq = 0;174 this.emit({ type: "snapshot", ops: batch.ops, cv: batch.cv });175 return "applied";176 }177 throw new ProtocolError("gap", `expected the seq-0 snapshot, got seq ${batch.seq}`);178 }179 const expected = this.lastSeq + 1;180 if (batch.seq < expected) return "duplicate";181 if (batch.seq > expected) {182 throw new ProtocolError("gap", `expected seq ${expected}, got ${batch.seq}`);183 }184 this.lastSeq = batch.seq;185 this.emit({ type: "batch", ops: batch.ops, cv: batch.cv });186 return "applied";187 }188}189