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 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        "sync" => {
412            let outcome = need_client(client)?.sync(transport);
413            Ok(outcome.to_json())
414        }
415        "syncUntilIdle" => {
416            let max_rounds = params
417                .get("maxRounds")
418                .and_then(Value::as_u64)
419                .map(|v| v as u32);
420            let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
421            Ok(outcome.to_json())
422        }
423        "readRows" => {
424            let table = params
425                .get("table")
426                .and_then(Value::as_str)
427                .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
428            let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
429            Ok(json!({ "rows": rows }))
430        }
431        "query" => {
432            // The React `useSyncQuery` live-query fast path: arbitrary read-only
433            // SQL over the local visible tables/views. Params ride as the driver
434            // value forms (bytes as `{"$bytes": hex}`); rows come back the same.
435            let sql = params
436                .get("sql")
437                .and_then(Value::as_str)
438                .ok_or_else(|| client_err("query missing sql".to_owned()))?;
439            let bind = match params.get("params") {
440                Some(Value::Array(list)) => list.clone(),
441                None | Some(Value::Null) => Vec::new(),
442                Some(_) => return Err(client_err("query params must be a list".to_owned())),
443            };
444            let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
445            Ok(json!({ "rows": rows }))
446        }
447        "querySnapshot" => {
448            let sql = params
449                .get("sql")
450                .and_then(Value::as_str)
451                .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
452            let bind = match params.get("params") {
453                Some(Value::Array(list)) => list.clone(),
454                None | Some(Value::Null) => Vec::new(),
455                Some(_) => {
456                    return Err(client_err("querySnapshot params must be a list".to_owned()))
457                }
458            };
459            let mut coverage = Vec::new();
460            for entry in params
461                .get("coverage")
462                .and_then(Value::as_array)
463                .into_iter()
464                .flatten()
465            {
466                let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
467                let units = entry
468                    .get("units")
469                    .and_then(Value::as_array)
470                    .map(|values| {
471                        values
472                            .iter()
473                            .filter_map(|value| value.as_str().map(str::to_owned))
474                            .collect()
475                    })
476                    .unwrap_or_default();
477                coverage.push(WindowCoverage { base, units });
478            }
479            let snapshot = need_client(client)?
480                .query_snapshot(sql, &bind, &coverage)
481                .map_err(client_err)?;
482            Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
483        }
484        "localRevision" => Ok(json!({
485            "revision": need_client(client)?.local_revision().to_string()
486        })),
487        "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
488            .expect("status serializes")),
489        // Conformance/debug drains. Production hosts normally drain these
490        // immediately after every command, but exposing the exact core output
491        // here lets both client implementations consume one observation
492        // vector catalog without bridge inference.
493        "drainChangeBatches" => Ok(json!({
494            "batches": need_client(client)?.drain_change_batches()
495        })),
496        "drainSyncIntents" => Ok(json!({
497            "intents": need_client(client)?.drain_sync_intents()
498        })),
499        // -- §5.10.5 native CRDT (the `crdt-yjs` feature) -----------------------
500        // Thin forwards to the client core's yrs helpers. The command surface
501        // stays present-but-unavailable in a lean build: without the feature
502        // these fail loudly (`client.crdt_unavailable`) rather than being an
503        // unknown method, so a wrapper's typed method gives a clear error.
504        #[cfg(feature = "crdt-yjs")]
505        "crdtText" => {
506            let (table, row_id, column, name) = crdt_target(params)?;
507            let text = need_client(client)?
508                .crdt_text(&table, &row_id, &column, &name)
509                .map_err(client_err)?;
510            Ok(json!({ "text": text }))
511        }
512        #[cfg(feature = "crdt-yjs")]
513        "crdtInsertText" => {
514            let (table, row_id, column, name) = crdt_target(params)?;
515            let index = params
516                .get("index")
517                .and_then(Value::as_u64)
518                .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
519                as u32;
520            let value = params
521                .get("value")
522                .and_then(Value::as_str)
523                .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
524            let id = need_client(client)?
525                .crdt_insert_text(&table, &row_id, &column, &name, index, value)
526                .map_err(client_err)?;
527            Ok(json!({ "clientCommitId": id }))
528        }
529        #[cfg(feature = "crdt-yjs")]
530        "crdtDeleteText" => {
531            let (table, row_id, column, name) = crdt_target(params)?;
532            let index = params
533                .get("index")
534                .and_then(Value::as_u64)
535                .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
536                as u32;
537            let len = params
538                .get("len")
539                .and_then(Value::as_u64)
540                .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
541                as u32;
542            let id = need_client(client)?
543                .crdt_delete_text(&table, &row_id, &column, &name, index, len)
544                .map_err(client_err)?;
545            Ok(json!({ "clientCommitId": id }))
546        }
547        #[cfg(feature = "crdt-yjs")]
548        "crdtApplyUpdate" => {
549            let (table, row_id, column, _name) = crdt_target(params)?;
550            let update = value_bytes(params.get("update")).map_err(client_err)?;
551            let id = need_client(client)?
552                .crdt_apply_update(&table, &row_id, &column, &update)
553                .map_err(client_err)?;
554            Ok(json!({ "clientCommitId": id }))
555        }
556        #[cfg(not(feature = "crdt-yjs"))]
557        "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
558            "client.crdt_unavailable".to_owned(),
559            "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
560        )),
561
562        "uploadBlob" => {
563            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
564            let media_type = params
565                .get("mediaType")
566                .and_then(Value::as_str)
567                .map(str::to_owned);
568            let name = params
569                .get("name")
570                .and_then(Value::as_str)
571                .map(str::to_owned);
572            let reference = need_client(client)?
573                .upload_blob(&bytes, media_type, name)
574                .map_err(client_err)?;
575            Ok(json!({ "ref": reference }))
576        }
577        "fetchBlob" => {
578            let blob = params
579                .get("blob")
580                .and_then(Value::as_str)
581                .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
582                .to_owned();
583            // fetch_blob returns (code, message) so the server's blob.* code
584            // reaches the caller (§5.9.5 cross-scope probe).
585            let value = need_client(client)?.fetch_blob(transport, &blob)?;
586            Ok(json!({ "blob": value }))
587        }
588        "conflicts" => {
589            let conflicts = need_client(client)?.conflicts().to_vec();
590            Ok(json!({ "conflicts": conflicts }))
591        }
592        "rejections" => {
593            let rejections = need_client(client)?.rejections().to_vec();
594            Ok(json!({ "rejections": rejections }))
595        }
596        "commitOutcome" => {
597            let client_commit_id = params
598                .get("clientCommitId")
599                .and_then(Value::as_str)
600                .ok_or_else(|| {
601                    client_err(
602                        "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
603                    )
604                })?;
605            let outcome = need_client(client)?
606                .commit_outcome(client_commit_id)
607                .map_err(client_err)?;
608            Ok(json!({ "outcome": outcome }))
609        }
610        "commitOutcomes" => {
611            let query = serde_json::from_value::<CommitOutcomeQuery>(
612                params.get("query").cloned().unwrap_or_else(|| json!({})),
613            )
614            .map_err(|error| {
615                client_err(format!(
616                    "sync.invalid_request: invalid commit outcome query: {error}"
617                ))
618            })?;
619            let outcomes = need_client(client)?
620                .commit_outcomes(query)
621                .map_err(client_err)?;
622            Ok(json!({ "outcomes": outcomes }))
623        }
624        "resolveCommitOutcome" => {
625            let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
626                params.get("input").cloned().ok_or_else(|| {
627                    client_err(
628                        "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
629                    )
630                })?,
631            )
632            .map_err(|error| {
633                client_err(format!(
634                    "sync.invalid_request: invalid outcome resolution: {error}"
635                ))
636            })?;
637            let outcome = need_client(client)?
638                .resolve_commit_outcome(input)
639                .map_err(client_err)?;
640            Ok(json!({ "outcome": outcome }))
641        }
642        "pendingCommitIds" => {
643            let ids = need_client(client)?.pending_commit_ids();
644            Ok(json!({ "ids": ids }))
645        }
646        "subscriptionState" => {
647            let id = params
648                .get("id")
649                .and_then(Value::as_str)
650                .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
651            let state = need_client(client)?.subscription_state(id);
652            Ok(json!({ "state": state }))
653        }
654        "schemaFloor" => {
655            let floor = need_client(client)?.schema_floor().cloned();
656            Ok(json!({ "floor": floor }))
657        }
658        "leaseState" => {
659            let lease = need_client(client)?.lease_state().cloned();
660            Ok(json!({ "lease": lease }))
661        }
662        "upgrading" => {
663            // §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
664            let value = need_client(client)?.upgrading();
665            Ok(json!({ "value": value }))
666        }
667        "recreateWithSchema" => {
668            // §7.4.2 "app ships new code": swap to the new schema on the SAME
669            // in-memory database (the Rust core has no persistent restart, so
670            // recreation IS the boot). Fires the §7.4.1 marker check.
671            let schema = params
672                .get("schema")
673                .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
674            need_client(client)?
675                .recreate_with_schema(schema)
676                .map_err(client_err)?;
677            Ok(json!({}))
678        }
679        "connectRealtime" => {
680            need_client(client)?
681                .connect_realtime(transport)
682                .map_err(client_err)?;
683            Ok(json!({}))
684        }
685        "disconnectRealtime" => {
686            need_client(client)?.disconnect_realtime(transport);
687            Ok(json!({}))
688        }
689        "syncNeeded" => {
690            let value = need_client(client)?.sync_needed();
691            Ok(json!({ "value": value }))
692        }
693        "setPresence" => {
694            let scope_key = params
695                .get("scopeKey")
696                .and_then(Value::as_str)
697                .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
698                .to_owned();
699            // §8.6.2: `doc` may be a JSON object or null (a leave).
700            let doc = params.get("doc");
701            let doc_ref = match doc {
702                None | Some(Value::Null) => None,
703                Some(v) => Some(v),
704            };
705            need_client(client)?
706                .set_presence(transport, &scope_key, doc_ref)
707                .map_err(client_err)?;
708            Ok(json!({}))
709        }
710        "presence" => {
711            let scope_key = params
712                .get("scopeKey")
713                .and_then(Value::as_str)
714                .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
715            let peers = need_client(client)?.presence(scope_key);
716            Ok(json!({ "peers": peers }))
717        }
718
719        // -- CodecDriver surface (Appendix A) — no client instance needed --
720        "messageRoundtrip" => {
721            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
722            match decode_message(&bytes) {
723                Ok(message) => Ok(json!({
724                    "ok": true,
725                    "bytes": bytes_value(&encode_message(&message)),
726                    "renderedJson": render_message(&message).to_string(),
727                })),
728                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
729            }
730        }
731        "segmentRoundtrip" => {
732            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
733            match decode_rows_segment(&bytes) {
734                Ok(segment) => Ok(json!({
735                    "ok": true,
736                    "bytes": bytes_value(&encode_rows_segment(&segment)),
737                    "renderedJson": render_rows_segment(&segment).to_string(),
738                })),
739                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
740            }
741        }
742        "realtimeKnown" => {
743            let text = params
744                .get("text")
745                .and_then(Value::as_str)
746                .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
747            let known = matches!(
748                parse_control(text),
749                Ok(ControlMessage::Hello { .. })
750                    | Ok(ControlMessage::Wake { .. })
751                    | Ok(ControlMessage::Heartbeat { .. })
752                    | Ok(ControlMessage::Presence { .. })
753            );
754            Ok(json!({ "value": known }))
755        }
756
757        other => Err(client_err(format!("unknown method {other:?}"))),
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use serde_json::json;
764
765    use super::parse_encryption;
766
767    #[test]
768    fn parses_portable_encryption_keyring_and_key_id_columns() {
769        let key_hex = "2a".repeat(32);
770        let config = parse_encryption(&json!({
771            "keys": { "practice-key-v1": { "$bytes": key_hex } },
772            "keyIdColumns": { "patients": "encryption_key_id" }
773        }))
774        .expect("portable keyring parses");
775        assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
776        assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
777    }
778
779    #[test]
780    fn rejects_non_string_key_id_columns() {
781        let error = parse_encryption(&json!({
782            "keys": {},
783            "keyIdColumns": { "patients": 7 }
784        }))
785        .expect_err("invalid selector must fail");
786        assert!(error.contains("must be a string"), "{error}");
787    }
788}