Rindle docs and package mapSkip to main content

rindle/
wire_schema.rs

1//! The view schema on the wire + a content fingerprint
2//! (`FLAT-CHANGES-DESIGN.md` §5.2, §5.5).
3//!
4//! [`WireSchema`] is the hierarchical view [`Schema`] reduced to exactly what a
5//! receiver needs to reconstruct the tree, shipped **once per subscription**: per
6//! level the ordered column **names** (wire rows are positional against them), the
7//! `primary_key`, the **resolved + PK-completed** `sort` (the comparator input, §4),
8//! the `singular` presentation flag, and the relationships **in slot order** — an
9//! out-of-view / gating slot (no child schema) has `child: None`.
10//!
11//! [`schema_fp`] is a content fingerprint (FNV-1a 64 over a canonical, length-prefixed
12//! byte stream that resolves the PK and `sort` to column **names** and recurses into
13//! child schemas). It is meant to be stamped on every batch so a receiver rejects a
14//! batch whose schema drifted from the one it subscribed with (§5.5). FNV-1a is chosen
15//! over `std`'s `DefaultHasher` precisely because the fingerprint is a **wire** value:
16//! it must be byte-for-byte reproducible across processes, Rust versions, and host
17//! languages, which `DefaultHasher` does not guarantee.
18//!
19//! [`COMPARATOR_VERSION`] is the orthogonal second versioning axis (§5.5): the
20//! `compare_values`/`compare_rows` *algorithm* contract (§4). A receiver hard-rejects a
21//! mismatch — it is a code contract, not data, so a content hash cannot cover it.
22
23use crate::value::{compare_values, OwnedValue, RelDef, Schema};
24
25/// The `compare_values`/`compare_rows` algorithm-contract version (§4/§5.5). Bump this
26/// whenever the total order changes (null handling, float/`total_cmp`, the bytewise
27/// `BINARY` string order, cross-type rules). A receiver MUST refuse a subscription
28/// whose `comparator_version` differs — its reconstruction would silently corrupt.
29///
30/// v2 (design 226 Stage B): mixed `Int`/`Float` comparison became exact
31/// ([`compare_int_f64`](crate::value::compare_int_f64)) instead of `as f64`
32/// widening. Below 2^53 the order is bit-identical to v1; the bump is the accepted
33/// hard fleet break (226 §5.2) — no negotiation or compatibility window.
34pub const COMPARATOR_VERSION: u32 = 2;
35
36/// The wire form of a [`ScalarProjection`](crate::value::ScalarProjection)
37/// (`REDUCE-DESIGN.md` §9): the **child** column to surface as a scalar plus the
38/// empty-relationship identity, carried so a receiver reproduces the unwrap at read
39/// time. The identity rides the wire for presentation only — it is deliberately **not**
40/// in the fingerprint (a bare JS cell can't reproduce a value's `Int`/`Float`/`Json`
41/// variant byte-for-byte; the projected-column name and the child schema already capture
42/// the structural drift, see `hash_level`).
43#[cfg_attr(
44    any(feature = "testkit", feature = "serde"),
45    derive(serde::Serialize, serde::Deserialize)
46)]
47#[derive(Clone, Debug)]
48pub struct WireProjection {
49    pub col: u32,
50    pub identity: OwnedValue,
51}
52
53// `OwnedValue` has no derived `PartialEq` (it would invite the wrong comparator), so
54// spell projection equality via `compare_values` (the wire comparator). `Eq` is the
55// marker over it — identities are concrete cells (no NaN), so it is well-formed.
56impl PartialEq for WireProjection {
57    fn eq(&self, other: &Self) -> bool {
58        self.col == other.col
59            && compare_values(self.identity.as_ref(), other.identity.as_ref())
60                == std::cmp::Ordering::Equal
61    }
62}
63impl Eq for WireProjection {}
64
65/// A relationship slot on a [`WireSchema`]: its name, its slot index, either the
66/// child level's schema (in-view) or `None` (gating / out-of-view — the receiver's
67/// in-view gate drops `Child` changes addressed at it, `FLAT-CHANGES-DESIGN.md` §6),
68/// and an optional scalar-projection annotation (`REDUCE-DESIGN.md` §9).
69#[cfg_attr(
70    any(feature = "testkit", feature = "serde"),
71    derive(serde::Serialize, serde::Deserialize)
72)]
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub struct WireRel {
75    pub name: Box<str>,
76    pub slot: u32,
77    pub child: Option<WireSchema>,
78    /// `Some` ⇒ a scalar-projected relationship aggregate; the receiver unwraps the
79    /// one-row child into a scalar field. `None` for an ordinary relationship.
80    pub project: Option<WireProjection>,
81}
82
83/// One level of the hierarchical view schema, wire-shaped. See the module docs.
84#[cfg_attr(
85    any(feature = "testkit", feature = "serde"),
86    derive(serde::Serialize, serde::Deserialize)
87)]
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct WireSchema {
90    /// Column names, in order; wire rows are positional against this list.
91    pub columns: Vec<Box<str>>,
92    /// Primary-key column indices (into `columns`).
93    pub primary_key: Vec<u32>,
94    /// The resolved, PK-completed sort: `(col_idx, ascending)` pairs — the comparator
95    /// input the receiver binary-searches with (`FLAT-CHANGES-DESIGN.md` §4/§5.2).
96    pub sort: Vec<(u32, bool)>,
97    /// `.one()` presentation flag — single object vs array at the result boundary.
98    /// Not used to build the (always-plural) reconstruction tree (§3); carried so a
99    /// receiver can reproduce `.one()` unwrapping at read time.
100    pub singular: bool,
101    /// Relationships in slot order (one entry per declared slot).
102    pub relationships: Vec<WireRel>,
103}
104
105impl WireSchema {
106    /// This schema's content fingerprint (`FLAT-CHANGES-DESIGN.md` §5.5). See [`schema_fp`].
107    pub fn fingerprint(&self) -> SchemaFp {
108        schema_fp(self)
109    }
110}
111
112/// Lower an engine [`Schema`] (the hierarchical view schema) to its [`WireSchema`].
113/// Recurses into each relationship's child schema; a join-only / gating slot (no child
114/// schema) becomes `child: None`.
115pub fn to_wire(schema: &Schema) -> WireSchema {
116    WireSchema {
117        columns: schema.columns.clone(),
118        primary_key: schema.primary_key.iter().map(|&c| c as u32).collect(),
119        sort: schema
120            .sort
121            .iter()
122            .map(|&(c, asc)| (c as u32, asc))
123            .collect(),
124        singular: schema.singular,
125        relationships: schema
126            .relationships
127            .iter()
128            .enumerate()
129            .map(|(slot, rd)| WireRel {
130                name: rd.name.clone(),
131                slot: slot as u32,
132                child: rd.child.as_deref().map(to_wire),
133                project: rd.project.as_ref().map(|p| WireProjection {
134                    col: p.col as u32,
135                    identity: p.identity.clone(),
136                }),
137            })
138            .collect(),
139    }
140}
141
142/// Rebuild an engine [`Schema`] from a [`WireSchema`] (the receiver side). The inverse
143/// of [`to_wire`] for every field the receiver uses (columns, PK, sort, `singular`,
144/// each relationship's name + child schema, and any scalar-projection annotation). A
145/// `child: None` relationship becomes a join-only [`RelDef::new`] (out-of-view) slot.
146pub fn to_schema(ws: &WireSchema) -> Schema {
147    let cols: Vec<&str> = ws.columns.iter().map(|c| &**c).collect();
148    let pk: Vec<usize> = ws.primary_key.iter().map(|&c| c as usize).collect();
149    let sort: Vec<(usize, bool)> = ws.sort.iter().map(|&(c, asc)| (c as usize, asc)).collect();
150    let mut schema = Schema::new(cols, pk, sort);
151    schema.singular = ws.singular;
152    if !ws.relationships.is_empty() {
153        let rels: Vec<RelDef> = ws
154            .relationships
155            .iter()
156            .map(|r| match &r.child {
157                Some(child) => {
158                    let rd = RelDef::related(&r.name, to_schema(child));
159                    match &r.project {
160                        Some(p) => rd.project_scalar(p.col as usize, p.identity.clone()),
161                        None => rd,
162                    }
163                }
164                None => RelDef::new(&r.name),
165            })
166            .collect();
167        schema = schema.with_relationships(rels);
168    }
169    schema
170}
171
172/// A content fingerprint of a resolved [`WireSchema`] (`FLAT-CHANGES-DESIGN.md` §5.5).
173/// FNV-1a 64. Serializes as its `u64`; `Display`s as zero-padded hex.
174#[cfg_attr(
175    any(feature = "testkit", feature = "serde"),
176    derive(serde::Serialize, serde::Deserialize)
177)]
178#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
179pub struct SchemaFp(pub u64);
180
181impl std::fmt::Display for SchemaFp {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        write!(f, "{:016x}", self.0)
184    }
185}
186
187/// FNV-1a 64 accumulator. The exact, length-prefixed byte protocol below is the wire
188/// contract — any receiver must hash identically to compare fingerprints.
189struct Fnv(u64);
190
191impl Fnv {
192    fn new() -> Fnv {
193        Fnv(0xcbf29ce484222325) // FNV-1a 64 offset basis
194    }
195    #[inline]
196    fn byte(&mut self, b: u8) {
197        self.0 ^= b as u64;
198        self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3); // FNV-1a 64 prime
199    }
200    fn bytes(&mut self, bs: &[u8]) {
201        for &b in bs {
202            self.byte(b);
203        }
204    }
205    fn u8(&mut self, v: u8) {
206        self.byte(v);
207    }
208    fn u32(&mut self, v: u32) {
209        self.bytes(&v.to_le_bytes());
210    }
211    /// Length-prefixed string — disambiguates concatenations (`"ab"+"c"` ≠ `"a"+"bc"`).
212    fn s(&mut self, s: &str) {
213        self.u32(s.len() as u32);
214        self.bytes(s.as_bytes());
215    }
216}
217
218/// Fingerprint a resolved [`WireSchema`]. The PK and `sort` are hashed by column
219/// **name** (resolved through `columns`), so the fingerprint is a semantic identity
220/// independent of any internal `ColId` numbering (§5.5).
221pub fn schema_fp(ws: &WireSchema) -> SchemaFp {
222    let mut h = Fnv::new();
223    hash_level(&mut h, ws);
224    SchemaFp(h.0)
225}
226
227fn hash_level(h: &mut Fnv, ws: &WireSchema) {
228    h.u8(b'S');
229    h.u32(ws.columns.len() as u32);
230    for c in &ws.columns {
231        h.s(c);
232    }
233    // PK + sort resolved to NAMES.
234    h.u32(ws.primary_key.len() as u32);
235    for &pk in &ws.primary_key {
236        h.s(&ws.columns[pk as usize]);
237    }
238    h.u32(ws.sort.len() as u32);
239    for &(c, asc) in &ws.sort {
240        h.s(&ws.columns[c as usize]);
241        h.u8(asc as u8);
242    }
243    h.u8(ws.singular as u8);
244    // Relationships in slot order: name + (child level recursively | gating marker).
245    h.u32(ws.relationships.len() as u32);
246    for r in &ws.relationships {
247        h.s(&r.name);
248        match &r.child {
249            Some(child) => {
250                h.u8(1);
251                hash_level(h, child);
252                // Scalar-projection marker (`REDUCE-DESIGN.md` §9): a presence byte, then
253                // — when present — the projected column's NAME in the child schema
254                // (semantic identity, index-independent, matching PK/sort). The empty
255                // identity value is NOT hashed: a bare JS cell can't reproduce its type
256                // variant byte-for-byte, and the projected-column name + child schema
257                // already pin the meaningful drift.
258                match &r.project {
259                    Some(p) => {
260                        h.u8(1);
261                        h.s(&child.columns[p.col as usize]);
262                    }
263                    None => h.u8(0),
264                }
265            }
266            None => h.u8(0),
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::value::{RelDef, Schema};
275
276    fn comment_schema() -> Schema {
277        Schema::new(vec!["id", "issue", "val"], vec![0], vec![(0, true)])
278    }
279    fn issue_schema() -> Schema {
280        Schema::new(vec!["id", "val"], vec![0], vec![(1, true), (0, true)]).with_relationships(
281            vec![
282                RelDef::related("comments", comment_schema()),
283                RelDef::new("gate"),
284            ],
285        )
286    }
287
288    #[test]
289    fn to_wire_shapes_columns_sort_and_relationships() {
290        let w = to_wire(&issue_schema());
291        assert_eq!(
292            w.columns.iter().map(|c| &**c).collect::<Vec<_>>(),
293            vec!["id", "val"]
294        );
295        assert_eq!(w.primary_key, vec![0]);
296        assert_eq!(w.sort, vec![(1, true), (0, true)]); // resolved sort: val asc, id asc
297        assert!(!w.singular);
298        assert_eq!(w.relationships.len(), 2);
299        // In-view "comments" carries a child schema; gating "gate" does not.
300        assert_eq!(&*w.relationships[0].name, "comments");
301        assert_eq!(w.relationships[0].slot, 0);
302        assert!(w.relationships[0].child.is_some());
303        assert_eq!(&*w.relationships[1].name, "gate");
304        assert!(w.relationships[1].child.is_none());
305        // Child level resolved.
306        let child = w.relationships[0].child.as_ref().unwrap();
307        assert_eq!(child.sort, vec![(0, true)]);
308        assert!(child.relationships.is_empty());
309    }
310
311    #[test]
312    fn schema_round_trips_through_wire() {
313        let w = to_wire(&issue_schema());
314        let rebuilt = to_schema(&w);
315        // The wire form of the rebuilt schema is identical (every field the receiver
316        // uses survives Schema -> WireSchema -> Schema).
317        assert_eq!(to_wire(&rebuilt), w);
318    }
319
320    #[test]
321    fn fingerprint_is_deterministic_and_round_trip_stable() {
322        let w = to_wire(&issue_schema());
323        assert_eq!(schema_fp(&w), schema_fp(&w));
324        // Stable across the Schema round-trip.
325        assert_eq!(schema_fp(&to_wire(&to_schema(&w))), schema_fp(&w));
326    }
327
328    #[test]
329    fn fingerprint_detects_drift() {
330        let base = schema_fp(&to_wire(&issue_schema()));
331
332        // Column rename.
333        let renamed = Schema::new(vec!["id", "value"], vec![0], vec![(1, true), (0, true)])
334            .with_relationships(vec![
335                RelDef::related("comments", comment_schema()),
336                RelDef::new("gate"),
337            ]);
338        assert_ne!(schema_fp(&to_wire(&renamed)), base, "column rename");
339
340        // Sort direction change.
341        let resorted = Schema::new(vec!["id", "val"], vec![0], vec![(1, false), (0, true)])
342            .with_relationships(vec![
343                RelDef::related("comments", comment_schema()),
344                RelDef::new("gate"),
345            ]);
346        assert_ne!(schema_fp(&to_wire(&resorted)), base, "sort dir change");
347
348        // Singular flip.
349        let mut singular = issue_schema();
350        singular.singular = true;
351        assert_ne!(schema_fp(&to_wire(&singular)), base, "singular flip");
352
353        // Relationship in-view → gating (drop the child schema).
354        let gated = Schema::new(vec!["id", "val"], vec![0], vec![(1, true), (0, true)])
355            .with_relationships(vec![RelDef::new("comments"), RelDef::new("gate")]);
356        assert_ne!(schema_fp(&to_wire(&gated)), base, "rel in-view->gating");
357
358        // Child schema drift (rename a child column).
359        let child_drift = Schema::new(vec!["id", "val"], vec![0], vec![(1, true), (0, true)])
360            .with_relationships(vec![
361                RelDef::related(
362                    "comments",
363                    Schema::new(vec!["id", "issue", "body"], vec![0], vec![(0, true)]),
364                ),
365                RelDef::new("gate"),
366            ]);
367        assert_ne!(
368            schema_fp(&to_wire(&child_drift)),
369            base,
370            "child column rename"
371        );
372    }
373
374    #[cfg(any(feature = "testkit", feature = "serde"))]
375    #[test]
376    fn wire_schema_serde_round_trips() {
377        let w = to_wire(&issue_schema());
378        let json = serde_json::to_value(&w).expect("serialize");
379        let back: WireSchema = serde_json::from_value(json).expect("deserialize");
380        assert_eq!(back, w);
381        assert_eq!(schema_fp(&back), schema_fp(&w));
382    }
383
384    // --- scalar projection (REDUCE-DESIGN.md §9) ---------------------------------
385
386    use crate::value::OwnedValue as V;
387
388    // issue { commentCount: count(comments) } — the aggregate child [issueID, count],
389    // attached as a scalar-projected (.singular) relationship that surfaces col 1
390    // (count) with identity 0 for a childless issue.
391    fn agg_child() -> Schema {
392        let mut s = Schema::new(vec!["issueID", "count"], vec![0], vec![(0, true)]);
393        s.singular = true;
394        s
395    }
396    fn projected_schema() -> Schema {
397        Schema::new(vec!["id", "val"], vec![0], vec![(0, true)]).with_relationships(vec![
398            RelDef::related("commentCount", agg_child()).project_scalar(1, V::Int(0)),
399        ])
400    }
401
402    #[test]
403    fn projection_round_trips_through_wire() {
404        let w = to_wire(&projected_schema());
405        let p = w.relationships[0]
406            .project
407            .as_ref()
408            .expect("projection on wire");
409        assert_eq!(p.col, 1);
410        // The whole Schema -> WireSchema -> Schema -> WireSchema is stable.
411        assert_eq!(to_wire(&to_schema(&w)), w);
412    }
413
414    #[test]
415    fn projection_perturbs_fingerprint() {
416        // A singular-but-not-projected variant (the `.one()` object shape) vs the same
417        // relationship scalar-projected: the projection presence byte + projected column
418        // name must perturb the fingerprint (so a sender↔receiver disagreement is caught).
419        let singular_only = Schema::new(vec!["id", "val"], vec![0], vec![(0, true)])
420            .with_relationships(vec![RelDef::related("commentCount", agg_child())]);
421        assert_ne!(
422            schema_fp(&to_wire(&projected_schema())),
423            schema_fp(&to_wire(&singular_only)),
424            "projection marker must perturb the fingerprint"
425        );
426    }
427
428    #[test]
429    fn fingerprint_detects_projected_column_change() {
430        let base = schema_fp(&to_wire(&projected_schema()));
431        // Project a DIFFERENT child column (issueID, col 0) with the same identity.
432        let other_col = Schema::new(vec!["id", "val"], vec![0], vec![(0, true)])
433            .with_relationships(vec![
434                RelDef::related("commentCount", agg_child()).project_scalar(0, V::Int(0))
435            ]);
436        assert_ne!(
437            schema_fp(&to_wire(&other_col)),
438            base,
439            "projected column name is part of the fingerprint"
440        );
441    }
442
443    #[cfg(any(feature = "testkit", feature = "serde"))]
444    #[test]
445    fn projection_serde_round_trips() {
446        let w = to_wire(&projected_schema());
447        let json = serde_json::to_value(&w).expect("serialize");
448        let back: WireSchema = serde_json::from_value(json).expect("deserialize");
449        assert_eq!(back, w);
450        assert_eq!(schema_fp(&back), schema_fp(&w));
451    }
452}