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