rindle_wire/normalize_protocol.rs
1//! The **normalized subscription protocol** (`NORMALIZED-CHANGES-DESIGN.md` §3) — the
2//! path-free twin of the engine's `flat_protocol`. The envelope semantics are identical
3//! (a once-per-subscription [`NormalizedHello`], a logical-seq-0 snapshot, then
4//! gap-free incremental [`NormalizedBatch`]es at seq 1, 2, …); only the **payload**
5//! differs — table-tagged [`NormalizedOp`]s instead of path-tagged `FlatChange`s — and
6//! the **hello is slimmer**: flat per-table schemas (for positional-row alignment + a
7//! `normalized_fp`), not a nested hierarchical view schema (§3).
8//!
9//! Why a sibling here rather than a generic reuse of `flat_protocol`: that module's
10//! `Publisher`/`Subscriber`/`Batch` are concrete over `FlatChange` + a hierarchical view
11//! `Schema`, and `flat_protocol` lives in the wasm-clean `rindle` core; normalize lives
12//! beside this module in `rindle-wire` (also wasm-clean, but the protocol crate, not
13//! the engine — see the placement note in [`crate::normalize`]). So this re-implements
14//! the *small* epoch/seq/gap machinery over the normalized payload rather than
15//! generic-ifying core. The contract it enforces is the flat one:
16//!
17//! - **Sender** ([`NormalizedPublisher`]) wraps a [`NormalizeFold`], drives the seq
18//! counter, and stamps every frame with the subscription `epoch` + `normalized_fp`.
19//! An **empty transaction emits no batch and consumes no seq** (so seq stays gap-free
20//! over *emitted* batches).
21//! - **Receiver** ([`NormalizedSubscriber`], the validation half — the fold itself is
22//! the TS `NormalizedSync`, Slice 4) enforces the comparator contract at the hello and,
23//! per batch, epoch + fingerprint + strict in-order seq. A **duplicate** seq is
24//! discarded idempotently (`rc` add/remove are not idempotent). A **gap** is fatal: the
25//! only repair is a full re-hydrate under a **new epoch** (§5.3), after which stale
26//! old-epoch frames are rejected by [`NormalizedProtocolError::EpochMismatch`].
27//!
28//! Snapshot **chunking** (the `flat_protocol` `SnapshotChunk` path) is deferred: this
29//! slice ships the single-shot seq-0 snapshot. Chunking is mechanical to add later (the
30//! design §3 notes it as a capability, not a v1 requirement).
31
32use std::collections::{BTreeMap, BTreeSet};
33
34use rindle::{Ast, CaughtChange, WireRow, COMPARATOR_VERSION};
35
36use crate::normalize::{
37 required_columns_by_table, table_tree, NormalizeFold, NormalizedOp, PkMap, ReqCols, TableNode,
38};
39
40// ---------------------------------------------------------------------------
41// Wire types (§3)
42// ---------------------------------------------------------------------------
43
44/// One base table's flat schema on the wire: its ordered column **names** (wire rows are
45/// positional against them) and the primary-key column **indices**. The client validates
46/// these against its own typed schema and registers any table it hasn't yet (e.g. an
47/// EXISTS witness table). No `sort` — base tables sort by PK in the memory source, and the
48/// result sort is the client's local engine's concern (§3).
49#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
50pub struct TableWireSchema {
51 pub name: Box<str>,
52 pub columns: Vec<Box<str>>,
53 pub primary_key: Vec<u32>,
54}
55
56/// The subscription handshake (§3), sent once before any [`NormalizedBatch`]. Slimmer than
57/// the flat `Hello`: flat per-table schemas, no nested view schema or per-level sort.
58#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59pub struct NormalizedHello {
60 pub epoch: u64,
61 pub comparator_version: u32,
62 /// The base tables this subscription's footprint can surface (the query's table tree),
63 /// in a stable order (sorted by name) so the wire form is deterministic.
64 pub tables: Vec<TableWireSchema>,
65 /// FNV-1a-64 fingerprint over `{ table → (ordered columns, PK) }` by **name** — a
66 /// sibling of the engine's `schema_fp`. Drift ⇒ re-subscribe.
67 pub normalized_fp: u64,
68}
69
70/// One committed transaction's normalized ops (or the seq-0 hydrate snapshot). Ops apply
71/// in order into one client `Db.write()` transaction.
72#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
73pub struct NormalizedBatch {
74 pub epoch: u64,
75 pub seq: u64,
76 /// The global commit version this frame's data reflects (`OPTIMISTIC-WRITES-DESIGN.md`
77 /// §8.3/§8.6) — the replica's `TxId` for the commit (the hydrate watermark for the
78 /// seq-0 snapshot). Data frames ship eagerly per query; an optimistic client buffers
79 /// them by `cv` and applies all `cv ≤ cv_min` as one coherent transaction (§8.5).
80 /// Non-optimistic consumers may ignore it.
81 pub cv: u64,
82 pub normalized_fp: u64,
83 pub ops: Vec<NormalizedOp>,
84}
85
86/// The first `Int` in this batch's rows outside `Number.MAX_SAFE_INTEGER`, if any —
87/// the 09.8 `strict_i64` walk for the normalized wire (design 226 Stage A). A JS-facing
88/// boundary with strict mode on refuses the batch with a typed error instead of letting
89/// the cell encode round it (`wire_json`'s `Int → f64` collapse).
90pub fn unsafe_int_in_normalized_batch(batch: &NormalizedBatch) -> Option<i64> {
91 use rindle::js_safe::unsafe_int_in_cells;
92 batch.ops.iter().find_map(|op| match op {
93 NormalizedOp::Add { row, .. } | NormalizedOp::Remove { row, .. } => {
94 unsafe_int_in_cells(row)
95 }
96 NormalizedOp::Edit { old, new, .. } => {
97 unsafe_int_in_cells(old).or_else(|| unsafe_int_in_cells(new))
98 }
99 })
100}
101
102/// The connection-level progress frame (§8.6): advances the client's coherent release
103/// point (`cv_min`). Pure release signal — mutation confirmation does NOT ride it:
104/// `lmid` is a row in the client-mutations table (`_rindle_client_mutations`,
105/// `rindle-replica`'s `CLIENT_MUTATIONS_TABLE`), delivered through the client's
106/// own per-client system query like any other data, so it is released by the same
107/// `cv_min` that releases the commit's effects (transactionally coherent by
108/// construction). Emitted per the poke rule (§8.4); standalone only to advance
109/// `cv_min` during a quiet window.
110#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
111pub struct ProgressFrame {
112 /// The commit version every one of this connection's live queries has processed
113 /// through — the client's coherent-apply release point (§8.3).
114 pub cv_min: u64,
115}
116
117/// A protocol violation the [`NormalizedSubscriber`] surfaces. All but a duplicate are
118/// fatal — the only repair is a re-hydrate under a new epoch (§5.3).
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum NormalizedProtocolError {
121 /// The hello's table set is not a valid normalized wire schema. Validation happens
122 /// before fingerprinting so an attacker-controlled primary-key index can never reach
123 /// `columns[pk]` and panic.
124 InvalidSchema(NormalizedSchemaError),
125 /// The hello's comparator-contract version differs — reconstruction would silently
126 /// corrupt, so the subscription is refused (a code contract, not data).
127 ComparatorMismatch { expected: u32, got: u32 },
128 /// The advertised / batch fingerprint differs from the subscribed schema.
129 FpMismatch { expected: u64, got: u64 },
130 /// A batch from a different (usually stale) subscription epoch.
131 EpochMismatch { expected: u64, got: u64 },
132 /// A seq beyond the next expected ⇒ a batch was lost. Re-hydrate. (`expected` is the
133 /// next seq the receiver wanted.)
134 Gap { expected: u64, got: u64 },
135}
136
137/// A malformed table set advertised by a [`NormalizedHello`]. `table_index` identifies
138/// the offending entry in [`NormalizedHello::tables`]; the table name itself stays in the
139/// hello so this error remains small and `Copy` while callers can still render a precise
140/// diagnostic.
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub struct NormalizedSchemaError {
143 pub table_index: usize,
144 pub kind: NormalizedSchemaErrorKind,
145}
146
147/// The semantic wire-schema rules required by [`normalized_fp`] and by consumers that
148/// register the advertised tables as keyed sources.
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum NormalizedSchemaErrorKind {
151 EmptyPrimaryKey,
152 PrimaryKeyOutOfBounds,
153 TablesNotSortedByUniqueName,
154}
155
156impl NormalizedSchemaErrorKind {
157 /// Stable human-readable reason used by room diagnostics.
158 pub fn reason(self) -> &'static str {
159 match self {
160 Self::EmptyPrimaryKey => "empty primary key",
161 Self::PrimaryKeyOutOfBounds => "primary-key index out of bounds",
162 Self::TablesNotSortedByUniqueName => "tables not sorted by unique name",
163 }
164 }
165}
166
167/// The outcome of applying a [`NormalizedBatch`].
168#[derive(Clone, Debug)]
169pub enum NormalizedApplied {
170 /// A newly-applied batch: apply these ops to the base store (in order).
171 Live(Vec<NormalizedOp>),
172 /// An already-applied seq (re-delivery) — discard idempotently, base store unchanged.
173 Duplicate,
174}
175
176// ---------------------------------------------------------------------------
177// normalized_fp — the by-name content fingerprint (§3)
178// ---------------------------------------------------------------------------
179
180/// Validate the table-set invariants required by [`normalized_fp`] and normalized-store
181/// construction. This accepts wire-deserialized input, so every failure is typed and no
182/// malformed schema may panic.
183pub fn validate_normalized_schema(tables: &[TableWireSchema]) -> Result<(), NormalizedSchemaError> {
184 for (table_index, table) in tables.iter().enumerate() {
185 let error = |kind| NormalizedSchemaError { table_index, kind };
186 if table.primary_key.is_empty() {
187 return Err(error(NormalizedSchemaErrorKind::EmptyPrimaryKey));
188 }
189 if table
190 .primary_key
191 .iter()
192 .any(|&pk| pk as usize >= table.columns.len())
193 {
194 return Err(error(NormalizedSchemaErrorKind::PrimaryKeyOutOfBounds));
195 }
196 if table_index > 0 && tables[table_index - 1].name >= table.name {
197 return Err(error(
198 NormalizedSchemaErrorKind::TablesNotSortedByUniqueName,
199 ));
200 }
201 }
202 Ok(())
203}
204
205/// FNV-1a-64 accumulator. The exact length-prefixed byte protocol is the wire contract —
206/// any peer must hash identically. (A local twin of the core `wire_schema::Fnv`, which is
207/// private; the byte protocol below is normalized-specific so it does not need to match
208/// `schema_fp`.)
209struct Fnv(u64);
210impl Fnv {
211 fn new() -> Fnv {
212 Fnv(0xcbf29ce4_84222325) // FNV-1a 64 offset basis
213 }
214 fn byte(&mut self, b: u8) {
215 self.0 ^= b as u64;
216 self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3); // FNV-1a 64 prime
217 }
218 fn u32(&mut self, v: u32) {
219 for b in v.to_le_bytes() {
220 self.byte(b);
221 }
222 }
223 /// Length-prefixed string — disambiguates concatenations (`"ab"+"c"` ≠ `"a"+"bc"`).
224 fn s(&mut self, s: &str) {
225 self.u32(s.len() as u32);
226 for &b in s.as_bytes() {
227 self.byte(b);
228 }
229 }
230}
231
232/// Fingerprint a `tables` set by **name** (resolving PK to column names), independent of
233/// internal column numbering. `tables` must satisfy [`validate_normalized_schema`]; the
234/// publisher constructors guarantee that invariant, and [`NormalizedSubscriber::open`]
235/// validates received schemas before calling this function.
236pub fn normalized_fp(tables: &[TableWireSchema]) -> u64 {
237 let mut h = Fnv::new();
238 h.u32(tables.len() as u32);
239 for t in tables {
240 h.s(&t.name);
241 h.u32(t.columns.len() as u32);
242 for c in &t.columns {
243 h.s(c);
244 }
245 h.u32(t.primary_key.len() as u32);
246 for &pk in &t.primary_key {
247 // Resolve PK to the column NAME so the identity is numbering-independent.
248 h.s(&t.columns[pk as usize]);
249 }
250 }
251 h.0
252}
253
254// ---------------------------------------------------------------------------
255// Publisher (sender)
256// ---------------------------------------------------------------------------
257
258/// Sender side: wraps a [`NormalizeFold`], stamps batches with the subscription `epoch` +
259/// `normalized_fp`, and drives the gap-free seq. The caller drains the change-sink (the
260/// replica's per-query `CaughtChange`s) and hands them to [`snapshot`](Self::snapshot) /
261/// [`commit`](Self::commit).
262pub struct NormalizedPublisher {
263 epoch: u64,
264 fold: NormalizeFold,
265 hello: NormalizedHello,
266 next_seq: u64,
267 /// Project-at-emit map (`PROJECTION-SUPPORT-DESIGN.md` §5.2): table name → the base
268 /// column indices this query syncs for it, in ascending (= projected wire) order. Only
269 /// **projected** tables appear; a table absent here syncs full. Empty ⇒ no projection,
270 /// so emission is a pass-through (a `'*'` query is byte-identical, §7).
271 proj: BTreeMap<Box<str>, Vec<usize>>,
272}
273
274/// Project-at-emit map (`PROJECTION-SUPPORT-DESIGN.md` §5.2): table name → the base column
275/// indices this query syncs for it, in ascending (= projected wire) order. Only **projected**
276/// tables appear; a table absent here syncs full. An empty map ⇒ no projection, so emission is
277/// a pass-through (a `'*'` query is byte-identical, §7).
278pub type ProjMap = BTreeMap<Box<str>, Vec<usize>>;
279
280/// The query-wide pieces a normalized subscription needs, derived once from the AST + the
281/// surfaced-table schemas: the [`NormalizeFold`] (the shared footprint serializer over the
282/// pipeline's **full** rows), the **projected** wire table set and its [`normalized_fp`], and
283/// the [`ProjMap`] that drives project-at-emit. Splitting this out lets a **shared** engine
284/// query own ONE fold + projection while each subscriber builds its own [`NormalizedHello`]
285/// from the same projected table set ([`hello_from_parts`]) — fold, projection, and hello agree
286/// by construction.
287///
288/// Panics if a surfaced table lacks a schema — a build-time config invariant (mirrors
289/// [`NormalizeFold`]'s `validate_pk`).
290pub fn build_query_parts(
291 ast: &Ast,
292 schemas: Vec<TableWireSchema>,
293) -> (NormalizeFold, Vec<TableWireSchema>, u64, ProjMap) {
294 // Every table the tree surfaces must have a schema.
295 let mut needed = BTreeSet::new();
296 collect_tables(&table_tree(ast), &mut needed);
297 for t in &needed {
298 assert!(
299 schemas.iter().any(|s| &s.name == t),
300 "normalized query: no schema supplied for table {t:?}"
301 );
302 }
303
304 // Sort tables by name for a deterministic wire form + fingerprint.
305 let mut tables = schemas;
306 tables.sort_by(|a, b| a.name.cmp(&b.name));
307
308 // Derive the fold's PK map (table → PK column indices) from the FULL schemas: the fold runs
309 // over the pipeline's full rows; projection is applied AFTER it, at emit (§2.1).
310 let pk: PkMap = tables
311 .iter()
312 .map(|t| {
313 (
314 t.name.clone(),
315 t.primary_key.iter().map(|&i| i as usize).collect(),
316 )
317 })
318 .collect();
319 let fold = NormalizeFold::new(ast, pk);
320
321 // Project each surfaced table to the columns this query needs (§5.2). A table whose required
322 // set covers every column (or is `'*'`) stays full; otherwise its wire schema declares the
323 // projected columns (in ascending base-index order) with the PK remapped, and the base
324 // indices are recorded in `proj` for project-at-emit. The wire fingerprint covers the
325 // PROJECTED schema so the subscriber validates against exactly what it receives.
326 let required = required_columns_by_table(ast);
327 let mut proj: ProjMap = BTreeMap::new();
328 let wire_tables: Vec<TableWireSchema> = tables
329 .iter()
330 .map(|t| match project_columns(t, required.get(&t.name)) {
331 Some(idxs) => {
332 let wire = TableWireSchema {
333 name: t.name.clone(),
334 columns: idxs.iter().map(|&i| t.columns[i].clone()).collect(),
335 primary_key: t
336 .primary_key
337 .iter()
338 .map(|&pk| {
339 idxs.iter()
340 .position(|&i| i == pk as usize)
341 .expect("PK is forced into the projection")
342 as u32
343 })
344 .collect(),
345 };
346 proj.insert(t.name.clone(), idxs);
347 wire
348 }
349 None => t.clone(),
350 })
351 .collect();
352 let fp = normalized_fp(&wire_tables);
353
354 (fold, wire_tables, fp, proj)
355}
356
357/// The per-subscriber handshake for a (possibly shared) normalized query at `epoch`, from
358/// the query's deterministic table set + [`normalized_fp`] (see [`build_query_parts`]).
359pub fn hello_from_parts(
360 epoch: u64,
361 tables: Vec<TableWireSchema>,
362 normalized_fp: u64,
363) -> NormalizedHello {
364 NormalizedHello {
365 epoch,
366 comparator_version: COMPARATOR_VERSION,
367 tables,
368 normalized_fp,
369 }
370}
371
372impl NormalizedPublisher {
373 /// Open a publisher at `epoch` for `ast`, given the flat schema of **every** table the
374 /// query's tree can surface (root + every `related`/EXISTS child table). Bump `epoch`
375 /// on a re-hydrate (§5.3). Panics if a surfaced table lacks a schema — a build-time
376 /// config invariant (mirrors `NormalizeFold`'s `validate_pk`).
377 pub fn new(epoch: u64, ast: &Ast, schemas: Vec<TableWireSchema>) -> NormalizedPublisher {
378 // The fold (over full rows), the projected wire table set + fingerprint, and the
379 // project-at-emit map all come from one place (§5.2) — the same primitive the shared
380 // engine-query path uses, so a `NormalizedPublisher` and a shared query project identically.
381 let (fold, wire_tables, fp, proj) = build_query_parts(ast, schemas);
382 NormalizedPublisher {
383 epoch,
384 fold,
385 hello: hello_from_parts(epoch, wire_tables, fp),
386 next_seq: 0,
387 proj,
388 }
389 }
390
391 /// The handshake to send before any batch.
392 pub fn hello(&self) -> &NormalizedHello {
393 &self.hello
394 }
395
396 /// The subscription epoch.
397 pub fn epoch(&self) -> u64 {
398 self.epoch
399 }
400
401 /// The hydrate snapshot (the pipeline's `CaughtChange::Add`s from
402 /// `Graph::hydrate_change_sink`) folded into a single seq-0 batch of `add` ops, at
403 /// commit version `cv` (the watermark the hydrate reflects — `Update::Hydrated`'s
404 /// `tx_id`). Always emitted — even for an empty result — so the receiver learns the
405 /// baseline is set. Reserves the seq-0 slot; increments start at seq 1. Call once,
406 /// before any `commit`.
407 pub fn snapshot(&mut self, caught: &[CaughtChange], cv: u64) -> NormalizedBatch {
408 let ops = project_ops(self.fold.fold(caught), &self.proj);
409 self.next_seq = 1;
410 NormalizedBatch {
411 epoch: self.epoch,
412 seq: 0,
413 cv,
414 normalized_fp: self.hello.normalized_fp,
415 ops,
416 }
417 }
418
419 /// Wrap one transaction's drained changes, stamped with the transaction's commit
420 /// version `cv` (`Update::Changed`'s `tx_id`). Folds them through the persistent
421 /// footprint and emits the net membership deltas. Returns `None` for a transaction
422 /// that produced **no** net op — no batch, no seq consumed — keeping seq gap-free
423 /// over emitted batches (an empty source tx, or one whose effects cancel within the
424 /// footprint).
425 pub fn commit(&mut self, caught: &[CaughtChange], cv: u64) -> Option<NormalizedBatch> {
426 let ops = project_ops(self.fold.fold(caught), &self.proj);
427 let seq = take_emitted_seq(&mut self.next_seq, !ops.is_empty())?;
428 Some(NormalizedBatch {
429 epoch: self.epoch,
430 seq,
431 cv,
432 normalized_fp: self.hello.normalized_fp,
433 ops,
434 })
435 }
436}
437
438/// Reserve the next sequence only when a fold produced an emitted batch. Kept as a pure
439/// transition both to make the gap-free rule explicit and to verify it without pulling
440/// `NormalizeFold`'s randomized `HashMap` state into the model checker.
441pub(crate) fn take_emitted_seq(next_seq: &mut u64, has_ops: bool) -> Option<u64> {
442 if !has_ops {
443 return None;
444 }
445 let seq = *next_seq;
446 *next_seq += 1;
447 Some(seq)
448}
449
450/// Every table `node`'s subtree can surface (`pub`: `rindle-room-core`'s downstream
451/// materialization walks the same set to assemble its schemas).
452pub fn collect_tables(node: &TableNode, out: &mut BTreeSet<Box<str>>) {
453 out.insert(node.table.clone());
454 // Pruned (`exists_noSync`) slots are `None`: their permission tables are never synced.
455 for c in node.children.iter().flatten() {
456 collect_tables(c, out);
457 }
458}
459
460/// The base column indices to sync for `t`, in ascending order, or `None` if the table syncs
461/// full (its required set is `All`, absent, or already covers every column). The PK is always
462/// forced present — the client keys rows by it (PROJECTION-SUPPORT-DESIGN.md §4.1).
463fn project_columns(t: &TableWireSchema, req: Option<&ReqCols>) -> Option<Vec<usize>> {
464 let names = match req {
465 Some(ReqCols::Names(names)) => names,
466 _ => return None, // `All` / missing ⇒ full table, no projection
467 };
468 let mut idxs: Vec<usize> = (0..t.columns.len())
469 .filter(|&i| names.contains(&t.columns[i]))
470 .collect();
471 for &pk in &t.primary_key {
472 let pk = pk as usize;
473 if !idxs.contains(&pk) {
474 idxs.push(pk);
475 }
476 }
477 idxs.sort_unstable();
478 // A projection that covers every column is just the full table.
479 if idxs.len() == t.columns.len() {
480 None
481 } else {
482 Some(idxs)
483 }
484}
485
486/// Project every op's rows to the per-table column set (§5.2). A no-op when `proj` is empty.
487/// `pub` (not `pub(crate)`): `rindle-replica`'s `Drain` applies the same projection on its
488/// own fanout path, and the room's downstream publisher will too.
489pub fn project_ops(ops: Vec<NormalizedOp>, proj: &ProjMap) -> Vec<NormalizedOp> {
490 if proj.is_empty() {
491 return ops;
492 }
493 ops.into_iter().map(|op| project_op(op, proj)).collect()
494}
495
496/// Project one op's row(s) positional over the table's projected columns; a table absent from
497/// `proj` passes through full.
498fn project_op(op: NormalizedOp, proj: &BTreeMap<Box<str>, Vec<usize>>) -> NormalizedOp {
499 fn pick(row: &WireRow, cols: &[usize]) -> WireRow {
500 cols.iter().map(|&c| row[c].clone()).collect()
501 }
502 match op {
503 NormalizedOp::Add { table, row } => {
504 let row = match proj.get(&table) {
505 Some(cols) => pick(&row, cols),
506 None => row,
507 };
508 NormalizedOp::Add { table, row }
509 }
510 NormalizedOp::Remove { table, row } => {
511 let row = match proj.get(&table) {
512 Some(cols) => pick(&row, cols),
513 None => row,
514 };
515 NormalizedOp::Remove { table, row }
516 }
517 NormalizedOp::Edit { table, old, new } => match proj.get(&table) {
518 Some(cols) => NormalizedOp::Edit {
519 old: pick(&old, cols),
520 new: pick(&new, cols),
521 table,
522 },
523 None => NormalizedOp::Edit { table, old, new },
524 },
525 }
526}
527
528// ---------------------------------------------------------------------------
529// Subscriber (receiver — the validation half)
530// ---------------------------------------------------------------------------
531
532/// The subscriber's lifecycle: establish the seq-0 baseline, then accept live increments.
533#[derive(Clone, Copy, Debug)]
534enum Phase {
535 /// Awaiting the seq-0 snapshot batch.
536 Snapshot,
537 /// Baseline established; only incremental batches (seq ≥ 1) are accepted.
538 Live { last_seq: u64 },
539}
540
541/// Receiver side: the protocol state machine that **validates** the envelope and emits
542/// clean ops for the caller (the base-store fold / TS `NormalizedSync`) to apply. It does
543/// not itself hold the base store — it owns only epoch/seq/fp state.
544#[derive(Debug)]
545pub struct NormalizedSubscriber {
546 epoch: u64,
547 normalized_fp: u64,
548 phase: Phase,
549}
550
551impl NormalizedSubscriber {
552 /// Open from a [`NormalizedHello`]: validate the wire-constructed table set before
553 /// fingerprinting it, hard-reject a comparator-contract mismatch (§5.5), and verify
554 /// the advertised fingerprint matches the shipped table set. Apply the seq-0 snapshot
555 /// batch next.
556 pub fn open(hello: &NormalizedHello) -> Result<NormalizedSubscriber, NormalizedProtocolError> {
557 validate_normalized_schema(&hello.tables)
558 .map_err(NormalizedProtocolError::InvalidSchema)?;
559 if hello.comparator_version != COMPARATOR_VERSION {
560 return Err(NormalizedProtocolError::ComparatorMismatch {
561 expected: COMPARATOR_VERSION,
562 got: hello.comparator_version,
563 });
564 }
565 let computed = normalized_fp(&hello.tables);
566 if computed != hello.normalized_fp {
567 return Err(NormalizedProtocolError::FpMismatch {
568 expected: hello.normalized_fp,
569 got: computed,
570 });
571 }
572 Ok(NormalizedSubscriber {
573 epoch: hello.epoch,
574 normalized_fp: hello.normalized_fp,
575 phase: Phase::Snapshot,
576 })
577 }
578
579 fn check_frame(&self, epoch: u64, fp: u64) -> Result<(), NormalizedProtocolError> {
580 if epoch != self.epoch {
581 return Err(NormalizedProtocolError::EpochMismatch {
582 expected: self.epoch,
583 got: epoch,
584 });
585 }
586 if fp != self.normalized_fp {
587 return Err(NormalizedProtocolError::FpMismatch {
588 expected: self.normalized_fp,
589 got: fp,
590 });
591 }
592 Ok(())
593 }
594
595 /// Apply one batch (the seq-0 snapshot or an incremental batch). Validates epoch +
596 /// fingerprint + strict in-order seq, returning the ops to apply on success. A
597 /// duplicate (already-applied seq) is [`NormalizedApplied::Duplicate`] (no ops); a gap
598 /// is a fatal [`NormalizedProtocolError::Gap`] (re-hydrate under a new epoch).
599 pub fn apply(
600 &mut self,
601 batch: &NormalizedBatch,
602 ) -> Result<NormalizedApplied, NormalizedProtocolError> {
603 self.check_frame(batch.epoch, batch.normalized_fp)?;
604 match &mut self.phase {
605 Phase::Snapshot => {
606 if batch.seq != 0 {
607 // The baseline must come first; a seq ≥ 1 before it means seq 0 was lost.
608 return Err(NormalizedProtocolError::Gap {
609 expected: 0,
610 got: batch.seq,
611 });
612 }
613 self.phase = Phase::Live { last_seq: 0 };
614 Ok(NormalizedApplied::Live(batch.ops.clone()))
615 }
616 Phase::Live { last_seq } => {
617 if batch.seq <= *last_seq {
618 return Ok(NormalizedApplied::Duplicate);
619 }
620 if batch.seq != *last_seq + 1 {
621 return Err(NormalizedProtocolError::Gap {
622 expected: *last_seq + 1,
623 got: batch.seq,
624 });
625 }
626 *last_seq = batch.seq;
627 Ok(NormalizedApplied::Live(batch.ops.clone()))
628 }
629 }
630 }
631}
632
633#[cfg(test)]
634mod projection_tests {
635 use super::*;
636 use rindle::value::{owned_row, OwnedValue as V};
637 use rindle::{table, CaughtChange, CaughtNode};
638
639 fn tbl(name: &str, cols: &[&str], pk: &[u32]) -> TableWireSchema {
640 TableWireSchema {
641 name: name.into(),
642 columns: cols.iter().map(|c| (*c).into()).collect(),
643 primary_key: pk.to_vec(),
644 }
645 }
646
647 fn leaf(row: Vec<V>) -> CaughtNode {
648 CaughtNode {
649 row: owned_row(row),
650 relationships: Default::default(),
651 }
652 }
653
654 fn ints(row: &WireRow) -> Vec<i64> {
655 row.iter()
656 .map(|v| match v {
657 V::Int(i) => *i,
658 other => panic!("expected Int, got {other:?}"),
659 })
660 .collect()
661 }
662
663 // issue(id, title, priority); PK id.
664 fn issue_schema() -> Vec<TableWireSchema> {
665 vec![tbl("issue", &["id", "title", "priority"], &[0])]
666 }
667
668 #[test]
669 fn projected_hello_declares_only_required_columns_with_remapped_pk() {
670 // select priority, order by id → required {id, priority}; title is dropped. Projected
671 // wire columns are in ascending base-index order: [id, priority]; PK remaps to 0.
672 let ast = table("issue")
673 .select("priority")
674 .order_by("id", "asc")
675 .build();
676 let pubr = NormalizedPublisher::new(1, &ast, issue_schema());
677 let hello = pubr.hello();
678 assert_eq!(hello.tables.len(), 1);
679 let t = &hello.tables[0];
680 assert_eq!(&*t.name, "issue");
681 assert_eq!(
682 t.columns.iter().map(|c| &**c).collect::<Vec<_>>(),
683 vec!["id", "priority"]
684 );
685 assert_eq!(t.primary_key, vec![0]);
686 }
687
688 #[test]
689 fn projected_snapshot_rows_drop_unselected_cells() {
690 let ast = table("issue")
691 .select("priority")
692 .order_by("id", "asc")
693 .build();
694 let mut pubr = NormalizedPublisher::new(1, &ast, issue_schema());
695 // The pipeline emits a FULL row; project-at-emit drops `title` (index 1).
696 let batch = pubr.snapshot(
697 &[CaughtChange::Add(leaf(vec![
698 V::Int(1),
699 V::str("a"),
700 V::Int(5),
701 ]))],
702 0,
703 );
704 assert_eq!(batch.ops.len(), 1);
705 match &batch.ops[0] {
706 NormalizedOp::Add { table, row } => {
707 assert_eq!(&**table, "issue");
708 assert_eq!(ints(row), vec![1, 5]); // [id, priority] — title gone
709 }
710 other => panic!("expected Add, got {other:?}"),
711 }
712 }
713
714 #[test]
715 fn projected_edit_rows_are_projected_on_both_sides() {
716 let ast = table("issue")
717 .select("priority")
718 .order_by("id", "asc")
719 .build();
720 let mut pubr = NormalizedPublisher::new(1, &ast, issue_schema());
721 // Seed (snapshot), then edit priority 5→9 — both old/new project to [id, priority].
722 pubr.snapshot(
723 &[CaughtChange::Add(leaf(vec![
724 V::Int(1),
725 V::str("a"),
726 V::Int(5),
727 ]))],
728 0,
729 );
730 let batch = pubr
731 .commit(
732 &[CaughtChange::Edit {
733 old: owned_row(vec![V::Int(1), V::str("a"), V::Int(5)]),
734 row: owned_row(vec![V::Int(1), V::str("a"), V::Int(9)]),
735 }],
736 1,
737 )
738 .expect("non-empty commit");
739 match &batch.ops[0] {
740 NormalizedOp::Edit { table, old, new } => {
741 assert_eq!(&**table, "issue");
742 assert_eq!(ints(old), vec![1, 5]);
743 assert_eq!(ints(new), vec![1, 9]);
744 }
745 other => panic!("expected Edit, got {other:?}"),
746 }
747 }
748
749 #[test]
750 fn empty_publisher_commit_consumes_no_seq() {
751 let ast = table("issue").order_by("id", "asc").build();
752 let mut pubr = NormalizedPublisher::new(1, &ast, issue_schema());
753 assert_eq!(pubr.snapshot(&[], 0).seq, 0);
754 assert!(pubr.commit(&[], 1).is_none());
755 assert!(pubr.commit(&[], 2).is_none());
756
757 let emitted = pubr
758 .commit(
759 &[CaughtChange::Add(leaf(vec![
760 V::Int(1),
761 V::str("a"),
762 V::Int(5),
763 ]))],
764 3,
765 )
766 .expect("non-empty commit");
767 assert_eq!(emitted.seq, 1);
768 }
769
770 #[test]
771 fn star_query_is_unprojected_and_byte_identical() {
772 let ast = table("issue").order_by("id", "asc").build();
773 let pubr = NormalizedPublisher::new(1, &ast, issue_schema());
774 let t = &pubr.hello().tables[0];
775 assert_eq!(
776 t.columns.iter().map(|c| &**c).collect::<Vec<_>>(),
777 vec!["id", "title", "priority"]
778 );
779 assert_eq!(t.primary_key, vec![0]);
780 let mut pubr = pubr;
781 let batch = pubr.snapshot(
782 &[CaughtChange::Add(leaf(vec![
783 V::Int(1),
784 V::str("a"),
785 V::Int(5),
786 ]))],
787 0,
788 );
789 match &batch.ops[0] {
790 NormalizedOp::Add { row, .. } => assert_eq!(row.len(), 3), // full row kept
791 other => panic!("expected Add, got {other:?}"),
792 }
793 }
794
795 #[test]
796 fn required_columns_force_in_filter_and_sort_not_just_select() {
797 // select title; where priority > 2; order by id → required {title, priority, id}.
798 let ast = table("issue")
799 .select("title")
800 .where_op("priority", ">", 2)
801 .order_by("id", "asc")
802 .build();
803 let req = required_columns_by_table(&ast);
804 match req.get("issue").expect("issue required") {
805 ReqCols::Names(names) => {
806 let mut got: Vec<&str> = names.iter().map(|n| &**n).collect();
807 got.sort_unstable();
808 assert_eq!(got, vec!["id", "priority", "title"]);
809 }
810 ReqCols::All => panic!("expected a projected (Names) requirement"),
811 }
812 // And the wire schema keeps all three (so a projection that needs every column is full).
813 let pubr = NormalizedPublisher::new(1, &ast, issue_schema());
814 assert_eq!(pubr.hello().tables[0].columns.len(), 3);
815 }
816
817 #[test]
818 fn materialized_relationship_correlation_forces_unselected_parent_column() {
819 // select id; .sub_as(watchers: watch where watch.issuePriority = issue.priority). The
820 // relationship is MATERIALIZED (a slot) — its child IS synced and the client re-runs the
821 // join — so the parent correlation key `priority` MUST be forced into issue's required
822 // set even though the user only selected `id`. Otherwise the client would receive the
823 // child rows but lack the parent key to join them (PROJECTION-SUPPORT-DESIGN.md §3.3).
824 // This is the projection × relationship interaction the rebase first fused; the existing
825 // `parity_projection_keeps_relationships` correlates on `id` (already selected), so it
826 // never exercises the force-an-UNSELECTED-column path (`collect_required` slot walk).
827 let ast = table("issue")
828 .select("id")
829 .sub_as("watchers", |row| {
830 table("watch").r#where("issuePriority", row.col("priority"))
831 })
832 .order_by("id", "asc")
833 .build();
834 let req = required_columns_by_table(&ast);
835 match req.get("issue").expect("issue required") {
836 ReqCols::Names(names) => {
837 assert!(
838 names.iter().any(|n| &**n == "priority"),
839 "the relationship correlation column `priority` must be forced in despite \
840 `select id`, got {names:?}"
841 );
842 assert!(
843 names.iter().any(|n| &**n == "id"),
844 "the selected pk `id` is present, got {names:?}"
845 );
846 }
847 ReqCols::All => panic!("expected a projected (Names) requirement, not All"),
848 }
849 // The child `watch` is synced too (it has a slot), so it appears in the required map.
850 assert!(
851 req.contains_key("watch"),
852 "the materialized child is synced: {req:?}"
853 );
854 }
855
856 #[test]
857 fn nonmaterialized_exists_is_server_side_and_syncs_no_child() {
858 // A bare top-level `whereExists` (semi-join, NOT flipped/materialized) is evaluated
859 // entirely server-side: it produces no query-local slot, its child table is NOT synced,
860 // and the client trusts the server-filtered parent membership without re-evaluating it.
861 // Therefore the EXISTS correlation column is intentionally NOT forced into the projected
862 // wire — `priority` here stays dropped. This characterizes WHY the correlation column is
863 // absent (a future change must not "fix" it by force-syncing an unsynced child's key).
864 let ast = table("issue")
865 .select("id")
866 .where_exists(|row| table("comment").r#where("issueID", row.col("priority")))
867 .order_by("id", "asc")
868 .build();
869 assert!(
870 rindle::query_local_slot_names(&ast).is_empty(),
871 "a non-materialized EXISTS creates no slot"
872 );
873 let mut needed = BTreeSet::new();
874 collect_tables(&table_tree(&ast), &mut needed);
875 assert_eq!(
876 needed.iter().map(|t| &**t).collect::<Vec<_>>(),
877 vec!["issue"],
878 "only the root table syncs; the EXISTS child is server-side-only"
879 );
880 let req = required_columns_by_table(&ast);
881 match req.get("issue").expect("issue required") {
882 ReqCols::Names(names) => assert!(
883 !names.iter().any(|n| &**n == "priority"),
884 "a server-side EXISTS does not force its correlation column into the wire: {names:?}"
885 ),
886 ReqCols::All => panic!("expected a projected (Names) requirement, not All"),
887 }
888 }
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894
895 fn tbl(name: &str, cols: &[&str], pk: &[u32]) -> TableWireSchema {
896 TableWireSchema {
897 name: name.into(),
898 columns: cols.iter().map(|c| (*c).into()).collect(),
899 primary_key: pk.to_vec(),
900 }
901 }
902
903 #[test]
904 fn fp_is_deterministic_and_order_independent_after_sort() {
905 let a = vec![
906 tbl("issues", &["id", "title"], &[0]),
907 tbl("comments", &["id", "issue_id", "body"], &[0]),
908 ];
909 let mut b = a.clone();
910 b.reverse();
911 // Sort both (as the publisher does) → identical fingerprint.
912 let mut a_sorted = a.clone();
913 a_sorted.sort_by(|x, y| x.name.cmp(&y.name));
914 let mut b_sorted = b;
915 b_sorted.sort_by(|x, y| x.name.cmp(&y.name));
916 assert_eq!(normalized_fp(&a_sorted), normalized_fp(&b_sorted));
917 }
918
919 #[test]
920 fn fp_detects_drift() {
921 let base = vec![tbl("issues", &["id", "title"], &[0])];
922 let base_fp = normalized_fp(&base);
923 // Column rename.
924 assert_ne!(
925 normalized_fp(&[tbl("issues", &["id", "name"], &[0])]),
926 base_fp
927 );
928 // PK change.
929 assert_ne!(
930 normalized_fp(&[tbl("issues", &["id", "title"], &[1])]),
931 base_fp
932 );
933 // Added column.
934 assert_ne!(
935 normalized_fp(&[tbl("issues", &["id", "title", "open"], &[0])]),
936 base_fp
937 );
938 }
939
940 fn mk_batch(epoch: u64, seq: u64, fp: u64) -> NormalizedBatch {
941 NormalizedBatch {
942 epoch,
943 seq,
944 cv: seq, // any monotone stand-in; the subscriber doesn't validate cv
945 normalized_fp: fp,
946 ops: vec![],
947 }
948 }
949
950 fn open_sub() -> (NormalizedSubscriber, u64) {
951 let tables = vec![tbl("issues", &["id", "title"], &[0])];
952 let fp = normalized_fp(&tables);
953 let hello = NormalizedHello {
954 epoch: 7,
955 comparator_version: COMPARATOR_VERSION,
956 tables,
957 normalized_fp: fp,
958 };
959 (NormalizedSubscriber::open(&hello).unwrap(), fp)
960 }
961
962 #[test]
963 fn subscriber_enforces_order_dedup_and_gap() {
964 let (mut sub, fp) = open_sub();
965 // Baseline (seq 0), then 1, 2.
966 assert!(matches!(
967 sub.apply(&mk_batch(7, 0, fp)).unwrap(),
968 NormalizedApplied::Live(_)
969 ));
970 assert!(matches!(
971 sub.apply(&mk_batch(7, 1, fp)).unwrap(),
972 NormalizedApplied::Live(_)
973 ));
974 // Re-delivery of seq 1 ⇒ idempotent duplicate.
975 assert!(matches!(
976 sub.apply(&mk_batch(7, 1, fp)).unwrap(),
977 NormalizedApplied::Duplicate
978 ));
979 assert!(matches!(
980 sub.apply(&mk_batch(7, 2, fp)).unwrap(),
981 NormalizedApplied::Live(_)
982 ));
983 // A skip to seq 4 ⇒ fatal gap (expected 3).
984 assert_eq!(
985 sub.apply(&mk_batch(7, 4, fp)).unwrap_err(),
986 NormalizedProtocolError::Gap {
987 expected: 3,
988 got: 4
989 }
990 );
991 }
992
993 #[test]
994 fn subscriber_rejects_stale_epoch_and_comparator() {
995 let (mut sub, fp) = open_sub();
996 sub.apply(&mk_batch(7, 0, fp)).unwrap();
997 // A stale-epoch frame is rejected (the re-hydrate guard, §5.3).
998 assert_eq!(
999 sub.apply(&mk_batch(6, 1, fp)).unwrap_err(),
1000 NormalizedProtocolError::EpochMismatch {
1001 expected: 7,
1002 got: 6
1003 }
1004 );
1005 // A comparator mismatch is refused at open.
1006 let hello = NormalizedHello {
1007 epoch: 1,
1008 comparator_version: COMPARATOR_VERSION + 1,
1009 tables: vec![tbl("issues", &["id"], &[0])],
1010 normalized_fp: 0,
1011 };
1012 assert!(matches!(
1013 NormalizedSubscriber::open(&hello),
1014 Err(NormalizedProtocolError::ComparatorMismatch { .. })
1015 ));
1016 }
1017
1018 #[test]
1019 fn subscriber_rejects_malformed_schema_before_fingerprinting() {
1020 let hello = NormalizedHello {
1021 epoch: 1,
1022 comparator_version: COMPARATOR_VERSION,
1023 tables: vec![tbl("issues", &["id"], &[99])],
1024 // The malformed schema cannot be fingerprinted safely. `open` must reject it
1025 // before `normalized_fp` indexes the attacker-controlled PK.
1026 normalized_fp: 0,
1027 };
1028 assert_eq!(
1029 NormalizedSubscriber::open(&hello).unwrap_err(),
1030 NormalizedProtocolError::InvalidSchema(NormalizedSchemaError {
1031 table_index: 0,
1032 kind: NormalizedSchemaErrorKind::PrimaryKeyOutOfBounds,
1033 })
1034 );
1035
1036 let hello = NormalizedHello {
1037 tables: vec![tbl("issues", &["id"], &[])],
1038 ..hello
1039 };
1040 assert_eq!(
1041 NormalizedSubscriber::open(&hello).unwrap_err(),
1042 NormalizedProtocolError::InvalidSchema(NormalizedSchemaError {
1043 table_index: 0,
1044 kind: NormalizedSchemaErrorKind::EmptyPrimaryKey,
1045 })
1046 );
1047
1048 let hello = NormalizedHello {
1049 tables: vec![tbl("issues", &["id"], &[0]), tbl("issues", &["id"], &[0])],
1050 ..hello
1051 };
1052 assert_eq!(
1053 NormalizedSubscriber::open(&hello).unwrap_err(),
1054 NormalizedProtocolError::InvalidSchema(NormalizedSchemaError {
1055 table_index: 1,
1056 kind: NormalizedSchemaErrorKind::TablesNotSortedByUniqueName,
1057 })
1058 );
1059 }
1060}