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    if method != "create" {
290        let allowed_during_preflight = matches!(
291            method,
292            "securityLifecycle"
293                | "beginSecurityPreflight"
294                | "activateSecurity"
295                | "purgeLocalData"
296                | "localRevision"
297                | "statusSnapshot"
298                | "shutdown"
299        );
300        if client
301            .as_ref()
302            .is_some_and(|running| running.security_preflight())
303            && !allowed_during_preflight
304        {
305            return Err((
306                syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
307                "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data".to_owned(),
308            ));
309        }
310    }
311    match method {
312        "create" => {
313            let client_id = params
314                .get("clientId")
315                .and_then(Value::as_str)
316                .map(str::to_owned);
317            let schema = params
318                .get("schema")
319                .ok_or_else(|| client_err("create missing schema".to_owned()))?;
320            let limits = parse_limits(params.get("limits"));
321            // §native: a `dbPath` installs a file-backed rusqlite connection so
322            // native hosts (Tauri plugin, FFI file variant) persist across
323            // restarts; absent it, the default in-memory core (the shim's mode).
324            let mut instance = match params.get("dbPath").and_then(Value::as_str) {
325                Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
326                    .map_err(client_err)?,
327                None => {
328                    SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
329                }
330            };
331            // Harness clock pin (§5.4 expiry runs on the virtual clock).
332            if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
333                instance.set_now_ms(now_ms);
334            }
335            // §5.4 capability of the host endpoint set (accept bit 3) — the
336            // host applies it to its own transport.
337            effects.signed_urls = params
338                .get("signedUrls")
339                .and_then(Value::as_bool)
340                .unwrap_or(false);
341            // §5.11: install client-side encryption keys. Shape:
342            // { encryption: { keys: { "<keyId>": {"$bytes": "<hex>"} },
343            //                 keyIdColumns: { "<table>": "<column>" } } }.
344            let security_preflight = params
345                .get("securityPreflight")
346                .and_then(Value::as_bool)
347                .unwrap_or(false);
348            if security_preflight && params.get("encryption").is_some() {
349                return Err(client_err(
350                    "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
351                        .to_owned(),
352                ));
353            }
354            if let Some(enc) = params.get("encryption") {
355                let config = parse_encryption(enc).map_err(client_err)?;
356                instance.set_encryption(config);
357            }
358            if security_preflight {
359                instance.begin_security_preflight();
360            }
361            *client = Some(instance);
362            Ok(json!({}))
363        }
364        "securityLifecycle" => Ok(json!({
365            "state": need_client(client)?.security_lifecycle()
366        })),
367        "beginSecurityPreflight" => {
368            let running = need_client(client)?;
369            running.disconnect_realtime(transport);
370            running.begin_security_preflight();
371            Ok(json!({}))
372        }
373        "activateSecurity" => {
374            let encryption = match params.get("encryption") {
375                Some(value) => parse_encryption(value).map_err(client_err)?,
376                None => syncular_client::values::EncryptionConfig::default(),
377            };
378            need_client(client)?
379                .activate_security(encryption)
380                .map_err(client_err)?;
381            Ok(json!({}))
382        }
383        "shutdown" => {
384            if let Some(running) = client.as_mut() {
385                running.disconnect_realtime(transport);
386                running.begin_security_preflight();
387            }
388            *client = None;
389            Ok(json!({}))
390        }
391        "subscribe" => {
392            let id = params
393                .get("id")
394                .and_then(Value::as_str)
395                .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
396                .to_owned();
397            let table = params
398                .get("table")
399                .and_then(Value::as_str)
400                .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
401                .to_owned();
402            let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
403            let sub_params = params
404                .get("params")
405                .and_then(Value::as_str)
406                .map(str::to_owned);
407            need_client(client)?
408                .subscribe(id, table, scopes, sub_params)
409                .map_err(client_err)?;
410            Ok(json!({}))
411        }
412        "unsubscribe" => {
413            let id = params
414                .get("id")
415                .and_then(Value::as_str)
416                .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
417            need_client(client)?.unsubscribe(id);
418            Ok(json!({}))
419        }
420        "setWindow" => {
421            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
422            let units: Vec<String> = params
423                .get("units")
424                .and_then(Value::as_array)
425                .map(|arr| {
426                    arr.iter()
427                        .filter_map(|v| v.as_str().map(str::to_owned))
428                        .collect()
429                })
430                .unwrap_or_default();
431            let command_effects = need_client(client)?
432                .set_window(&base, &units)
433                .map_err(client_err)?;
434            Ok(json!({ "effects": command_effects }))
435        }
436        "windowState" => {
437            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
438            let state = need_client(client)?.window_state(&base);
439            Ok(json!({ "units": state.units, "pending": state.pending }))
440        }
441        "mutate" => {
442            let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
443            let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
444            Ok(json!({
445                "clientCommitId": id,
446                "effects": CommandEffects::interactive()
447            }))
448        }
449        "patch" => {
450            let table = params
451                .get("table")
452                .and_then(Value::as_str)
453                .ok_or_else(|| client_err("patch missing table".to_owned()))?;
454            let row_id = params
455                .get("rowId")
456                .and_then(Value::as_str)
457                .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
458            let mut partial = params
459                .get("partial")
460                .and_then(Value::as_object)
461                .cloned()
462                .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
463            decode_bigint_members(&mut partial).map_err(client_err)?;
464            let base_version = params.get("baseVersion").and_then(Value::as_i64);
465            let id = need_client(client)?
466                .patch(table, row_id, partial, base_version)
467                .map_err(client_err)?;
468            Ok(json!({
469                "clientCommitId": id,
470                "effects": CommandEffects::interactive()
471            }))
472        }
473        "purgeLocalData" => {
474            let input: LocalDataPurgeInput =
475                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
476                    client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
477                })?)
478                .map_err(|error| {
479                    client_err(format!(
480                        "sync.invalid_request: invalid purgeLocalData input: {error}"
481                    ))
482                })?;
483            let result = need_client(client)?
484                .purge_local_data(&input)
485                .map_err(client_err)?;
486            serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
487        }
488        "sync" => {
489            let outcome = need_client(client)?.sync(transport);
490            Ok(outcome.to_json())
491        }
492        "syncUntilIdle" => {
493            let max_rounds = params
494                .get("maxRounds")
495                .and_then(Value::as_u64)
496                .map(|v| v as u32);
497            let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
498            Ok(outcome.to_json())
499        }
500        "readRows" => {
501            let table = params
502                .get("table")
503                .and_then(Value::as_str)
504                .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
505            let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
506            Ok(json!({ "rows": rows }))
507        }
508        "query" => {
509            // The React `useSyncQuery` live-query fast path: arbitrary read-only
510            // SQL over the local visible tables/views. Params ride as the driver
511            // value forms (bytes as `{"$bytes": hex}`); rows come back the same.
512            let sql = params
513                .get("sql")
514                .and_then(Value::as_str)
515                .ok_or_else(|| client_err("query missing sql".to_owned()))?;
516            let bind = match params.get("params") {
517                Some(Value::Array(list)) => list.clone(),
518                None | Some(Value::Null) => Vec::new(),
519                Some(_) => return Err(client_err("query params must be a list".to_owned())),
520            };
521            let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
522            Ok(json!({ "rows": rows }))
523        }
524        "querySnapshot" => {
525            let sql = params
526                .get("sql")
527                .and_then(Value::as_str)
528                .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
529            let bind = match params.get("params") {
530                Some(Value::Array(list)) => list.clone(),
531                None | Some(Value::Null) => Vec::new(),
532                Some(_) => {
533                    return Err(client_err("querySnapshot params must be a list".to_owned()))
534                }
535            };
536            let mut coverage = Vec::new();
537            for entry in params
538                .get("coverage")
539                .and_then(Value::as_array)
540                .into_iter()
541                .flatten()
542            {
543                let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
544                let units = entry
545                    .get("units")
546                    .and_then(Value::as_array)
547                    .map(|values| {
548                        values
549                            .iter()
550                            .filter_map(|value| value.as_str().map(str::to_owned))
551                            .collect()
552                    })
553                    .unwrap_or_default();
554                coverage.push(WindowCoverage { base, units });
555            }
556            let snapshot = need_client(client)?
557                .query_snapshot(sql, &bind, &coverage)
558                .map_err(client_err)?;
559            Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
560        }
561        "localRevision" => Ok(json!({
562            "revision": need_client(client)?.local_revision().to_string()
563        })),
564        "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
565            .expect("status serializes")),
566        // Conformance/debug drains. Production hosts normally drain these
567        // immediately after every command, but exposing the exact core output
568        // here lets both client implementations consume one observation
569        // vector catalog without bridge inference.
570        "drainChangeBatches" => Ok(json!({
571            "batches": need_client(client)?.drain_change_batches()
572        })),
573        "drainSyncIntents" => Ok(json!({
574            "intents": need_client(client)?.drain_sync_intents()
575        })),
576        // -- §5.10.5 native CRDT (the `crdt-yjs` feature) -----------------------
577        // Thin forwards to the client core's yrs helpers. The command surface
578        // stays present-but-unavailable in a lean build: without the feature
579        // these fail loudly (`client.crdt_unavailable`) rather than being an
580        // unknown method, so a wrapper's typed method gives a clear error.
581        #[cfg(feature = "crdt-yjs")]
582        "crdtText" => {
583            let (table, row_id, column, name) = crdt_target(params)?;
584            let text = need_client(client)?
585                .crdt_text(&table, &row_id, &column, &name)
586                .map_err(client_err)?;
587            Ok(json!({ "text": text }))
588        }
589        #[cfg(feature = "crdt-yjs")]
590        "crdtInsertText" => {
591            let (table, row_id, column, name) = crdt_target(params)?;
592            let index = params
593                .get("index")
594                .and_then(Value::as_u64)
595                .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
596                as u32;
597            let value = params
598                .get("value")
599                .and_then(Value::as_str)
600                .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
601            let id = need_client(client)?
602                .crdt_insert_text(&table, &row_id, &column, &name, index, value)
603                .map_err(client_err)?;
604            Ok(json!({ "clientCommitId": id }))
605        }
606        #[cfg(feature = "crdt-yjs")]
607        "crdtDeleteText" => {
608            let (table, row_id, column, name) = crdt_target(params)?;
609            let index = params
610                .get("index")
611                .and_then(Value::as_u64)
612                .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
613                as u32;
614            let len = params
615                .get("len")
616                .and_then(Value::as_u64)
617                .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
618                as u32;
619            let id = need_client(client)?
620                .crdt_delete_text(&table, &row_id, &column, &name, index, len)
621                .map_err(client_err)?;
622            Ok(json!({ "clientCommitId": id }))
623        }
624        #[cfg(feature = "crdt-yjs")]
625        "crdtApplyUpdate" => {
626            let (table, row_id, column, _name) = crdt_target(params)?;
627            let update = value_bytes(params.get("update")).map_err(client_err)?;
628            let id = need_client(client)?
629                .crdt_apply_update(&table, &row_id, &column, &update)
630                .map_err(client_err)?;
631            Ok(json!({ "clientCommitId": id }))
632        }
633        #[cfg(not(feature = "crdt-yjs"))]
634        "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
635            "client.crdt_unavailable".to_owned(),
636            "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
637        )),
638
639        "uploadBlob" => {
640            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
641            let media_type = params
642                .get("mediaType")
643                .and_then(Value::as_str)
644                .map(str::to_owned);
645            let name = params
646                .get("name")
647                .and_then(Value::as_str)
648                .map(str::to_owned);
649            let reference = need_client(client)?
650                .upload_blob(&bytes, media_type, name)
651                .map_err(client_err)?;
652            Ok(json!({ "ref": reference }))
653        }
654        "fetchBlob" => {
655            let blob = params
656                .get("blob")
657                .and_then(Value::as_str)
658                .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
659                .to_owned();
660            // fetch_blob returns (code, message) so the server's blob.* code
661            // reaches the caller (§5.9.5 cross-scope probe).
662            let value = need_client(client)?.fetch_blob(transport, &blob)?;
663            Ok(json!({ "blob": value }))
664        }
665        "conflicts" => {
666            let conflicts = need_client(client)?.conflicts().to_vec();
667            Ok(json!({ "conflicts": conflicts }))
668        }
669        "rejections" => {
670            let rejections = need_client(client)?.rejections().to_vec();
671            Ok(json!({ "rejections": rejections }))
672        }
673        "commitOutcome" => {
674            let client_commit_id = params
675                .get("clientCommitId")
676                .and_then(Value::as_str)
677                .ok_or_else(|| {
678                    client_err(
679                        "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
680                    )
681                })?;
682            let outcome = need_client(client)?
683                .commit_outcome(client_commit_id)
684                .map_err(client_err)?;
685            Ok(json!({ "outcome": outcome }))
686        }
687        "commitOutcomes" => {
688            let query = serde_json::from_value::<CommitOutcomeQuery>(
689                params.get("query").cloned().unwrap_or_else(|| json!({})),
690            )
691            .map_err(|error| {
692                client_err(format!(
693                    "sync.invalid_request: invalid commit outcome query: {error}"
694                ))
695            })?;
696            let outcomes = need_client(client)?
697                .commit_outcomes(query)
698                .map_err(client_err)?;
699            Ok(json!({ "outcomes": outcomes }))
700        }
701        "resolveCommitOutcome" => {
702            let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
703                params.get("input").cloned().ok_or_else(|| {
704                    client_err(
705                        "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
706                    )
707                })?,
708            )
709            .map_err(|error| {
710                client_err(format!(
711                    "sync.invalid_request: invalid outcome resolution: {error}"
712                ))
713            })?;
714            let outcome = need_client(client)?
715                .resolve_commit_outcome(input)
716                .map_err(client_err)?;
717            Ok(json!({ "outcome": outcome }))
718        }
719        "pendingCommitIds" => {
720            let ids = need_client(client)?.pending_commit_ids();
721            Ok(json!({ "ids": ids }))
722        }
723        "subscriptionState" => {
724            let id = params
725                .get("id")
726                .and_then(Value::as_str)
727                .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
728            let state = need_client(client)?.subscription_state(id);
729            Ok(json!({ "state": state }))
730        }
731        "schemaFloor" => {
732            let floor = need_client(client)?.schema_floor().cloned();
733            Ok(json!({ "floor": floor }))
734        }
735        "leaseState" => {
736            let lease = need_client(client)?.lease_state().cloned();
737            Ok(json!({ "lease": lease }))
738        }
739        "upgrading" => {
740            // §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
741            let value = need_client(client)?.upgrading();
742            Ok(json!({ "value": value }))
743        }
744        "recreateWithSchema" => {
745            // §7.4.2 "app ships new code": swap to the new schema on the SAME
746            // in-memory database (the Rust core has no persistent restart, so
747            // recreation IS the boot). Fires the §7.4.1 marker check.
748            let schema = params
749                .get("schema")
750                .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
751            need_client(client)?
752                .recreate_with_schema(schema)
753                .map_err(client_err)?;
754            Ok(json!({}))
755        }
756        "connectRealtime" => {
757            need_client(client)?
758                .connect_realtime(transport)
759                .map_err(client_err)?;
760            Ok(json!({}))
761        }
762        "disconnectRealtime" => {
763            need_client(client)?.disconnect_realtime(transport);
764            Ok(json!({}))
765        }
766        "syncNeeded" => {
767            let value = need_client(client)?.sync_needed();
768            Ok(json!({ "value": value }))
769        }
770        "setPresence" => {
771            let scope_key = params
772                .get("scopeKey")
773                .and_then(Value::as_str)
774                .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
775                .to_owned();
776            // §8.6.2: `doc` may be a JSON object or null (a leave).
777            let doc = params.get("doc");
778            let doc_ref = match doc {
779                None | Some(Value::Null) => None,
780                Some(v) => Some(v),
781            };
782            need_client(client)?
783                .set_presence(transport, &scope_key, doc_ref)
784                .map_err(client_err)?;
785            Ok(json!({}))
786        }
787        "presence" => {
788            let scope_key = params
789                .get("scopeKey")
790                .and_then(Value::as_str)
791                .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
792            let peers = need_client(client)?.presence(scope_key);
793            Ok(json!({ "peers": peers }))
794        }
795
796        // -- CodecDriver surface (Appendix A) — no client instance needed --
797        "messageRoundtrip" => {
798            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
799            match decode_message(&bytes) {
800                Ok(message) => Ok(json!({
801                    "ok": true,
802                    "bytes": bytes_value(&encode_message(&message)),
803                    "renderedJson": render_message(&message).to_string(),
804                })),
805                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
806            }
807        }
808        "segmentRoundtrip" => {
809            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
810            match decode_rows_segment(&bytes) {
811                Ok(segment) => Ok(json!({
812                    "ok": true,
813                    "bytes": bytes_value(&encode_rows_segment(&segment)),
814                    "renderedJson": render_rows_segment(&segment).to_string(),
815                })),
816                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
817            }
818        }
819        "realtimeKnown" => {
820            let text = params
821                .get("text")
822                .and_then(Value::as_str)
823                .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
824            let known = matches!(
825                parse_control(text),
826                Ok(ControlMessage::Hello { .. })
827                    | Ok(ControlMessage::Wake { .. })
828                    | Ok(ControlMessage::Heartbeat { .. })
829                    | Ok(ControlMessage::Presence { .. })
830            );
831            Ok(json!({ "value": known }))
832        }
833
834        other => Err(client_err(format!("unknown method {other:?}"))),
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use serde_json::{json, Value};
841    use syncular_client::{
842        SegmentRequest, SyncClient, Transport, TransportError, SECURITY_PREFLIGHT_REQUIRED_CODE,
843    };
844
845    use super::{dispatch, parse_encryption, CreateEffects};
846
847    struct NoNetwork;
848
849    impl Transport for NoNetwork {
850        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
851            Err(TransportError::new("sync.transport_failed", "offline"))
852        }
853
854        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
855            Err(TransportError::new("sync.transport_failed", "offline"))
856        }
857
858        fn download_segment(
859            &mut self,
860            _request: &SegmentRequest,
861        ) -> Result<Vec<u8>, TransportError> {
862            Err(TransportError::new("sync.transport_failed", "offline"))
863        }
864
865        fn realtime_connect(&mut self) -> Result<(), TransportError> {
866            Ok(())
867        }
868
869        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
870            Ok(())
871        }
872
873        fn realtime_close(&mut self) -> Result<(), TransportError> {
874            Ok(())
875        }
876    }
877
878    fn schema() -> Value {
879        json!({
880            "version": 1,
881            "tables": [{
882                "name": "todos",
883                "columns": [
884                    { "name": "id", "type": "string", "nullable": false },
885                    { "name": "list_id", "type": "string", "nullable": false }
886                ],
887                "primaryKey": "id",
888                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
889            }]
890        })
891    }
892
893    #[test]
894    fn parses_portable_encryption_keyring_and_key_id_columns() {
895        let key_hex = "2a".repeat(32);
896        let config = parse_encryption(&json!({
897            "keys": { "practice-key-v1": { "$bytes": key_hex } },
898            "keyIdColumns": { "patients": "encryption_key_id" }
899        }))
900        .expect("portable keyring parses");
901        assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
902        assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
903    }
904
905    #[test]
906    fn rejects_non_string_key_id_columns() {
907        let error = parse_encryption(&json!({
908            "keys": {},
909            "keyIdColumns": { "patients": 7 }
910        }))
911        .expect_err("invalid selector must fail");
912        assert!(error.contains("must be a string"), "{error}");
913    }
914
915    #[test]
916    fn security_preflight_is_fail_closed_until_exact_activation() {
917        let mut transport = NoNetwork;
918        let mut client: Option<SyncClient> = None;
919        let mut effects = CreateEffects::default();
920        dispatch(
921            &mut transport,
922            &mut client,
923            &mut effects,
924            "create",
925            &json!({ "schema": schema(), "securityPreflight": true }),
926        )
927        .expect("preflight create");
928
929        let lifecycle = dispatch(
930            &mut transport,
931            &mut client,
932            &mut effects,
933            "securityLifecycle",
934            &json!({}),
935        )
936        .expect("lifecycle");
937        assert_eq!(lifecycle, json!({ "state": "preflight" }));
938
939        let query_error = dispatch(
940            &mut transport,
941            &mut client,
942            &mut effects,
943            "query",
944            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
945        )
946        .expect_err("protected query must fail");
947        assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
948
949        dispatch(
950            &mut transport,
951            &mut client,
952            &mut effects,
953            "purgeLocalData",
954            &json!({
955                "input": {
956                    "purgeId": "directive-1",
957                    "targets": [{
958                        "table": "todos",
959                        "selectors": { "list_id": ["list-1"] }
960                    }]
961                }
962            }),
963        )
964        .expect("authorized local purge remains available");
965
966        dispatch(
967            &mut transport,
968            &mut client,
969            &mut effects,
970            "activateSecurity",
971            &json!({}),
972        )
973        .expect("activation");
974        let rows = dispatch(
975            &mut transport,
976            &mut client,
977            &mut effects,
978            "query",
979            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
980        )
981        .expect("active query");
982        assert_eq!(rows, json!({ "rows": [] }));
983
984        dispatch(
985            &mut transport,
986            &mut client,
987            &mut effects,
988            "beginSecurityPreflight",
989            &json!({}),
990        )
991        .expect("re-enter preflight");
992        let blocked = dispatch(
993            &mut transport,
994            &mut client,
995            &mut effects,
996            "mutate",
997            &json!({ "mutations": [] }),
998        )
999        .expect_err("mutation must be gated");
1000        assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1001    }
1002}