Rindle docs and package mapSkip to main content

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}