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::{ColumnValue, RowsSegment};
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(1)),
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        } => {
60            entries.push(("type", Value::from("REQ_HEADER")));
61            entries.push(("clientId", Value::from(client_id.clone())));
62            entries.push(("schemaVersion", Value::from(*schema_version)));
63        }
64        Frame::PushCommit {
65            client_commit_id,
66            operations,
67        } => {
68            entries.push(("type", Value::from("PUSH_COMMIT")));
69            entries.push(("clientCommitId", Value::from(client_commit_id.clone())));
70            let ops = operations
71                .iter()
72                .map(|op| {
73                    let mut e: Vec<(&str, Value)> = vec![
74                        ("table", Value::from(op.table.clone())),
75                        ("rowId", Value::from(op.row_id.clone())),
76                        ("op", Value::from(op.op.name())),
77                    ];
78                    if let Some(v) = op.base_version {
79                        e.push(("baseVersion", Value::from(v)));
80                    }
81                    if let Some(p) = &op.payload {
82                        e.push(("payload", Value::from(base64(p))));
83                    }
84                    obj(e)
85                })
86                .collect();
87            entries.push(("operations", Value::Array(ops)));
88        }
89        Frame::PullHeader {
90            limit_commits,
91            limit_snapshot_rows,
92            max_snapshot_pages,
93            accept,
94        } => {
95            entries.push(("type", Value::from("PULL_HEADER")));
96            entries.push(("limitCommits", Value::from(*limit_commits)));
97            entries.push(("limitSnapshotRows", Value::from(*limit_snapshot_rows)));
98            entries.push(("maxSnapshotPages", Value::from(*max_snapshot_pages)));
99            // Rule 5: bitmask fields are not enums; render numerically.
100            entries.push(("accept", Value::from(*accept)));
101        }
102        Frame::Subscription {
103            id,
104            table,
105            scopes,
106            params,
107            cursor,
108            bootstrap_state,
109        } => {
110            entries.push(("type", Value::from("SUBSCRIPTION")));
111            entries.push(("id", Value::from(id.clone())));
112            entries.push(("table", Value::from(table.clone())));
113            entries.push(("scopes", scope_map_value(scopes)));
114            if let Some(p) = params {
115                // Rule 7: json-typed fields embed as parsed JSON.
116                entries.push(("params", p.parse()));
117            }
118            entries.push(("cursor", Value::from(*cursor)));
119            if let Some(b) = bootstrap_state {
120                entries.push(("bootstrapState", b.parse()));
121            }
122        }
123        Frame::RespHeader {
124            required_schema_version,
125            latest_schema_version,
126        } => {
127            entries.push(("type", Value::from("RESP_HEADER")));
128            if let Some(v) = required_schema_version {
129                entries.push(("requiredSchemaVersion", Value::from(*v)));
130            }
131            if let Some(v) = latest_schema_version {
132                entries.push(("latestSchemaVersion", Value::from(*v)));
133            }
134        }
135        Frame::Lease {
136            lease_id,
137            expires_at_ms,
138        } => {
139            entries.push(("type", Value::from("LEASE")));
140            entries.push(("leaseId", Value::from(lease_id.clone())));
141            entries.push(("expiresAtMs", Value::from(*expires_at_ms)));
142        }
143        Frame::PushResult {
144            client_commit_id,
145            status,
146            commit_seq,
147            results,
148        } => {
149            entries.push(("type", Value::from("PUSH_RESULT")));
150            entries.push(("clientCommitId", Value::from(client_commit_id.clone())));
151            entries.push(("status", Value::from(status.name())));
152            if let Some(v) = commit_seq {
153                entries.push(("commitSeq", Value::from(*v)));
154            }
155            entries.push((
156                "results",
157                Value::Array(results.iter().map(render_result).collect()),
158            ));
159        }
160        Frame::SubStart {
161            id,
162            status,
163            reason_code,
164            effective_scopes,
165            bootstrap,
166        } => {
167            entries.push(("type", Value::from("SUB_START")));
168            entries.push(("id", Value::from(id.clone())));
169            entries.push(("status", Value::from(status.name())));
170            entries.push(("reasonCode", Value::from(reason_code.clone())));
171            entries.push(("effectiveScopes", scope_map_value(effective_scopes)));
172            entries.push(("bootstrap", Value::from(*bootstrap)));
173        }
174        Frame::Commit {
175            commit_seq,
176            created_at_ms,
177            actor_id,
178            tables,
179            changes,
180        } => {
181            entries.push(("type", Value::from("COMMIT")));
182            entries.push(("commitSeq", Value::from(*commit_seq)));
183            entries.push(("createdAtMs", Value::from(*created_at_ms)));
184            entries.push(("actorId", Value::from(actor_id.clone())));
185            entries.push((
186                "tables",
187                Value::Array(tables.iter().map(|t| Value::from(t.clone())).collect()),
188            ));
189            let rendered_changes = changes
190                .iter()
191                .map(|c| {
192                    let mut e: Vec<(&str, Value)> = vec![
193                        ("tableIndex", Value::from(c.table_index)),
194                        ("rowId", Value::from(c.row_id.clone())),
195                        ("op", Value::from(c.op.name())),
196                    ];
197                    if let Some(v) = c.row_version {
198                        e.push(("rowVersion", Value::from(v)));
199                    }
200                    let mut scopes = Map::new();
201                    for (k, v) in &c.scopes {
202                        scopes.insert(k.clone(), Value::from(v.clone()));
203                    }
204                    e.push(("scopes", Value::Object(scopes)));
205                    if let Some(row) = &c.row {
206                        // Rule 4: binary fields render as base64.
207                        e.push(("row", Value::from(base64(row))));
208                    }
209                    obj(e)
210                })
211                .collect();
212            entries.push(("changes", Value::Array(rendered_changes)));
213        }
214        Frame::SegmentRef {
215            segment_id,
216            media_type,
217            table,
218            byte_length,
219            row_count,
220            as_of_commit_seq,
221            scope_digest,
222            row_cursor,
223            next_row_cursor,
224            url,
225            url_expires_at_ms,
226        } => {
227            entries.push(("type", Value::from("SEGMENT_REF")));
228            entries.push(("segmentId", Value::from(segment_id.clone())));
229            entries.push(("mediaType", Value::from(media_type.name())));
230            entries.push(("table", Value::from(table.clone())));
231            entries.push(("byteLength", Value::from(*byte_length)));
232            entries.push(("rowCount", Value::from(*row_count)));
233            entries.push(("asOfCommitSeq", Value::from(*as_of_commit_seq)));
234            entries.push(("scopeDigest", Value::from(scope_digest.clone())));
235            if let Some(v) = row_cursor {
236                entries.push(("rowCursor", Value::from(v.clone())));
237            }
238            if let Some(v) = next_row_cursor {
239                entries.push(("nextRowCursor", Value::from(v.clone())));
240            }
241            if let Some(v) = url {
242                entries.push(("url", Value::from(v.clone())));
243            }
244            if let Some(v) = url_expires_at_ms {
245                entries.push(("urlExpiresAtMs", Value::from(*v)));
246            }
247        }
248        Frame::SegmentInline { payload } => {
249            // Rule 4: the whole payload renders as base64, never as a nested
250            // rule-8 object.
251            entries.push(("type", Value::from("SEGMENT_INLINE")));
252            entries.push(("payload", Value::from(base64(payload))));
253        }
254        Frame::SubEnd {
255            next_cursor,
256            bootstrap_state,
257        } => {
258            entries.push(("type", Value::from("SUB_END")));
259            entries.push(("nextCursor", Value::from(*next_cursor)));
260            if let Some(b) = bootstrap_state {
261                entries.push(("bootstrapState", b.parse()));
262            }
263        }
264        Frame::Error {
265            code,
266            message,
267            category,
268            retryable,
269            recommended_action,
270            details,
271        } => {
272            entries.push(("type", Value::from("ERROR")));
273            entries.push(("code", Value::from(code.clone())));
274            entries.push(("message", Value::from(message.clone())));
275            entries.push(("category", Value::from(category.clone())));
276            entries.push(("retryable", Value::from(*retryable)));
277            entries.push(("recommendedAction", Value::from(recommended_action.clone())));
278            if let Some(d) = details {
279                entries.push(("details", d.parse()));
280            }
281        }
282        Frame::Unknown {
283            frame_type,
284            payload,
285        } => {
286            // Rule 9.
287            entries.push(("type", Value::from("UNKNOWN")));
288            entries.push(("frameType", Value::from(*frame_type)));
289            entries.push(("payload", Value::from(base64(payload))));
290        }
291    }
292    obj(entries)
293}
294
295fn render_result(result: &OpResult) -> Value {
296    match result {
297        OpResult::Applied { op_index } => obj(vec![
298            ("opIndex", Value::from(*op_index)),
299            ("status", Value::from("applied")),
300        ]),
301        OpResult::Conflict {
302            op_index,
303            code,
304            message,
305            server_version,
306            server_row,
307        } => obj(vec![
308            ("opIndex", Value::from(*op_index)),
309            ("status", Value::from("conflict")),
310            ("code", Value::from(code.clone())),
311            ("message", Value::from(message.clone())),
312            ("serverVersion", Value::from(*server_version)),
313            ("serverRow", Value::from(base64(server_row))),
314        ]),
315        OpResult::Error {
316            op_index,
317            code,
318            message,
319            retryable,
320        } => obj(vec![
321            ("opIndex", Value::from(*op_index)),
322            ("status", Value::from("error")),
323            ("code", Value::from(code.clone())),
324            ("message", Value::from(message.clone())),
325            ("retryable", Value::from(*retryable)),
326        ]),
327    }
328}
329
330/// Render a rows segment per §11.1 rule 8: row records as
331/// `{"serverVersion":…,"values":{name→value}}`, NULLs as JSON `null`,
332/// `bytes` as base64, `json` columns parsed.
333pub fn render_rows_segment(seg: &RowsSegment) -> Value {
334    let columns = seg
335        .columns
336        .iter()
337        .map(|c| {
338            obj(vec![
339                ("name", Value::from(c.name.clone())),
340                ("type", Value::from(c.ty.name())),
341                ("nullable", Value::from(c.nullable)),
342            ])
343        })
344        .collect();
345    let blocks = seg
346        .blocks
347        .iter()
348        .map(|block| {
349            Value::Array(
350                block
351                    .iter()
352                    .map(|row| {
353                        let mut values = Map::new();
354                        for (col, value) in seg.columns.iter().zip(row.values.iter()) {
355                            values.insert(col.name.clone(), render_column_value(value));
356                        }
357                        obj(vec![
358                            ("serverVersion", Value::from(row.server_version)),
359                            ("values", Value::Object(values)),
360                        ])
361                    })
362                    .collect(),
363            )
364        })
365        .collect();
366    obj(vec![
367        ("magic", Value::from("SSG2")),
368        ("formatVersion", Value::from(1)),
369        ("table", Value::from(seg.table.clone())),
370        ("schemaVersion", Value::from(seg.schema_version)),
371        ("columns", Value::Array(columns)),
372        ("blocks", Value::Array(blocks)),
373    ])
374}
375
376fn render_column_value(value: &Option<ColumnValue>) -> Value {
377    match value {
378        None => Value::Null,
379        Some(ColumnValue::String(s)) => Value::from(s.clone()),
380        Some(ColumnValue::Integer(v)) => Value::from(*v),
381        Some(ColumnValue::Float(v)) => {
382            serde_json::Number::from_f64(*v).map_or(Value::Null, Value::Number)
383        }
384        Some(ColumnValue::Boolean(v)) => Value::from(*v),
385        Some(ColumnValue::Json(j)) => j.parse(),
386        Some(ColumnValue::Bytes(b)) => Value::from(base64(b)),
387        // §11: blob_ref (tag 7) renders as embedded parsed JSON, like json.
388        Some(ColumnValue::BlobRef(j)) => j.parse(),
389        // §11: crdt (tag 8) renders as base64, like bytes.
390        Some(ColumnValue::Crdt(b)) => Value::from(base64(b)),
391    }
392}