Rindle docs and package mapSkip to main content

rindle_wire/
wire_json.rs

1//! The normalized protocol's JSON wire rendering — **byte-for-byte the shape the
2//! `@rindle/remote` client validates** (bare cells, camelCase keys, lowercase `op` tags,
3//! hex fingerprints, f64-safe counters). Moved here from `rindle-replica` so every party
4//! on the wire shares ONE codec: the napi addon and the Rust daemon's ws front *emit*
5//! with it (via `rindle-replica`'s re-export), and the room — the first Rust *consumer*
6//! of these frames (`RINDLE-REALTIME-DESIGN.md` §3) — *parses* with it. The
7//! `..._from_json` decoders are new with the room's upstream leg; until they landed,
8//! decode existed only in TS (`@rindle/remote/src/normalized.ts`).
9//!
10//! Two conventions are deliberate and load-bearing:
11//!
12//! - **Counters are JS numbers** (`epoch`/`seq`/`cv`/`comparatorVersion` as f64 — the
13//!   emitters write `as f64`, so a Rust producer renders `3` as `3.0` while a JS producer
14//!   writes `3`; the decoders accept both), and **fingerprints are 16-hex-char strings**
15//!   (a u64 would lose precision as a JS number).
16//! - **Cells are bare** (`number | string | boolean | null`): `Int` collapses to a JS
17//!   number and `Json` to its string form on encode, so decode yields `Float`/`Str` —
18//!   the same inference the wasm `Db`'s `js_to_owned_value` applies. The wire is
19//!   value-faithful, not variant-faithful, by design (the engine's single numeric model).
20//!
21//! The serde derives on the protocol types are **not** this wire — they are the in-Rust
22//! round-trip form (oracles, journals). Sockets carry this rendering.
23//!
24//! Shape-only by design: [`normalized_hello_from_json`] checks structure, not semantics —
25//! PK bounds, duplicate tables, and fingerprint verification belong to
26//! `RoomStore::open` (in `rindle-room-core`) / the
27//! [`NormalizedSubscriber`](crate::normalize_protocol::NormalizedSubscriber), which
28//! return the protocol's own errors.
29
30use std::sync::Arc;
31
32use rindle::change::SourceChange;
33use rindle::value::{OwnedRow, OwnedValue};
34use serde_json::{json, Map, Value};
35
36use crate::normalize::NormalizedOp;
37use crate::normalize_protocol::{NormalizedBatch, NormalizedHello, TableWireSchema};
38
39/// `OwnedValue` → a bare JS value. `Int`/`Float` collapse to a JS `number` (the engine's
40/// single numeric model); `Str`/`Json` to a string (a json column is parsed at read time by
41/// the view's typed schema, exactly as on the local path).
42pub fn owned_to_json(v: &OwnedValue) -> Value {
43    match v {
44        // The server projects each query to its own columns at the emit boundary
45        // (PROJECTION-SUPPORT-DESIGN.md §5.2), so a non-selected column is *dropped* from
46        // the wire row — `Absent` never reaches the JSON renderer. Assert that; map to
47        // `null` defensively.
48        OwnedValue::Absent => {
49            debug_assert!(false, "OwnedValue::Absent must never reach the server wire");
50            Value::Null
51        }
52        OwnedValue::Null => Value::Null,
53        OwnedValue::Bool(b) => Value::Bool(*b),
54        OwnedValue::Int(i) => json!(*i as f64),
55        OwnedValue::Float(f) => json!(*f),
56        OwnedValue::Str(s) => Value::String(s.to_string()),
57        OwnedValue::Json(s) => Value::String(s.to_string()),
58    }
59}
60
61/// A bare JS cell → `OwnedValue`, inferring from the runtime JSON type (matches the wasm
62/// `js_to_owned_value`: number→Float always, string→Str, bool→Bool, null→Null). A json
63/// column already crossed as its stringified form, so it lands here as a `String`.
64pub fn json_to_owned(v: &Value) -> OwnedValue {
65    match v {
66        Value::Null => OwnedValue::Null,
67        Value::Bool(b) => OwnedValue::Bool(*b),
68        Value::Number(n) => OwnedValue::Float(n.as_f64().unwrap_or(0.0)),
69        Value::String(s) => OwnedValue::Str(Arc::from(s.as_str())),
70        // Defensive: a non-stringified object/array cell isn't expected on the bare wire.
71        other => OwnedValue::Str(Arc::from(other.to_string().as_str())),
72    }
73}
74
75/// One positional wire row → its JSON array of bare cells.
76pub fn wire_row_to_json(row: &[OwnedValue]) -> Value {
77    Value::Array(row.iter().map(owned_to_json).collect())
78}
79
80/// One JSON array of bare cells → a positional wire row.
81pub fn wire_row_from_json(v: &Value) -> Result<Vec<OwnedValue>, String> {
82    Ok(v.as_array()
83        .ok_or("wire row: not an array")?
84        .iter()
85        .map(json_to_owned)
86        .collect())
87}
88
89/// A `NormalizedOp` → its camelCase JS object: `{ table, op, row | old/new }` (bare cells),
90/// the path-free twin of a flat change (NORMALIZED-CHANGES-DESIGN.md §3).
91pub fn normalized_op_to_json(op: &NormalizedOp) -> Value {
92    match op {
93        NormalizedOp::Add { table, row } => {
94            json!({ "table": table.to_string(), "op": "add", "row": wire_row_to_json(row) })
95        }
96        NormalizedOp::Remove { table, row } => {
97            json!({ "table": table.to_string(), "op": "remove", "row": wire_row_to_json(row) })
98        }
99        NormalizedOp::Edit { table, old, new } => json!({
100            "table": table.to_string(),
101            "op": "edit",
102            "old": wire_row_to_json(old),
103            "new": wire_row_to_json(new),
104        }),
105    }
106}
107
108/// Parse one `{ table, op, … }` wire object into a [`NormalizedOp`]. Note the shape is the
109/// OUTBOUND batch wire — `remove` carries `row` and `edit` carries `old`/`new` — distinct
110/// from the change-source ingest shape (`rindle-replica`'s `mutation_from_json`), where
111/// `remove` carries `old` and `edit`'s new row rides as `row`.
112pub fn normalized_op_from_json(v: &Value) -> Result<NormalizedOp, String> {
113    let obj = v.as_object().ok_or("normalized op: not an object")?;
114    let table: Box<str> = obj
115        .get("table")
116        .and_then(Value::as_str)
117        .ok_or("normalized op missing table")?
118        .into();
119    let row = |key: &str| -> Result<Vec<OwnedValue>, String> {
120        wire_row_from_json(
121            obj.get(key)
122                .ok_or_else(|| format!("normalized op missing {key}"))?,
123        )
124    };
125    match obj.get("op").and_then(Value::as_str) {
126        Some("add") => Ok(NormalizedOp::Add {
127            table,
128            row: row("row")?,
129        }),
130        Some("remove") => Ok(NormalizedOp::Remove {
131            table,
132            row: row("row")?,
133        }),
134        Some("edit") => Ok(NormalizedOp::Edit {
135            table,
136            old: row("old")?,
137            new: row("new")?,
138        }),
139        other => Err(format!("unknown normalized op: {other:?}")),
140    }
141}
142
143/// A `NormalizedBatch` → `{ epoch, seq, cv, normalizedFp, ops }`. The fingerprint is a
144/// hex string (a u64 would lose precision as a JS number); `cv` is the commit version
145/// the optimistic client buffers by (OPTIMISTIC-WRITES-DESIGN.md §8.6).
146pub fn normalized_batch_to_json(b: &NormalizedBatch) -> Value {
147    json!({
148        "epoch": b.epoch as f64,
149        "seq": b.seq as f64,
150        "cv": b.cv as f64,
151        "normalizedFp": format!("{:016x}", b.normalized_fp),
152        "ops": b.ops.iter().map(normalized_op_to_json).collect::<Vec<_>>(),
153    })
154}
155
156/// Parse an `nbatch` frame's `batch` object into a [`NormalizedBatch`].
157pub fn normalized_batch_from_json(v: &Value) -> Result<NormalizedBatch, String> {
158    let obj = v.as_object().ok_or("nbatch: not an object")?;
159    let ops = obj
160        .get("ops")
161        .and_then(Value::as_array)
162        .ok_or("nbatch missing ops")?
163        .iter()
164        .map(normalized_op_from_json)
165        .collect::<Result<Vec<_>, _>>()?;
166    Ok(NormalizedBatch {
167        epoch: counter(obj, "epoch")?,
168        seq: counter(obj, "seq")?,
169        cv: counter(obj, "cv")?,
170        normalized_fp: fingerprint(obj, "normalizedFp")?,
171        ops,
172    })
173}
174
175/// A `NormalizedHello` → `{ epoch, comparatorVersion, tables: [{ name, columns, primaryKey }],
176/// normalizedFp }` — the slim per-table-schema handshake (§3).
177pub fn normalized_hello_to_json(h: &NormalizedHello) -> Value {
178    let tables: Vec<Value> = h
179        .tables
180        .iter()
181        .map(|t| {
182            json!({
183                "name": t.name.to_string(),
184                "columns": t.columns.iter().map(|c| Value::String(c.to_string())).collect::<Vec<_>>(),
185                "primaryKey": t.primary_key.iter().map(|&i| json!(i as f64)).collect::<Vec<_>>(),
186            })
187        })
188        .collect();
189    json!({
190        "epoch": h.epoch as f64,
191        "comparatorVersion": h.comparator_version as f64,
192        "tables": tables,
193        "normalizedFp": format!("{:016x}", h.normalized_fp),
194    })
195}
196
197/// Parse an `nhello` frame's `hello` object into a [`NormalizedHello`] (shape only — see
198/// the module docs for where semantic validation lives).
199pub fn normalized_hello_from_json(v: &Value) -> Result<NormalizedHello, String> {
200    let obj = v.as_object().ok_or("nhello: not an object")?;
201    let tables = obj
202        .get("tables")
203        .and_then(Value::as_array)
204        .ok_or("nhello missing tables")?
205        .iter()
206        .map(table_wire_schema_from_json)
207        .collect::<Result<Vec<_>, _>>()?;
208    let comparator_version = counter(obj, "comparatorVersion")?;
209    let comparator_version = u32::try_from(comparator_version)
210        .map_err(|_| format!("comparatorVersion out of range: {comparator_version}"))?;
211    Ok(NormalizedHello {
212        epoch: counter(obj, "epoch")?,
213        comparator_version,
214        tables,
215        normalized_fp: fingerprint(obj, "normalizedFp")?,
216    })
217}
218
219fn table_wire_schema_from_json(v: &Value) -> Result<TableWireSchema, String> {
220    let obj = v.as_object().ok_or("table schema: not an object")?;
221    let name: Box<str> = obj
222        .get("name")
223        .and_then(Value::as_str)
224        .ok_or("table schema missing name")?
225        .into();
226    let columns = obj
227        .get("columns")
228        .and_then(Value::as_array)
229        .ok_or("table schema missing columns")?
230        .iter()
231        .map(|c| {
232            c.as_str()
233                .map(Box::from)
234                .ok_or_else(|| "table schema: non-string column".to_string())
235        })
236        .collect::<Result<Vec<_>, _>>()?;
237    let primary_key = obj
238        .get("primaryKey")
239        .and_then(Value::as_array)
240        .ok_or("table schema missing primaryKey")?
241        .iter()
242        .map(|i| {
243            let idx = counter_value(i, "primaryKey index")?;
244            u32::try_from(idx).map_err(|_| format!("primaryKey index out of range: {idx}"))
245        })
246        .collect::<Result<Vec<_>, _>>()?;
247    Ok(TableWireSchema {
248        name,
249        columns,
250        primary_key,
251    })
252}
253
254/// One wire counter field: a non-negative integer-valued JS number. Rust producers write
255/// `3.0` (the emitters cast `as f64`), JS producers write `3` — accept both; reject
256/// fractions, negatives, and anything past 2^53 (not exactly representable, so a producer
257/// could never have meant it).
258fn counter(obj: &Map<String, Value>, key: &str) -> Result<u64, String> {
259    counter_value(obj.get(key).ok_or_else(|| format!("missing {key}"))?, key)
260}
261
262fn counter_value(v: &Value, what: &str) -> Result<u64, String> {
263    if let Some(u) = v.as_u64() {
264        return Ok(u);
265    }
266    let f = v.as_f64().ok_or_else(|| format!("{what}: not a number"))?;
267    const MAX_SAFE: f64 = 9007199254740992.0; // 2^53
268    if f.fract() != 0.0 || !(0.0..=MAX_SAFE).contains(&f) {
269        return Err(format!("{what}: not a wire-safe counter: {f}"));
270    }
271    Ok(f as u64)
272}
273
274/// One wire fingerprint field: the emitter's `{:016x}` hex string.
275fn fingerprint(obj: &Map<String, Value>, key: &str) -> Result<u64, String> {
276    let s = obj
277        .get(key)
278        .and_then(Value::as_str)
279        .ok_or_else(|| format!("missing {key}"))?;
280    u64::from_str_radix(s, 16).map_err(|_| format!("{key}: not a hex fingerprint: {s:?}"))
281}
282
283/// A packed engine row → its JSON array of bare cells (the [`wire_row_to_json`] twin
284/// for [`OwnedRow`]).
285pub fn packed_row_to_json(row: &OwnedRow) -> Value {
286    Value::Array(row.cells().map(|v| owned_to_json(&v.to_owned())).collect())
287}
288
289/// One row change in the change-source **ingest** shape — the `changes[]` element
290/// `rindled`'s `/apply-row-change-txn` consumes (`rindle-replica`'s `mutation_from_json`
291/// is the decode dual): `add → {row}`, `remove → {old}`, `edit → {old, row}` where `row`
292/// is the NEW image. Deliberately distinct from [`normalized_op_to_json`] — that is the
293/// OUTBOUND batch wire, where `remove` carries `row` and `edit` carries `new`. The
294/// room's write-behind flush emits this shape (`RINDLE-REALTIME-DESIGN.md` §5.3), with
295/// `old` carrying the **base** image the CAS precondition asserts. NOTE: this emitter
296/// renders cells via [`owned_to_json`], which is FLOAT-FORM for `Int` — it is NOT the
297/// integer-exact ingest codec (`rindle-replica::wire_json::mutation_to_json`, design
298/// 226 Stage D). Room cells are Number-plane today so nothing is lost; unify onto the
299/// exact codec before any exact-int producer feeds this path.
300pub fn row_change_to_json(table: &str, change: &SourceChange) -> Value {
301    match change {
302        SourceChange::Add(row) => {
303            json!({ "table": table, "op": "add", "row": packed_row_to_json(row) })
304        }
305        SourceChange::Remove(row) => {
306            json!({ "table": table, "op": "remove", "old": packed_row_to_json(row) })
307        }
308        SourceChange::Edit { row, old } => json!({
309            "table": table,
310            "op": "edit",
311            "old": packed_row_to_json(old),
312            "row": packed_row_to_json(row),
313        }),
314    }
315}
316
317/// FNV-1a-64 over raw bytes — the flush batch identity (`batch_hash`, §5.3 step 4:
318/// computed once by the emitter over its canonical batch bytes; the authority stores
319/// and *compares* it, never recomputes). Render with `{:016x}`.
320pub fn fnv1a64(bytes: &[u8]) -> u64 {
321    let mut h: u64 = 0xcbf29ce4_84222325; // FNV-1a 64 offset basis
322    for &b in bytes {
323        h ^= b as u64;
324        h = h.wrapping_mul(0x0000_0100_0000_01b3); // FNV-1a 64 prime
325    }
326    h
327}
328
329// ---------------------------------------------------------------------------
330// Direct-to-bytes `nbatch` encoding — the server delivery fan-out hot path
331// ---------------------------------------------------------------------------
332//
333// `RouterSink::batch` (in `rindle-server`) fans one commit's derived delta out to every
334// subscribing connection. The obvious path — build a `serde_json::Value` tree per
335// subscriber (`normalized_batch_to_json`) and `.to_string()` it — pays a fresh `Value` tree
336// (a heap `Value` per cell + a `Map`/`Vec` per op) for every subscriber. [`nbatch_frame`] /
337// [`nbatch_ops_fragment`] instead write the frame DIRECTLY into a byte buffer, no `Value`
338// tree: encode the `ops` array once (outside the router lock), then splice it into each
339// subscriber's cheap scalar envelope.
340//
341// (An earlier revision also memoized the encoded `ops` fragment within a tick to collapse
342// identical-delta fan-out, but an isolated before/after showed no measurable win — delivery
343// is not the drain thread's bottleneck in the measured regimes — so it was dropped for the
344// simpler stateless path. The structural fix for wide identical-delta fan-out is subscriber
345// dedup, RINDLE-SERVER-DESIGN.md §6, which collapses the derive too.)
346//
347// **Byte-for-byte identical** to the `json!` renderer it replaces, by construction: every
348// scalar (counters as f64, cells, strings) is emitted by `serde_json` itself
349// (`to_writer` → `serialize_f64` / `serialize_str`, the same calls `Value::to_string`
350// makes), and object keys are written in the alphabetical order the `BTreeMap`-backed
351// `serde_json::Map` produces. `nbatch_frame_matches_json_macro` pins this against the old
352// renderer across every op/cell shape — a divergence fails there, not in a browser.
353
354/// Append `serde_json`'s rendering of the f64 `f` to `buf`. Delegating to `serde_json`
355/// (not a hand-rolled `ryu`/`write!`) is what makes the output byte-identical to the
356/// `json!(x as f64)` path: `5u64 as f64` → `5.0` (never `5`), non-finite → `null`.
357fn write_f64(buf: &mut Vec<u8>, f: f64) {
358    serde_json::to_writer(&mut *buf, &f).expect("Vec<u8> write is infallible");
359}
360
361/// Append the JSON string form of `s` — quoted and escaped exactly as `serde_json`
362/// escapes it (the same `serialize_str` `Value::String` uses).
363fn write_json_str(buf: &mut Vec<u8>, s: &str) {
364    serde_json::to_writer(&mut *buf, &s).expect("Vec<u8> write is infallible");
365}
366
367/// Append `v` as a 16-char lowercase zero-padded hex run — identical to `{:016x}`, with
368/// no `format!` allocation. Hex digits never need JSON escaping, so the caller wraps it
369/// in quotes directly.
370fn write_hex16(buf: &mut Vec<u8>, mut v: u64) {
371    let mut out = [0u8; 16];
372    for slot in out.iter_mut().rev() {
373        let nibble = (v & 0xf) as u8;
374        *slot = if nibble < 10 {
375            b'0' + nibble
376        } else {
377            b'a' + (nibble - 10)
378        };
379        v >>= 4;
380    }
381    buf.extend_from_slice(&out);
382}
383
384/// One bare cell → its JS value, byte-identical to [`owned_to_json`] + `.to_string()`.
385fn write_cell(buf: &mut Vec<u8>, v: &OwnedValue) {
386    match v {
387        OwnedValue::Absent => {
388            debug_assert!(false, "OwnedValue::Absent must never reach the server wire");
389            buf.extend_from_slice(b"null");
390        }
391        OwnedValue::Null => buf.extend_from_slice(b"null"),
392        OwnedValue::Bool(true) => buf.extend_from_slice(b"true"),
393        OwnedValue::Bool(false) => buf.extend_from_slice(b"false"),
394        // `Int` collapses to a JS number exactly as `json!(*i as f64)` does.
395        OwnedValue::Int(i) => write_f64(buf, *i as f64),
396        OwnedValue::Float(f) => write_f64(buf, *f),
397        OwnedValue::Str(s) | OwnedValue::Json(s) => write_json_str(buf, s),
398    }
399}
400
401/// One positional row → its `[cells…]` array.
402fn write_row(buf: &mut Vec<u8>, row: &[OwnedValue]) {
403    buf.push(b'[');
404    for (i, c) in row.iter().enumerate() {
405        if i > 0 {
406            buf.push(b',');
407        }
408        write_cell(buf, c);
409    }
410    buf.push(b']');
411}
412
413/// One op → its `{…}` object, keys in the alphabetical order `serde_json` emits.
414fn write_op(buf: &mut Vec<u8>, op: &NormalizedOp) {
415    match op {
416        // keys: op, row, table
417        NormalizedOp::Add { table, row } => {
418            buf.extend_from_slice(br#"{"op":"add","row":"#);
419            write_row(buf, row);
420            buf.extend_from_slice(br#","table":"#);
421            write_json_str(buf, table);
422            buf.push(b'}');
423        }
424        NormalizedOp::Remove { table, row } => {
425            buf.extend_from_slice(br#"{"op":"remove","row":"#);
426            write_row(buf, row);
427            buf.extend_from_slice(br#","table":"#);
428            write_json_str(buf, table);
429            buf.push(b'}');
430        }
431        // keys: new, old, op, table
432        NormalizedOp::Edit { table, old, new } => {
433            buf.extend_from_slice(br#"{"new":"#);
434            write_row(buf, new);
435            buf.extend_from_slice(br#","old":"#);
436            write_row(buf, old);
437            buf.extend_from_slice(br#","op":"edit","table":"#);
438            write_json_str(buf, table);
439            buf.push(b'}');
440        }
441    }
442}
443
444/// Encode `ops` as its `[…]` JSON array — the memoizable fragment.
445fn write_ops(buf: &mut Vec<u8>, ops: &[NormalizedOp]) {
446    buf.push(b'[');
447    for (i, op) in ops.iter().enumerate() {
448        if i > 0 {
449            buf.push(b',');
450        }
451        write_op(buf, op);
452    }
453    buf.push(b']');
454}
455
456/// Assemble one full `nbatch` frame for `client_qid`, splicing the already-encoded
457/// `ops_fragment` into the scalar envelope. Byte-identical to the `json!` frame in
458/// `RouterSink::batch`: `{"batch":{"cv":…,"epoch":…,"normalizedFp":"…","ops":…,"seq":…},
459/// "queryId":…,"t":"nbatch"}` (all keys alphabetical).
460pub fn nbatch_frame(client_qid: u64, batch: &NormalizedBatch, ops_fragment: &str) -> String {
461    let mut buf = Vec::with_capacity(ops_fragment.len() + 96);
462    buf.extend_from_slice(br#"{"batch":{"cv":"#);
463    write_f64(&mut buf, batch.cv as f64);
464    buf.extend_from_slice(br#","epoch":"#);
465    write_f64(&mut buf, batch.epoch as f64);
466    buf.extend_from_slice(br#","normalizedFp":""#);
467    write_hex16(&mut buf, batch.normalized_fp);
468    buf.extend_from_slice(br#"","ops":"#);
469    buf.extend_from_slice(ops_fragment.as_bytes());
470    buf.extend_from_slice(br#","seq":"#);
471    write_f64(&mut buf, batch.seq as f64);
472    buf.extend_from_slice(br#"},"queryId":"#);
473    write_f64(&mut buf, client_qid as f64);
474    buf.extend_from_slice(br#","t":"nbatch"}"#);
475    String::from_utf8(buf).expect("nbatch frame is valid UTF-8")
476}
477
478/// Encode `batch.ops` as its `[…]` JSON array — the fragment [`nbatch_frame`] splices into
479/// each subscriber's envelope. `RouterSink::batch` encodes it ONCE per batch, outside the
480/// router lock, then assembles a frame per subscriber (only the envelope's
481/// `queryId`/`seq`/`epoch` differ across a fan-out).
482pub fn nbatch_ops_fragment(batch: &NormalizedBatch) -> String {
483    let mut buf = Vec::new();
484    write_ops(&mut buf, &batch.ops);
485    String::from_utf8(buf).expect("ops fragment is valid UTF-8")
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use rindle::value::OwnedValue as V;
492
493    fn hello() -> NormalizedHello {
494        NormalizedHello {
495            epoch: 3,
496            comparator_version: 1,
497            tables: vec![
498                TableWireSchema {
499                    name: "comment".into(),
500                    columns: vec!["id".into(), "issue_id".into(), "body".into()],
501                    primary_key: vec![0],
502                },
503                TableWireSchema {
504                    name: "issue".into(),
505                    columns: vec!["id".into(), "title".into()],
506                    primary_key: vec![0, 1],
507                },
508            ],
509            normalized_fp: 0xdead_beef_0102_0304,
510        }
511    }
512
513    fn batch() -> NormalizedBatch {
514        NormalizedBatch {
515            epoch: 3,
516            seq: 7,
517            cv: 41,
518            normalized_fp: 0xdead_beef_0102_0304,
519            ops: vec![
520                NormalizedOp::Add {
521                    table: "issue".into(),
522                    row: vec![V::Float(1.0), V::str("a")],
523                },
524                NormalizedOp::Edit {
525                    table: "issue".into(),
526                    old: vec![V::Float(1.0), V::str("a")],
527                    new: vec![V::Float(1.0), V::Null],
528                },
529                NormalizedOp::Remove {
530                    table: "comment".into(),
531                    row: vec![V::Float(9.0), V::Float(1.0), V::Bool(true)],
532                },
533            ],
534        }
535    }
536
537    /// The golden vector: pins camelCase keys, hex fp, and alphabetical serde_json key
538    /// order, so an accidental rename breaks HERE and not in a browser.
539    #[test]
540    fn hello_golden_wire_form() {
541        let json = normalized_hello_to_json(&hello()).to_string();
542        assert_eq!(
543            json,
544            r#"{"comparatorVersion":1.0,"epoch":3.0,"normalizedFp":"deadbeef01020304","tables":[{"columns":["id","issue_id","body"],"name":"comment","primaryKey":[0.0]},{"columns":["id","title"],"name":"issue","primaryKey":[0.0,1.0]}]}"#
545        );
546    }
547
548    /// decode(encode(x)) == x for wire-faithful values (Float/Str/Bool/Null cells — the
549    /// forms decode can produce), and the encoding is stable across a second round trip.
550    #[test]
551    fn hello_and_batch_round_trip() {
552        let h = hello();
553        let hj = normalized_hello_to_json(&h);
554        assert_eq!(normalized_hello_from_json(&hj).unwrap(), h);
555
556        let b = batch();
557        let bj = normalized_batch_to_json(&b);
558        let back = normalized_batch_from_json(&bj).unwrap();
559        // NormalizedBatch has no PartialEq; the wire form IS the contract — compare there.
560        assert_eq!(normalized_batch_to_json(&back), bj);
561        assert_eq!(back.epoch, 3);
562        assert_eq!(back.seq, 7);
563        assert_eq!(back.cv, 41);
564        assert_eq!(back.normalized_fp, 0xdead_beef_0102_0304);
565        assert_eq!(back.ops.len(), 3);
566    }
567
568    /// `Int` and `Json` cells are value-faithful, not variant-faithful: they cross as
569    /// number/string and come back as `Float`/`Str` with the same wire form.
570    #[test]
571    fn int_and_json_cells_collapse_to_wire_forms() {
572        let row = vec![V::Int(5), V::Json(Arc::from(r#"{"k":1}"#))];
573        let j = wire_row_to_json(&row);
574        assert_eq!(j.to_string(), r#"[5.0,"{\"k\":1}"]"#);
575        let back = wire_row_from_json(&j).unwrap();
576        assert!(matches!(back[0], V::Float(f) if f == 5.0));
577        assert!(matches!(&back[1], V::Str(s) if &**s == r#"{"k":1}"#));
578        // And the collapsed row re-encodes identically.
579        assert_eq!(wire_row_to_json(&back), j);
580    }
581
582    /// The direct-to-bytes [`NbatchEncoder`] must be byte-for-byte identical to the
583    /// `json!` frame `RouterSink::batch` used to build — over every op shape, every cell
584    /// variant, edge floats (−0.0, a huge float, a past-2^53 int), and an escapable +
585    /// non-ASCII string. This is the wire-compat contract; a drift breaks a browser.
586    #[test]
587    fn nbatch_frame_matches_json_macro() {
588        let cases = [
589            batch(),
590            // Empty ops (a fold that nets to a bare envelope).
591            NormalizedBatch {
592                epoch: 0,
593                seq: 0,
594                cv: 0,
595                normalized_fp: 0,
596                ops: vec![],
597            },
598            // Every cell variant + escaping/formatting edges.
599            NormalizedBatch {
600                epoch: 2,
601                seq: 9,
602                cv: 100,
603                normalized_fp: 0xff,
604                ops: vec![
605                    NormalizedOp::Add {
606                        table: "w*ird \"tbl\"\n".into(),
607                        row: vec![
608                            V::Null,
609                            V::Bool(false),
610                            V::Int(-7),
611                            V::Float(-0.0),
612                            V::Float(1.5e300),
613                            V::str("héllo \"q\"\t/\\"),
614                            V::Json(Arc::from(r#"{"a":[1,2]}"#)),
615                        ],
616                    },
617                    // A past-2^53 int collapses through `as f64` exactly as the old path did.
618                    NormalizedOp::Remove {
619                        table: "t".into(),
620                        row: vec![V::Int(9_007_199_254_740_993)],
621                    },
622                ],
623            },
624        ];
625        for (i, b) in cases.iter().enumerate() {
626            let qid = (i as u64) * 7 + 3;
627            let expect = json!({
628                "t": "nbatch",
629                "queryId": qid as f64,
630                "batch": normalized_batch_to_json(b),
631            })
632            .to_string();
633            let got = nbatch_frame(qid, b, &nbatch_ops_fragment(b));
634            assert_eq!(got, expect, "frame {i} diverged");
635        }
636    }
637
638    /// A JS producer writes bare integers (`3`, not `3.0`) — both parse.
639    #[test]
640    fn accepts_js_integer_counters() {
641        let b = normalized_batch_from_json(
642            &serde_json::from_str(
643                r#"{"epoch":1,"seq":0,"cv":12,"normalizedFp":"00000000000000ff","ops":[]}"#,
644            )
645            .unwrap(),
646        )
647        .unwrap();
648        assert_eq!((b.epoch, b.seq, b.cv, b.normalized_fp), (1, 0, 12, 0xff));
649
650        let h = normalized_hello_from_json(
651            &serde_json::from_str(
652                r#"{"epoch":1,"comparatorVersion":1,"tables":[],"normalizedFp":"0"}"#,
653            )
654            .unwrap(),
655        )
656        .unwrap();
657        assert_eq!(h.epoch, 1);
658        assert_eq!(h.comparator_version, 1);
659    }
660
661    #[test]
662    fn malformed_frames_error_loudly() {
663        let cases = [
664            // fractional counter
665            r#"{"epoch":1.5,"seq":0,"cv":0,"normalizedFp":"0","ops":[]}"#,
666            // negative counter
667            r#"{"epoch":-1,"seq":0,"cv":0,"normalizedFp":"0","ops":[]}"#,
668            // fp not hex
669            r#"{"epoch":1,"seq":0,"cv":0,"normalizedFp":"zz","ops":[]}"#,
670            // fp as a number
671            r#"{"epoch":1,"seq":0,"cv":0,"normalizedFp":12,"ops":[]}"#,
672            // missing cv
673            r#"{"epoch":1,"seq":0,"normalizedFp":"0","ops":[]}"#,
674            // op with unknown tag
675            r#"{"epoch":1,"seq":0,"cv":0,"normalizedFp":"0","ops":[{"table":"t","op":"upsert","row":[]}]}"#,
676            // edit missing new
677            r#"{"epoch":1,"seq":0,"cv":0,"normalizedFp":"0","ops":[{"table":"t","op":"edit","old":[]}]}"#,
678            // row not an array
679            r#"{"epoch":1,"seq":0,"cv":0,"normalizedFp":"0","ops":[{"table":"t","op":"add","row":5}]}"#,
680            // op missing table
681            r#"{"epoch":1,"seq":0,"cv":0,"normalizedFp":"0","ops":[{"op":"add","row":[]}]}"#,
682        ];
683        for c in cases {
684            let v: Value = serde_json::from_str(c).unwrap();
685            assert!(
686                normalized_batch_from_json(&v).is_err(),
687                "should reject: {c}"
688            );
689        }
690
691        let hellos = [
692            // non-string column
693            r#"{"epoch":1,"comparatorVersion":1,"tables":[{"name":"t","columns":[1],"primaryKey":[0]}],"normalizedFp":"0"}"#,
694            // pk index fractional
695            r#"{"epoch":1,"comparatorVersion":1,"tables":[{"name":"t","columns":["a"],"primaryKey":[0.5]}],"normalizedFp":"0"}"#,
696            // comparatorVersion overflows u32
697            r#"{"epoch":1,"comparatorVersion":4294967296,"tables":[],"normalizedFp":"0"}"#,
698            // tables not an array
699            r#"{"epoch":1,"comparatorVersion":1,"tables":{},"normalizedFp":"0"}"#,
700        ];
701        for c in hellos {
702            let v: Value = serde_json::from_str(c).unwrap();
703            assert!(
704                normalized_hello_from_json(&v).is_err(),
705                "should reject: {c}"
706            );
707        }
708    }
709}