Rindle docs and package mapSkip to main content

rindle/
js_safe.rs

1//! JS-boundary safe-integer enforcement (productionization 09.8; design 226 Stage A).
2//!
3//! An [`OwnedValue::Int`](crate::value::OwnedValue::Int) crosses every JS boundary as an f64 `number` today (the
4//! single-`number` model, R10). Above `Number.MAX_SAFE_INTEGER` (2^53 − 1) that
5//! conversion collides adjacent values — fatal for PK identity (two distinct ids
6//! become equal in JS). These walkers find the first offending `Int` in each shape
7//! that crosses, so a boundary with `strict_i64` enabled refuses the frame with a
8//! typed error instead of silently rounding. **Mandatory since design 226 Stage C
9//! (§8)** — `int64` columns exist, and every JS boundary (wasm, napi, the daemon's
10//! bare-cell HTTP read wire) runs the walk unconditionally; Stage E adds the bigint
11//! lane and makes it column-aware.
12//!
13//! Note the bound is deliberately **stricter than the round-trip guards** (the CDC
14//! capture guard and the scan-path `check_number_bounds`): 2^54 round-trips
15//! i64 → f64 → i64 exactly, yet is NOT a safe JS integer — its f64 *neighbors* are
16//! more than 1 apart, so distinct nearby i64s would collide. `Float` cells are never
17//! checked: they are f64 already, whatever their magnitude.
18//!
19//! Only the **shape walkers** live here — the ones that need the engine's own change,
20//! flat and view types. The cell-level bound they all bottom out in lives in
21//! `rindle_value::js_safe` and is re-exported below.
22
23use crate::changes::CaughtNode;
24use crate::flat::{FlatChange, FlatOp, WireNode};
25use crate::view::ViewData;
26
27// The cell-level half moved to `rindle-value` — it needs no engine types, and keeping it
28// there is what lets the write plane and the CDC apply plane run the same JS-boundary
29// check without linking the engine. Re-exported so `rindle::js_safe::…` stays one
30// import site for the wasm, napi and daemon boundaries.
31pub use rindle_value::js_safe::unsafe_int_in_cells;
32pub(crate) use rindle_value::js_safe::unsafe_int_in_row;
33
34fn unsafe_int_in_wire_node(node: &WireNode) -> Option<i64> {
35    unsafe_int_in_cells(&node.row).or_else(|| {
36        node.rels
37            .iter()
38            .flat_map(|(_, children)| children.iter())
39            .find_map(unsafe_int_in_wire_node)
40    })
41}
42
43/// The first out-of-safe-range `Int` anywhere in a flat-change batch — path parent
44/// rows, op rows, and every nested [`WireNode`].
45pub fn unsafe_int_in_flat_changes(changes: &[FlatChange]) -> Option<i64> {
46    changes.iter().find_map(|c| {
47        c.path
48            .iter()
49            .find_map(|seg| unsafe_int_in_cells(&seg.parent_row))
50            .or_else(|| match &c.op {
51                FlatOp::Add(node) => unsafe_int_in_wire_node(node),
52                FlatOp::Remove { row } => unsafe_int_in_cells(row),
53                FlatOp::Edit { old, new } => {
54                    unsafe_int_in_cells(old).or_else(|| unsafe_int_in_cells(new))
55                }
56            })
57    })
58}
59
60/// The first out-of-safe-range `Int` in a materialized view tree.
61#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
62pub(crate) fn unsafe_int_in_view_data(data: &ViewData) -> Option<i64> {
63    data.items.iter().find_map(|e| {
64        unsafe_int_in_row(&e.row).or_else(|| e.rels.iter().find_map(unsafe_int_in_view_data))
65    })
66}
67
68/// The first out-of-safe-range `Int` in a one-shot hydrate tree.
69#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
70pub(crate) fn unsafe_int_in_caught_node(node: &CaughtNode) -> Option<i64> {
71    unsafe_int_in_row(&node.row).or_else(|| {
72        node.relationships
73            .values()
74            .flat_map(|v| v.iter())
75            .find_map(unsafe_int_in_caught_node)
76    })
77}
78
79#[cfg(test)]
80mod tests {
81    use std::collections::BTreeMap;
82    use std::sync::Arc;
83
84    use super::*;
85    use crate::flat::PathSeg;
86    use crate::value::{owned_row, OwnedValue, RelId};
87    use crate::view::{Entry, EntryListInner, TxnGen};
88
89    const UNSAFE: i64 = (1_i64 << 53) + 1;
90
91    #[test]
92    fn flat_changes_are_walked_to_every_row_position() {
93        let node = |cell: i64| WireNode {
94            row: vec![OwnedValue::Int(cell)],
95            rels: Vec::new(),
96        };
97        // Offender nested two levels down an Add's child tree.
98        let deep = FlatChange {
99            path: vec![PathSeg {
100                rel: RelId(0),
101                parent_row: vec![OwnedValue::Int(1)],
102            }],
103            op: FlatOp::Add(WireNode {
104                row: vec![OwnedValue::Int(2)],
105                rels: vec![(RelId(0), vec![node(UNSAFE)])],
106            }),
107        };
108        assert_eq!(unsafe_int_in_flat_changes(&[deep]), Some(UNSAFE));
109        // Offender in an Edit's `old` side.
110        let edit = FlatChange {
111            path: Vec::new(),
112            op: FlatOp::Edit {
113                old: vec![OwnedValue::Int(UNSAFE)],
114                new: vec![OwnedValue::Int(3)],
115            },
116        };
117        assert_eq!(unsafe_int_in_flat_changes(&[edit]), Some(UNSAFE));
118        // Offender in a path parent row.
119        let in_path = FlatChange {
120            path: vec![PathSeg {
121                rel: RelId(0),
122                parent_row: vec![OwnedValue::Int(UNSAFE)],
123            }],
124            op: FlatOp::Remove {
125                row: vec![OwnedValue::Int(4)],
126            },
127        };
128        assert_eq!(unsafe_int_in_flat_changes(&[in_path]), Some(UNSAFE));
129        // A clean batch passes.
130        let clean = FlatChange {
131            path: Vec::new(),
132            op: FlatOp::Remove {
133                row: vec![OwnedValue::Int(5), OwnedValue::Float(1e300)],
134            },
135        };
136        assert_eq!(unsafe_int_in_flat_changes(&[clean]), None);
137    }
138
139    #[test]
140    fn view_trees_and_caught_nodes_are_walked_recursively() {
141        let leaf = Arc::new(Entry {
142            row: owned_row(vec![OwnedValue::Int(UNSAFE)]),
143            rc: 1,
144            created: TxnGen(0),
145            id: None,
146            rels: Box::new([]),
147        });
148        let root = Arc::new(Entry {
149            row: owned_row(vec![OwnedValue::Int(1)]),
150            rc: 1,
151            created: TxnGen(0),
152            id: None,
153            rels: Box::new([Arc::new(EntryListInner {
154                created: TxnGen(0),
155                items: vec![leaf],
156            })]),
157        });
158        let data: ViewData = Arc::new(EntryListInner {
159            created: TxnGen(0),
160            items: vec![root],
161        });
162        assert_eq!(unsafe_int_in_view_data(&data), Some(UNSAFE));
163
164        let child = CaughtNode {
165            row: owned_row(vec![OwnedValue::Int(UNSAFE)]),
166            relationships: BTreeMap::new(),
167        };
168        let parent = CaughtNode {
169            row: owned_row(vec![OwnedValue::Int(1)]),
170            relationships: BTreeMap::from([(RelId(0), vec![child])]),
171        };
172        assert_eq!(unsafe_int_in_caught_node(&parent), Some(UNSAFE));
173    }
174}