Rindle docs and package mapSkip to main content

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}