rindle_replica/wire_json.rs
1//! The replica's JSON wire surface: the shared write-plane codec re-exported wholesale,
2//! plus the two renderings that read the ENGINE's own types and therefore cannot live in
3//! the write plane — the **flat-change / wire-schema** dialect every native host ships to
4//! its client ([`flat_changes_to_json`], [`wire_schema_to_json`]) and the SSR **assembled
5//! snapshot** ([`assembled_snapshot_to_json`]).
6//!
7//! Both read the engine's change model (`FlatChange`/`WireSchema`, `CaughtChange`/
8//! `CaughtNode`) — the IVM types a `Graph` produces. `rindle-writeplane` is engine-free
9//! by construction (it depends on `rindle-value`, not `rindle`), so this is the lowest
10//! crate that can host them. Everything else in the wire codec still lives in the write
11//! plane and is re-exported below, so every downstream `rindle_replica::wire_json::…`
12//! import site is unchanged.
13//!
14//! ## One dialect, every native home
15//!
16//! The flat-change rendering is **byte-for-byte the wasm `Db`'s** (`rindle/src/wasm/
17//! marshal.rs`): bare cells (`number | string | boolean | null`), camelCase keys,
18//! `{ tag }`-tagged ops — so the SAME `@rindle/client` `ArrayView` folds either backend's
19//! stream. It used to live in the napi addon (`rindle-replica-node/src/marshal.rs`); it
20//! was lifted here (design 410 PR 0) when a SECOND native home — `rindle-mobile`'s Swift
21//! binding — needed the same bytes. Two copies of a wire dialect drift; one function
22//! cannot. The addon's `marshal` now re-exports these, and `rindle-mobile`'s
23//! `tests/json_shape.rs` pins the output against literal strings.
24
25pub use rindle_writeplane::wire_json::*;
26
27use rindle::value::{RelId, Schema};
28use rindle::{CaughtChange, CaughtNode, FlatChange, FlatOp, WireNode, WireRel, WireSchema};
29use serde_json::{json, Map, Value};
30
31fn wire_node_to_json(node: &WireNode) -> Value {
32 let rels: Vec<Value> = node
33 .rels
34 .iter()
35 .map(|(rel, children)| {
36 json!({
37 "rel": rel.0 as f64,
38 "children": children.iter().map(wire_node_to_json).collect::<Vec<_>>(),
39 })
40 })
41 .collect();
42 json!({ "row": wire_row_to_json(&node.row), "rels": rels })
43}
44
45fn flat_change_to_json(c: &FlatChange) -> Value {
46 let path: Vec<Value> = c
47 .path
48 .iter()
49 .map(|seg| json!({ "rel": seg.rel.0 as f64, "parentRow": wire_row_to_json(&seg.parent_row) }))
50 .collect();
51 let op = match &c.op {
52 FlatOp::Add(node) => json!({ "tag": "add", "node": wire_node_to_json(node) }),
53 FlatOp::Remove { row } => json!({ "tag": "remove", "row": wire_row_to_json(row) }),
54 FlatOp::Edit { old, new } => {
55 json!({ "tag": "edit", "old": wire_row_to_json(old), "new": wire_row_to_json(new) })
56 }
57 };
58 json!({ "path": path, "op": op })
59}
60
61/// A batch of flat changes → the JS/Swift-shaped array (the hydrate snapshot, or one
62/// commit's events).
63pub fn flat_changes_to_json(changes: &[FlatChange]) -> Value {
64 Value::Array(changes.iter().map(flat_change_to_json).collect())
65}
66
67fn wire_rel_to_json(r: &WireRel) -> Value {
68 let child = match &r.child {
69 Some(cs) => wire_schema_to_json(cs),
70 None => Value::Null,
71 };
72 // A scalar-projected relationship aggregate (`REDUCE-DESIGN.md` §9) carries
73 // `project: { col, identity }` (identity as a bare cell); `null` otherwise.
74 let project = match &r.project {
75 Some(p) => json!({ "col": p.col as f64, "identity": owned_to_json(&p.identity) }),
76 None => Value::Null,
77 };
78 json!({ "name": r.name.to_string(), "slot": r.slot as f64, "child": child, "project": project })
79}
80
81/// A `WireSchema` → the camelCase object the `ArrayView` builds from (handed once in
82/// `hello`): `{ columns, primaryKey, sort: [col, asc][], singular, relationships }`.
83pub fn wire_schema_to_json(ws: &WireSchema) -> Value {
84 let sort: Vec<Value> = ws
85 .sort
86 .iter()
87 .map(|&(c, asc)| json!([c as f64, asc]))
88 .collect();
89 let mut obj = Map::new();
90 obj.insert(
91 "columns".into(),
92 Value::Array(
93 ws.columns
94 .iter()
95 .map(|c| Value::String(c.to_string()))
96 .collect(),
97 ),
98 );
99 obj.insert(
100 "primaryKey".into(),
101 Value::Array(ws.primary_key.iter().map(|&c| json!(c as f64)).collect()),
102 );
103 obj.insert("sort".into(), Value::Array(sort));
104 obj.insert("singular".into(), Value::Bool(ws.singular));
105 obj.insert(
106 "relationships".into(),
107 Value::Array(ws.relationships.iter().map(wire_rel_to_json).collect()),
108 );
109 Value::Object(obj)
110}
111
112/// [`owned_to_json`] over a flat row's borrowed cell (`rindle::value::Value`).
113fn cell_to_json(v: rindle::value::Value<'_>) -> Value {
114 owned_to_json(&v.to_owned())
115}
116
117/// Render a query's **assembled** view snapshot — the `Add` hydration changes
118/// ([`Graph::hydrate_change_sink`](rindle::graph::Graph)) read for the SSR one-shot — to the flat,
119/// already-joined wire shape (`SSR-DESIGN.md` §3.3). Unlike the normalized per-table ops, the
120/// view is handed back **constructed**: each result node is `{ cols, <rel>: [..] }`, with
121/// relationships nested inline by name, so a stateless API server (which runs no IVM engine)
122/// can use it directly. Cells are keyed by column name and honor the query's projection;
123/// `.one()` levels surface as a single object (or `null`); scalar-projected relationship
124/// aggregates (`countAs`) surface as a single scalar field.
125pub fn assembled_snapshot_to_json(changes: &[CaughtChange], schema: &Schema) -> Value {
126 let rows: Vec<Value> = changes
127 .iter()
128 .filter_map(|ch| match ch {
129 // A hydrate snapshot is `Add`s only; ignore anything else defensively.
130 CaughtChange::Add(node) => Some(assembled_node_to_json(node, schema)),
131 _ => None,
132 })
133 .collect();
134 Value::Array(rows)
135}
136
137fn assembled_node_to_json(node: &CaughtNode, schema: &Schema) -> Value {
138 let mut obj = serde_json::Map::new();
139
140 // Cells: keyed by column name, narrowed to the projection when the query has one
141 // (rows stay full-width internally — read `columns[col]`/`row[col]` per projected col).
142 let mut cols = serde_json::Map::new();
143 let col_ids: Vec<usize> = match &schema.projection {
144 Some(p) => p.clone(),
145 None => (0..schema.columns.len()).collect(),
146 };
147 for col in col_ids {
148 if let (Some(name), Some(val)) = (schema.columns.get(col), node.row.get(col)) {
149 cols.insert(name.to_string(), cell_to_json(val));
150 }
151 }
152 obj.insert("cols".to_string(), Value::Object(cols));
153
154 // In-view relationships, nested inline by name (slot = index into `relationships`).
155 for (slot, rel) in schema.relationships.iter().enumerate() {
156 let children = node.relationships.get(&RelId(slot as u32));
157 let Some(child_schema) = rel.child.as_deref() else {
158 continue; // join-only relationship (no child schema): not in-view, skip
159 };
160 if let Some(proj) = &rel.project {
161 // Scalar-projected aggregate (REDUCE `countAs`): unwrap the one-row child to a
162 // scalar, substituting the identity (e.g. 0) when the parent has no children.
163 let val = children
164 .and_then(|c| c.first())
165 .and_then(|n| n.row.get(proj.col))
166 .map(cell_to_json)
167 .unwrap_or_else(|| owned_to_json(&proj.identity));
168 obj.insert(rel.name.to_string(), val);
169 continue;
170 }
171 let list: Vec<Value> = children
172 .map(|c| {
173 c.iter()
174 .map(|n| assembled_node_to_json(n, child_schema))
175 .collect()
176 })
177 .unwrap_or_default();
178 let value = if child_schema.singular {
179 list.into_iter().next().unwrap_or(Value::Null)
180 } else {
181 Value::Array(list)
182 };
183 obj.insert(rel.name.to_string(), value);
184 }
185
186 Value::Object(obj)
187}