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