Skip to main content

ssp2/
render.rs

1//! Canonical JSON debug rendering (SPEC.md §11.1). Non-contractual for the
2//! wire; contractual for golden vectors — rendered values must deep-equal the
3//! committed `.json` files when parsed as JSON.
4
5use serde_json::{Map, Value};
6
7use crate::model::{Frame, Message, MsgKind, OpResult};
8use crate::segment::{Column, ColumnValue, RowsSegment, SparseRow, SparseSlot};
9use crate::util::base64;
10
11fn obj(entries: Vec<(&str, Value)>) -> Value {
12    let mut map = Map::new();
13    for (k, v) in entries {
14        map.insert(k.to_string(), v);
15    }
16    Value::Object(map)
17}
18
19fn scope_map_value(scopes: &[(String, Vec<String>)]) -> Value {
20    let mut map = Map::new();
21    for (k, vs) in scopes {
22        map.insert(
23            k.clone(),
24            Value::Array(vs.iter().map(|v| Value::String(v.clone())).collect()),
25        );
26    }
27    Value::Object(map)
28}
29
30/// Render a decoded message per §11.1 rule 1: one JSON document, frames in
31/// wire order, `END` omitted.
32pub fn render_message(msg: &Message) -> Value {
33    obj(vec![
34        ("magic", Value::from("SSP2")),
35        ("wireVersion", Value::from(msg.wire_version)),
36        (
37            "msgKind",
38            Value::from(match msg.msg_kind {
39                MsgKind::Request => "request",
40                MsgKind::Response => "response",
41            }),
42        ),
43        (
44            "frames",
45            Value::Array(msg.frames.iter().map(render_frame).collect()),
46        ),
47    ])
48}
49
50fn render_frame(frame: &Frame) -> Value {
51    // Rule 2: field order follows the SPEC field tables (JSON object key
52    // order is irrelevant for deep-equality, but we keep it anyway).
53    // Rule 3: absent opt() fields are omitted, never null.
54    let mut entries: Vec<(&str, Value)> = Vec::new();
55    match frame {
56        Frame::ReqHeader {
57            client_id,
58            schema_version,
59            log_epoch,
60        } => {
61            entries.push(("type", Value::from("REQ_HEADER")));
62            entries.push(("clientId", Value::from(client_id.clone())));
63            entries.push(("schemaVersion", Value::from(*schema_version)));
64            if let Some(value) = log_epoch {
65                entries.push(("logEpoch", Value::from(value.clone())));
66            }
67        }
68        Frame::PushCommit {
69            client_commit_id,
70            operations,
71        } => {
72            entries.push(("type", Value::from("PUSH_COMMIT")));
73            entries.push(("clientCommitId", Value::from(client_commit_id.clone())));
74            let ops = operations
75                .iter()
76                .map(|op| {
77                    let mut e: Vec<(&str, Value)> = vec![
78                        ("table", Value::from(op.table.clone())),
79                        ("rowId", Value::from(op.row_id.clone())),
80                        ("op", Value::from(op.op.name())),
81                    ];
82                    if let Some(v) = op.base_version {
83                        e.push(("baseVersion", Value::from(v)));
84                    }
85                    if let Some(p) = &op.payload {
86                        e.push(("payload", Value::from(base64(p))));
87                    }
88                    obj(e)
89                })
90                .collect();
91            entries.push(("operations", Value::Array(ops)));
92        }
93        Frame::PullHeader {
94            limit_commits,
95            limit_snapshot_rows,
96            max_snapshot_pages,
97            accept,
98        } => {
99            entries.push(("type", Value::from("PULL_HEADER")));
100            entries.push(("limitCommits", Value::from(*limit_commits)));
101            entries.push(("limitSnapshotRows", Value::from(*limit_snapshot_rows)));
102            entries.push(("maxSnapshotPages", Value::from(*max_snapshot_pages)));
103            // Rule 5: bitmask fields are not enums; render numerically.
104            entries.push(("accept", Value::from(*accept)));
105        }
106        Frame::Subscription {
107            id,
108            table,
109            scopes,
110            params,
111            cursor,
112            bootstrap_state,
113        } => {
114            entries.push(("type", Value::from("SUBSCRIPTION")));
115            entries.push(("id", Value::from(id.clone())));
116            entries.push(("table", Value::from(table.clone())));
117            entries.push(("scopes", scope_map_value(scopes)));
118            if let Some(p) = params {
119                // Rule 7: json-typed fields embed as parsed JSON.
120                entries.push(("params", p.parse()));
121            }
122            entries.push(("cursor", Value::from(*cursor)));
123            if let Some(b) = bootstrap_state {
124                entries.push(("bootstrapState", b.parse()));
125            }
126        }
127        Frame::RespHeader {
128            required_schema_version,
129            latest_schema_version,
130            log_epoch,
131            reset_required,
132        } => {
133            entries.push(("type", Value::from("RESP_HEADER")));
134            if let Some(v) = required_schema_version {
135                entries.push(("requiredSchemaVersion", Value::from(*v)));
136            }
137            if let Some(v) = latest_schema_version {
138                entries.push(("latestSchemaVersion", Value::from(*v)));
139            }
140            if let Some(value) = log_epoch {
141                entries.push(("logEpoch", Value::from(value.clone())));
142            }
143            if let Some(value) = reset_required {
144                entries.push(("resetRequired", Value::from(*value)));
145            }
146        }
147        Frame::Lease {
148            lease_id,
149            expires_at_ms,
150        } => {
151            entries.push(("type", Value::from("LEASE")));
152            entries.push(("leaseId", Value::from(lease_id.clone())));
153            entries.push(("expiresAtMs", Value::from(*expires_at_ms)));
154        }
155        Frame::PushResult {
156            client_commit_id,
157            status,
158            commit_seq,
159            results,
160        } => {
161            entries.push(("type", Value::from("PUSH_RESULT")));
162            entries.push(("clientCommitId", Value::from(client_commit_id.clone())));
163            entries.push(("status", Value::from(status.name())));
164            if let Some(v) = commit_seq {
165                entries.push(("commitSeq", Value::from(*v)));
166            }
167            entries.push((
168                "results",
169                Value::Array(results.iter().map(render_result).collect()),
170            ));
171        }
172        Frame::PushResultDetails {
173            client_commit_id,
174            entries: details_entries,
175        } => {
176            entries.push(("type", Value::from("PUSH_RESULT_DETAILS")));
177            entries.push(("clientCommitId", Value::from(client_commit_id.clone())));
178            entries.push((
179                "entries",
180                Value::Array(
181                    details_entries
182                        .iter()
183                        .map(|entry| {
184                            obj(vec![
185                                ("opIndex", Value::from(entry.op_index)),
186                                ("details", entry.details.parse()),
187                            ])
188                        })
189                        .collect(),
190                ),
191            ));
192        }
193        Frame::SubStart {
194            id,
195            status,
196            reason_code,
197            effective_scopes,
198            bootstrap,
199        } => {
200            entries.push(("type", Value::from("SUB_START")));
201            entries.push(("id", Value::from(id.clone())));
202            entries.push(("status", Value::from(status.name())));
203            entries.push(("reasonCode", Value::from(reason_code.clone())));
204            entries.push(("effectiveScopes", scope_map_value(effective_scopes)));
205            entries.push(("bootstrap", Value::from(*bootstrap)));
206        }
207        Frame::Commit {
208            commit_seq,
209            created_at_ms,
210            actor_id,
211            tables,
212            changes,
213        } => {
214            entries.push(("type", Value::from("COMMIT")));
215            entries.push(("commitSeq", Value::from(*commit_seq)));
216            entries.push(("createdAtMs", Value::from(*created_at_ms)));
217            entries.push(("actorId", Value::from(actor_id.clone())));
218            entries.push((
219                "tables",
220                Value::Array(tables.iter().map(|t| Value::from(t.clone())).collect()),
221            ));
222            let rendered_changes = changes
223                .iter()
224                .map(|c| {
225                    let mut e: Vec<(&str, Value)> = vec![
226                        ("tableIndex", Value::from(c.table_index)),
227                        ("rowId", Value::from(c.row_id.clone())),
228                        ("op", Value::from(c.op.name())),
229                    ];
230                    if let Some(v) = c.row_version {
231                        e.push(("rowVersion", Value::from(v)));
232                    }
233                    let mut scopes = Map::new();
234                    for (k, v) in &c.scopes {
235                        scopes.insert(k.clone(), Value::from(v.clone()));
236                    }
237                    e.push(("scopes", Value::Object(scopes)));
238                    if let Some(row) = &c.row {
239                        // Rule 4: binary fields render as base64.
240                        e.push(("row", Value::from(base64(row))));
241                    }
242                    obj(e)
243                })
244                .collect();
245            entries.push(("changes", Value::Array(rendered_changes)));
246        }
247        Frame::SegmentRef {
248            segment_id,
249            media_type,
250            table,
251            byte_length,
252            row_count,
253            as_of_commit_seq,
254            scope_digest,
255            row_cursor,
256            next_row_cursor,
257            url,
258            url_expires_at_ms,
259        } => {
260            entries.push(("type", Value::from("SEGMENT_REF")));
261            entries.push(("segmentId", Value::from(segment_id.clone())));
262            entries.push(("mediaType", Value::from(media_type.name())));
263            entries.push(("table", Value::from(table.clone())));
264            entries.push(("byteLength", Value::from(*byte_length)));
265            entries.push(("rowCount", Value::from(*row_count)));
266            entries.push(("asOfCommitSeq", Value::from(*as_of_commit_seq)));
267            entries.push(("scopeDigest", Value::from(scope_digest.clone())));
268            if let Some(v) = row_cursor {
269                entries.push(("rowCursor", Value::from(v.clone())));
270            }
271            if let Some(v) = next_row_cursor {
272                entries.push(("nextRowCursor", Value::from(v.clone())));
273            }
274            if let Some(v) = url {
275                entries.push(("url", Value::from(v.clone())));
276            }
277            if let Some(v) = url_expires_at_ms {
278                entries.push(("urlExpiresAtMs", Value::from(*v)));
279            }
280        }
281        Frame::SegmentInline { payload } => {
282            // Rule 4: the whole payload renders as base64, never as a nested
283            // rule-8 object.
284            entries.push(("type", Value::from("SEGMENT_INLINE")));
285            entries.push(("payload", Value::from(base64(payload))));
286        }
287        Frame::SubEnd {
288            next_cursor,
289            bootstrap_state,
290        } => {
291            entries.push(("type", Value::from("SUB_END")));
292            entries.push(("nextCursor", Value::from(*next_cursor)));
293            if let Some(b) = bootstrap_state {
294                entries.push(("bootstrapState", b.parse()));
295            }
296        }
297        Frame::Error {
298            code,
299            message,
300            category,
301            retryable,
302            recommended_action,
303            details,
304        } => {
305            entries.push(("type", Value::from("ERROR")));
306            entries.push(("code", Value::from(code.clone())));
307            entries.push(("message", Value::from(message.clone())));
308            entries.push(("category", Value::from(category.clone())));
309            entries.push(("retryable", Value::from(*retryable)));
310            entries.push(("recommendedAction", Value::from(recommended_action.clone())));
311            if let Some(d) = details {
312                entries.push(("details", d.parse()));
313            }
314        }
315        Frame::Unknown {
316            frame_type,
317            payload,
318        } => {
319            // Rule 9.
320            entries.push(("type", Value::from("UNKNOWN")));
321            entries.push(("frameType", Value::from(*frame_type)));
322            entries.push(("payload", Value::from(base64(payload))));
323        }
324    }
325    obj(entries)
326}
327
328fn render_result(result: &OpResult) -> Value {
329    match result {
330        OpResult::Applied { op_index } => obj(vec![
331            ("opIndex", Value::from(*op_index)),
332            ("status", Value::from("applied")),
333        ]),
334        OpResult::Conflict {
335            op_index,
336            code,
337            message,
338            server_version,
339            server_row,
340            conflict_columns,
341        } => obj(vec![
342            ("opIndex", Value::from(*op_index)),
343            ("status", Value::from("conflict")),
344            ("code", Value::from(code.clone())),
345            ("message", Value::from(message.clone())),
346            ("serverVersion", Value::from(*server_version)),
347            ("serverRow", Value::from(base64(server_row))),
348            ("conflictColumns", Value::from(base64(conflict_columns))),
349        ]),
350        OpResult::Error {
351            op_index,
352            code,
353            message,
354            retryable,
355        } => obj(vec![
356            ("opIndex", Value::from(*op_index)),
357            ("status", Value::from("error")),
358            ("code", Value::from(code.clone())),
359            ("message", Value::from(message.clone())),
360            ("retryable", Value::from(*retryable)),
361        ]),
362    }
363}
364
365/// Render a rows segment per §11.1 rule 8: row records as
366/// `{"serverVersion":…,"values":{name→value}}`, NULLs as JSON `null`,
367/// `bytes` as base64, `json` columns parsed.
368pub fn render_rows_segment(seg: &RowsSegment) -> Value {
369    let blocks = seg
370        .blocks
371        .iter()
372        .map(|block| {
373            Value::Array(
374                block
375                    .iter()
376                    .map(|row| {
377                        let mut values = Map::new();
378                        for (col, value) in seg.columns.iter().zip(row.values.iter()) {
379                            values.insert(
380                                col.name.clone(),
381                                match value {
382                                    None => Value::Null,
383                                    Some(value) => render_column_value(value),
384                                },
385                            );
386                        }
387                        obj(vec![
388                            ("serverVersion", Value::from(row.server_version)),
389                            ("values", Value::Object(values)),
390                        ])
391                    })
392                    .collect(),
393            )
394        })
395        .collect();
396    obj(vec![
397        ("magic", Value::from("SSG2")),
398        ("formatVersion", Value::from(1)),
399        ("table", Value::from(seg.table.clone())),
400        ("schemaVersion", Value::from(seg.schema_version)),
401        ("columns", render_column_table(&seg.columns)),
402        ("blocks", Value::Array(blocks)),
403    ])
404}
405
406fn render_column_table(columns: &[Column]) -> Value {
407    Value::Array(
408        columns
409            .iter()
410            .map(|c| {
411                obj(vec![
412                    ("name", Value::from(c.name.clone())),
413                    ("type", Value::from(c.ty.name())),
414                    ("nullable", Value::from(c.nullable)),
415                ])
416            })
417            .collect(),
418    )
419}
420
421fn render_column_value(value: &ColumnValue) -> Value {
422    match value {
423        ColumnValue::String(s) => Value::from(s.clone()),
424        ColumnValue::Integer(v) => Value::from(*v),
425        ColumnValue::Float(v) => {
426            serde_json::Number::from_f64(*v).map_or(Value::Null, Value::Number)
427        }
428        ColumnValue::Boolean(v) => Value::from(*v),
429        ColumnValue::Json(j) => j.parse(),
430        ColumnValue::Bytes(b) => Value::from(base64(b)),
431        // §11: blob_ref (tag 7) renders as embedded parsed JSON, like json.
432        ColumnValue::BlobRef(j) => j.parse(),
433        // §11: crdt (tag 8) renders as base64, like bytes.
434        ColumnValue::Crdt(b) => Value::from(base64(b)),
435    }
436}
437
438/// Render a decoded sparse row (SPEC.md §11.1 rule 11): the column table plus
439/// `values` keyed by column name, carrying only the present columns, so an
440/// absent column has no key and a present NULL renders as JSON `null`.
441pub fn render_sparse_row(columns: &[Column], row: &SparseRow) -> Value {
442    let mut values = Map::new();
443    for (col, slot) in columns.iter().zip(row.iter()) {
444        match slot {
445            SparseSlot::Absent => {}
446            SparseSlot::Null => {
447                values.insert(col.name.clone(), Value::Null);
448            }
449            SparseSlot::Value(value) => {
450                values.insert(col.name.clone(), render_column_value(value));
451            }
452        }
453    }
454    obj(vec![
455        ("columns", render_column_table(columns)),
456        ("values", Value::Object(values)),
457    ])
458}