Skip to main content

syncular_command/
lib.rs

1//! # syncular-command — one JSON command surface over the Rust client core
2//!
3//! The command router the conformance shim proved (JSON in, JSON out, bytes
4//! as `{"$bytes": hex}`) factored into a transport-agnostic module so BOTH
5//! the stdio conformance shim AND the FFI native core dispatch through the
6//! same code. That keeps a single command surface, conformance-locked via
7//! the shim: whatever the shim exercises, the FFI core inherits.
8//!
9//! The router is generic over the `Transport` seam. The shim binds it to a
10//! stdio host (transport inverted to the harness); the FFI crate binds it to
11//! a real native HTTP+WS transport. Everything host-specific — realtime
12//! notification draining, deferred requests, event queues — stays in each
13//! host; only the pure `method → result` dispatch (and its JSON parsing) is
14//! shared here.
15
16use serde_json::{json, Value};
17use ssp2::segment::{decode_rows_segment, encode_rows_segment};
18use ssp2::{
19    decode_message, encode_message, parse_control, render_message, render_rows_segment,
20    ControlMessage,
21};
22use syncular_client::{
23    ClientLimits, CommandEffects, Mutation, SyncClient, Transport, WindowBase, WindowCoverage,
24};
25
26// -- bytes <-> {"$bytes": hex} (the driver-protocol byte envelope) ----------
27
28pub fn bytes_to_hex(bytes: &[u8]) -> String {
29    syncular_client::values::bytes_to_hex(bytes)
30}
31
32pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
33    syncular_client::values::hex_to_bytes(hex)
34}
35
36pub fn bytes_value(bytes: &[u8]) -> Value {
37    json!({ "$bytes": bytes_to_hex(bytes) })
38}
39
40pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
41    let hex = value
42        .and_then(|v| v.get("$bytes"))
43        .and_then(Value::as_str)
44        .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
45    hex_to_bytes(hex)
46}
47
48/// The `(code, message)` pair the driver protocol carries in an `error`.
49pub type CommandError = (String, String);
50
51/// Parsed side effects of a `create` command that the host must apply to its
52/// own transport/clock (the router stays transport-agnostic). The client is
53/// already installed into the `Option<SyncClient>` slot by `dispatch`.
54#[derive(Debug, Default, Clone)]
55pub struct CreateEffects {
56    /// §5.4 capability the harness/host announced for its endpoints — the
57    /// host sets its transport's `supports_url_fetch` accordingly.
58    pub signed_urls: bool,
59}
60
61fn client_err(message: String) -> CommandError {
62    let code = message
63        .split_once(':')
64        .map(|(candidate, _)| candidate)
65        .filter(|candidate| candidate.starts_with("client."))
66        .unwrap_or("client.failed");
67    (code.to_owned(), message)
68}
69
70fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
71    client
72        .as_mut()
73        .ok_or_else(|| client_err("no client instance created".to_owned()))
74}
75
76pub fn parse_limits(value: Option<&Value>) -> ClientLimits {
77    let mut limits = ClientLimits::default();
78    let Some(object) = value.and_then(Value::as_object) else {
79        return limits;
80    };
81    limits.limit_commits = object
82        .get("limitCommits")
83        .and_then(Value::as_i64)
84        .map(|v| v as i32);
85    limits.limit_snapshot_rows = object
86        .get("limitSnapshotRows")
87        .and_then(Value::as_i64)
88        .map(|v| v as i32);
89    limits.max_snapshot_pages = object
90        .get("maxSnapshotPages")
91        .and_then(Value::as_i64)
92        .map(|v| v as i32);
93    limits.accept = object
94        .get("accept")
95        .and_then(Value::as_u64)
96        .map(|v| v as u8);
97    limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
98    limits
99}
100
101/// §5.11: parse the `encryption` config into the client's key map. Shape:
102/// `{ keys: { "<keyId>": {"$bytes": "<hex>"} } }`. Keys are 32 bytes.
103pub fn parse_encryption(
104    value: &Value,
105) -> Result<syncular_client::values::EncryptionConfig, String> {
106    let mut config = syncular_client::values::EncryptionConfig::default();
107    let Some(keys) = value.get("keys").and_then(Value::as_object) else {
108        return Ok(config);
109    };
110    for (key_id, key_val) in keys {
111        let bytes =
112            value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
113        if bytes.len() != 32 {
114            return Err(format!(
115                "encryption key {key_id:?} must be 32 bytes, got {}",
116                bytes.len()
117            ));
118        }
119        config.keys.insert(key_id.clone(), bytes);
120    }
121    Ok(config)
122}
123
124pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
125    let list = value
126        .and_then(Value::as_array)
127        .ok_or_else(|| "mutations must be a list".to_owned())?;
128    let mut out = Vec::with_capacity(list.len());
129    for entry in list {
130        let op = entry
131            .get("op")
132            .and_then(Value::as_str)
133            .ok_or_else(|| "mutation missing op".to_owned())?;
134        let table = entry
135            .get("table")
136            .and_then(Value::as_str)
137            .ok_or_else(|| "mutation missing table".to_owned())?
138            .to_owned();
139        let base_version = entry.get("baseVersion").and_then(Value::as_i64);
140        match op {
141            "upsert" => {
142                let mut values = entry
143                    .get("values")
144                    .and_then(Value::as_object)
145                    .cloned()
146                    .ok_or_else(|| "upsert missing values".to_owned())?;
147                decode_bigint_members(&mut values)?;
148                out.push(Mutation::Upsert {
149                    table,
150                    values,
151                    base_version,
152                });
153            }
154            "delete" => {
155                let row_id = entry
156                    .get("rowId")
157                    .and_then(Value::as_str)
158                    .ok_or_else(|| "delete missing rowId".to_owned())?
159                    .to_owned();
160                out.push(Mutation::Delete {
161                    table,
162                    row_id,
163                    base_version,
164                });
165            }
166            other => return Err(format!("unknown mutation op {other:?}")),
167        }
168    }
169    Ok(out)
170}
171
172fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
173    for value in values.values_mut() {
174        let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
175            continue;
176        };
177        let integer = decimal
178            .parse::<i64>()
179            .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
180        *value = Value::from(integer);
181    }
182    Ok(())
183}
184
185fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
186    match value {
187        Some(v) => syncular_client::values::json_to_scope_map(v),
188        None => Ok(Vec::new()),
189    }
190}
191
192/// §4.8: parse a window base descriptor `{ table, variable, fixedScopes?,
193/// params? }` from a command's `base` param.
194fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
195    let object = value
196        .and_then(Value::as_object)
197        .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
198    let table = object
199        .get("table")
200        .and_then(Value::as_str)
201        .ok_or_else(|| "window base missing table".to_owned())?
202        .to_owned();
203    let variable = object
204        .get("variable")
205        .and_then(Value::as_str)
206        .ok_or_else(|| "window base missing variable".to_owned())?
207        .to_owned();
208    let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
209    let params = object
210        .get("params")
211        .and_then(Value::as_str)
212        .map(str::to_owned);
213    Ok(WindowBase {
214        table,
215        variable,
216        fixed_scopes,
217        params,
218    })
219}
220
221/// §5.10.5: parse the common `(table, rowId, column, name)` target of a crdt
222/// command. `name` selects the shared type inside the doc (default `"text"`,
223/// matching the TS `YjsColumn.text()` default).
224#[cfg(feature = "crdt-yjs")]
225fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
226    let table = params
227        .get("table")
228        .and_then(Value::as_str)
229        .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
230        .to_owned();
231    let row_id = params
232        .get("rowId")
233        .and_then(Value::as_str)
234        .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
235        .to_owned();
236    let column = params
237        .get("column")
238        .and_then(Value::as_str)
239        .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
240        .to_owned();
241    let name = params
242        .get("name")
243        .and_then(Value::as_str)
244        .unwrap_or("text")
245        .to_owned();
246    Ok((table, row_id, column, name))
247}
248
249/// Dispatch one command against the client instance over `transport`.
250///
251/// The `create` command installs a fresh `SyncClient` into `client` and
252/// returns its parsed `CreateEffects` in the `Ok` result via `effects`; every
253/// other command mutates the existing instance. Errors come back as the
254/// driver-protocol `(code, message)` pair.
255///
256/// Generic over `T: Transport` so the shim (host-inverted transport) and the
257/// FFI core (native HTTP+WS transport) share this exact router.
258pub fn dispatch<T: Transport>(
259    transport: &mut T,
260    client: &mut Option<SyncClient>,
261    effects: &mut CreateEffects,
262    method: &str,
263    params: &Value,
264) -> Result<Value, CommandError> {
265    match method {
266        "create" => {
267            let client_id = params
268                .get("clientId")
269                .and_then(Value::as_str)
270                .map(str::to_owned);
271            let schema = params
272                .get("schema")
273                .ok_or_else(|| client_err("create missing schema".to_owned()))?;
274            let limits = parse_limits(params.get("limits"));
275            // §native: a `dbPath` installs a file-backed rusqlite connection so
276            // native hosts (Tauri plugin, FFI file variant) persist across
277            // restarts; absent it, the default in-memory core (the shim's mode).
278            let mut instance = match params.get("dbPath").and_then(Value::as_str) {
279                Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
280                    .map_err(client_err)?,
281                None => {
282                    SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
283                }
284            };
285            // Harness clock pin (§5.4 expiry runs on the virtual clock).
286            if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
287                instance.set_now_ms(now_ms);
288            }
289            // §5.4 capability of the host endpoint set (accept bit 3) — the
290            // host applies it to its own transport.
291            effects.signed_urls = params
292                .get("signedUrls")
293                .and_then(Value::as_bool)
294                .unwrap_or(false);
295            // §5.11: install client-side encryption keys. Shape:
296            // { encryption: { keys: { "<keyId>": {"$bytes": "<hex>"} } } }.
297            if let Some(enc) = params.get("encryption") {
298                let config = parse_encryption(enc).map_err(client_err)?;
299                instance.set_encryption(config);
300            }
301            *client = Some(instance);
302            Ok(json!({}))
303        }
304        "subscribe" => {
305            let id = params
306                .get("id")
307                .and_then(Value::as_str)
308                .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
309                .to_owned();
310            let table = params
311                .get("table")
312                .and_then(Value::as_str)
313                .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
314                .to_owned();
315            let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
316            let sub_params = params
317                .get("params")
318                .and_then(Value::as_str)
319                .map(str::to_owned);
320            need_client(client)?
321                .subscribe(id, table, scopes, sub_params)
322                .map_err(client_err)?;
323            Ok(json!({}))
324        }
325        "unsubscribe" => {
326            let id = params
327                .get("id")
328                .and_then(Value::as_str)
329                .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
330            need_client(client)?.unsubscribe(id);
331            Ok(json!({}))
332        }
333        "setWindow" => {
334            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
335            let units: Vec<String> = params
336                .get("units")
337                .and_then(Value::as_array)
338                .map(|arr| {
339                    arr.iter()
340                        .filter_map(|v| v.as_str().map(str::to_owned))
341                        .collect()
342                })
343                .unwrap_or_default();
344            let command_effects = need_client(client)?
345                .set_window(&base, &units)
346                .map_err(client_err)?;
347            Ok(json!({ "effects": command_effects }))
348        }
349        "windowState" => {
350            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
351            let state = need_client(client)?.window_state(&base);
352            Ok(json!({ "units": state.units, "pending": state.pending }))
353        }
354        "mutate" => {
355            let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
356            let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
357            Ok(json!({
358                "clientCommitId": id,
359                "effects": CommandEffects::interactive()
360            }))
361        }
362        "patch" => {
363            let table = params
364                .get("table")
365                .and_then(Value::as_str)
366                .ok_or_else(|| client_err("patch missing table".to_owned()))?;
367            let row_id = params
368                .get("rowId")
369                .and_then(Value::as_str)
370                .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
371            let mut partial = params
372                .get("partial")
373                .and_then(Value::as_object)
374                .cloned()
375                .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
376            decode_bigint_members(&mut partial).map_err(client_err)?;
377            let base_version = params.get("baseVersion").and_then(Value::as_i64);
378            let id = need_client(client)?
379                .patch(table, row_id, partial, base_version)
380                .map_err(client_err)?;
381            Ok(json!({
382                "clientCommitId": id,
383                "effects": CommandEffects::interactive()
384            }))
385        }
386        "sync" => {
387            let outcome = need_client(client)?.sync(transport);
388            Ok(outcome.to_json())
389        }
390        "syncUntilIdle" => {
391            let max_rounds = params
392                .get("maxRounds")
393                .and_then(Value::as_u64)
394                .map(|v| v as u32);
395            let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
396            Ok(outcome.to_json())
397        }
398        "readRows" => {
399            let table = params
400                .get("table")
401                .and_then(Value::as_str)
402                .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
403            let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
404            Ok(json!({ "rows": rows }))
405        }
406        "query" => {
407            // The React `useSyncQuery` live-query fast path: arbitrary read-only
408            // SQL over the local visible tables/views. Params ride as the driver
409            // value forms (bytes as `{"$bytes": hex}`); rows come back the same.
410            let sql = params
411                .get("sql")
412                .and_then(Value::as_str)
413                .ok_or_else(|| client_err("query missing sql".to_owned()))?;
414            let bind = match params.get("params") {
415                Some(Value::Array(list)) => list.clone(),
416                None | Some(Value::Null) => Vec::new(),
417                Some(_) => return Err(client_err("query params must be a list".to_owned())),
418            };
419            let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
420            Ok(json!({ "rows": rows }))
421        }
422        "querySnapshot" => {
423            let sql = params
424                .get("sql")
425                .and_then(Value::as_str)
426                .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
427            let bind = match params.get("params") {
428                Some(Value::Array(list)) => list.clone(),
429                None | Some(Value::Null) => Vec::new(),
430                Some(_) => {
431                    return Err(client_err("querySnapshot params must be a list".to_owned()))
432                }
433            };
434            let mut coverage = Vec::new();
435            for entry in params
436                .get("coverage")
437                .and_then(Value::as_array)
438                .into_iter()
439                .flatten()
440            {
441                let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
442                let units = entry
443                    .get("units")
444                    .and_then(Value::as_array)
445                    .map(|values| {
446                        values
447                            .iter()
448                            .filter_map(|value| value.as_str().map(str::to_owned))
449                            .collect()
450                    })
451                    .unwrap_or_default();
452                coverage.push(WindowCoverage { base, units });
453            }
454            let snapshot = need_client(client)?
455                .query_snapshot(sql, &bind, &coverage)
456                .map_err(client_err)?;
457            Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
458        }
459        "localRevision" => Ok(json!({
460            "revision": need_client(client)?.local_revision().to_string()
461        })),
462        "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
463            .expect("status serializes")),
464        // Conformance/debug drains. Production hosts normally drain these
465        // immediately after every command, but exposing the exact core output
466        // here lets both client implementations consume one observation
467        // vector catalog without bridge inference.
468        "drainChangeBatches" => Ok(json!({
469            "batches": need_client(client)?.drain_change_batches()
470        })),
471        "drainSyncIntents" => Ok(json!({
472            "intents": need_client(client)?.drain_sync_intents()
473        })),
474        // -- §5.10.5 native CRDT (the `crdt-yjs` feature) -----------------------
475        // Thin forwards to the client core's yrs helpers. The command surface
476        // stays present-but-unavailable in a lean build: without the feature
477        // these fail loudly (`client.crdt_unavailable`) rather than being an
478        // unknown method, so a wrapper's typed method gives a clear error.
479        #[cfg(feature = "crdt-yjs")]
480        "crdtText" => {
481            let (table, row_id, column, name) = crdt_target(params)?;
482            let text = need_client(client)?
483                .crdt_text(&table, &row_id, &column, &name)
484                .map_err(client_err)?;
485            Ok(json!({ "text": text }))
486        }
487        #[cfg(feature = "crdt-yjs")]
488        "crdtInsertText" => {
489            let (table, row_id, column, name) = crdt_target(params)?;
490            let index = params
491                .get("index")
492                .and_then(Value::as_u64)
493                .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
494                as u32;
495            let value = params
496                .get("value")
497                .and_then(Value::as_str)
498                .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
499            let id = need_client(client)?
500                .crdt_insert_text(&table, &row_id, &column, &name, index, value)
501                .map_err(client_err)?;
502            Ok(json!({ "clientCommitId": id }))
503        }
504        #[cfg(feature = "crdt-yjs")]
505        "crdtDeleteText" => {
506            let (table, row_id, column, name) = crdt_target(params)?;
507            let index = params
508                .get("index")
509                .and_then(Value::as_u64)
510                .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
511                as u32;
512            let len = params
513                .get("len")
514                .and_then(Value::as_u64)
515                .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
516                as u32;
517            let id = need_client(client)?
518                .crdt_delete_text(&table, &row_id, &column, &name, index, len)
519                .map_err(client_err)?;
520            Ok(json!({ "clientCommitId": id }))
521        }
522        #[cfg(feature = "crdt-yjs")]
523        "crdtApplyUpdate" => {
524            let (table, row_id, column, _name) = crdt_target(params)?;
525            let update = value_bytes(params.get("update")).map_err(client_err)?;
526            let id = need_client(client)?
527                .crdt_apply_update(&table, &row_id, &column, &update)
528                .map_err(client_err)?;
529            Ok(json!({ "clientCommitId": id }))
530        }
531        #[cfg(not(feature = "crdt-yjs"))]
532        "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
533            "client.crdt_unavailable".to_owned(),
534            "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
535        )),
536
537        "uploadBlob" => {
538            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
539            let media_type = params
540                .get("mediaType")
541                .and_then(Value::as_str)
542                .map(str::to_owned);
543            let name = params
544                .get("name")
545                .and_then(Value::as_str)
546                .map(str::to_owned);
547            let reference = need_client(client)?
548                .upload_blob(&bytes, media_type, name)
549                .map_err(client_err)?;
550            Ok(json!({ "ref": reference }))
551        }
552        "fetchBlob" => {
553            let blob = params
554                .get("blob")
555                .and_then(Value::as_str)
556                .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
557                .to_owned();
558            // fetch_blob returns (code, message) so the server's blob.* code
559            // reaches the caller (§5.9.5 cross-scope probe).
560            let value = need_client(client)?.fetch_blob(transport, &blob)?;
561            Ok(json!({ "blob": value }))
562        }
563        "conflicts" => {
564            let conflicts = need_client(client)?.conflicts().to_vec();
565            Ok(json!({ "conflicts": conflicts }))
566        }
567        "rejections" => {
568            let rejections = need_client(client)?.rejections().to_vec();
569            Ok(json!({ "rejections": rejections }))
570        }
571        "pendingCommitIds" => {
572            let ids = need_client(client)?.pending_commit_ids();
573            Ok(json!({ "ids": ids }))
574        }
575        "subscriptionState" => {
576            let id = params
577                .get("id")
578                .and_then(Value::as_str)
579                .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
580            let state = need_client(client)?.subscription_state(id);
581            Ok(json!({ "state": state }))
582        }
583        "schemaFloor" => {
584            let floor = need_client(client)?.schema_floor().cloned();
585            Ok(json!({ "floor": floor }))
586        }
587        "leaseState" => {
588            let lease = need_client(client)?.lease_state().cloned();
589            Ok(json!({ "lease": lease }))
590        }
591        "upgrading" => {
592            // §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
593            let value = need_client(client)?.upgrading();
594            Ok(json!({ "value": value }))
595        }
596        "recreateWithSchema" => {
597            // §7.4.2 "app ships new code": swap to the new schema on the SAME
598            // in-memory database (the Rust core has no persistent restart, so
599            // recreation IS the boot). Fires the §7.4.1 marker check.
600            let schema = params
601                .get("schema")
602                .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
603            need_client(client)?
604                .recreate_with_schema(schema)
605                .map_err(client_err)?;
606            Ok(json!({}))
607        }
608        "connectRealtime" => {
609            need_client(client)?
610                .connect_realtime(transport)
611                .map_err(client_err)?;
612            Ok(json!({}))
613        }
614        "disconnectRealtime" => {
615            need_client(client)?.disconnect_realtime(transport);
616            Ok(json!({}))
617        }
618        "syncNeeded" => {
619            let value = need_client(client)?.sync_needed();
620            Ok(json!({ "value": value }))
621        }
622        "setPresence" => {
623            let scope_key = params
624                .get("scopeKey")
625                .and_then(Value::as_str)
626                .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
627                .to_owned();
628            // §8.6.2: `doc` may be a JSON object or null (a leave).
629            let doc = params.get("doc");
630            let doc_ref = match doc {
631                None | Some(Value::Null) => None,
632                Some(v) => Some(v),
633            };
634            need_client(client)?
635                .set_presence(transport, &scope_key, doc_ref)
636                .map_err(client_err)?;
637            Ok(json!({}))
638        }
639        "presence" => {
640            let scope_key = params
641                .get("scopeKey")
642                .and_then(Value::as_str)
643                .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
644            let peers = need_client(client)?.presence(scope_key);
645            Ok(json!({ "peers": peers }))
646        }
647
648        // -- CodecDriver surface (Appendix A) — no client instance needed --
649        "messageRoundtrip" => {
650            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
651            match decode_message(&bytes) {
652                Ok(message) => Ok(json!({
653                    "ok": true,
654                    "bytes": bytes_value(&encode_message(&message)),
655                    "renderedJson": render_message(&message).to_string(),
656                })),
657                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
658            }
659        }
660        "segmentRoundtrip" => {
661            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
662            match decode_rows_segment(&bytes) {
663                Ok(segment) => Ok(json!({
664                    "ok": true,
665                    "bytes": bytes_value(&encode_rows_segment(&segment)),
666                    "renderedJson": render_rows_segment(&segment).to_string(),
667                })),
668                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
669            }
670        }
671        "realtimeKnown" => {
672            let text = params
673                .get("text")
674                .and_then(Value::as_str)
675                .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
676            let known = matches!(
677                parse_control(text),
678                Ok(ControlMessage::Hello { .. })
679                    | Ok(ControlMessage::Wake { .. })
680                    | Ok(ControlMessage::Heartbeat { .. })
681                    | Ok(ControlMessage::Presence { .. })
682            );
683            Ok(json!({ "value": known }))
684        }
685
686        other => Err(client_err(format!("unknown method {other:?}"))),
687    }
688}