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    ClientDiagnosticsRequest, ClientLimits, CommandEffects, CommitOutcomeQuery,
24    LocalDataPurgeInput, Mutation, ResolveCommitOutcomeInput, SyncClient, Transport, WindowBase,
25    WindowCoverage,
26};
27
28// -- bytes <-> {"$bytes": hex} (the driver-protocol byte envelope) ----------
29
30pub fn bytes_to_hex(bytes: &[u8]) -> String {
31    syncular_client::values::bytes_to_hex(bytes)
32}
33
34pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
35    syncular_client::values::hex_to_bytes(hex)
36}
37
38pub fn bytes_value(bytes: &[u8]) -> Value {
39    json!({ "$bytes": bytes_to_hex(bytes) })
40}
41
42pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
43    let hex = value
44        .and_then(|v| v.get("$bytes"))
45        .and_then(Value::as_str)
46        .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
47    hex_to_bytes(hex)
48}
49
50/// The `(code, message)` pair the driver protocol carries in an `error`.
51pub type CommandError = (String, String);
52
53/// Parsed side effects of a `create` command that the host must apply to its
54/// own transport/clock (the router stays transport-agnostic). The client is
55/// already installed into the `Option<SyncClient>` slot by `dispatch`.
56#[derive(Debug, Default, Clone)]
57pub struct CreateEffects {
58    /// §5.4 capability the harness/host announced for its endpoints — the
59    /// host sets its transport's `supports_url_fetch` accordingly.
60    pub signed_urls: bool,
61}
62
63fn client_err(message: String) -> CommandError {
64    let code = message
65        .split_once(':')
66        .map(|(candidate, _)| candidate)
67        .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
68        .unwrap_or("client.failed");
69    (code.to_owned(), message)
70}
71
72fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
73    client
74        .as_mut()
75        .ok_or_else(|| client_err("no client instance created".to_owned()))
76}
77
78pub fn parse_limits(value: Option<&Value>) -> ClientLimits {
79    let mut limits = ClientLimits::default();
80    let Some(object) = value.and_then(Value::as_object) else {
81        return limits;
82    };
83    limits.limit_commits = object
84        .get("limitCommits")
85        .and_then(Value::as_i64)
86        .map(|v| v as i32);
87    limits.limit_snapshot_rows = object
88        .get("limitSnapshotRows")
89        .and_then(Value::as_i64)
90        .map(|v| v as i32);
91    limits.max_snapshot_pages = object
92        .get("maxSnapshotPages")
93        .and_then(Value::as_i64)
94        .map(|v| v as i32);
95    limits.accept = object
96        .get("accept")
97        .and_then(Value::as_u64)
98        .map(|v| v as u8);
99    limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
100    limits.outcome_retention_max_entries = object
101        .get("outcomeRetentionMaxEntries")
102        .and_then(Value::as_u64)
103        .map(|value| value as usize);
104    limits
105}
106
107/// §5.11: parse the `encryption` config into the client's portable keyring.
108/// Shape: `{ keys: { "<keyId>": {"$bytes": "<hex>"} },
109/// keyIdColumns: { "<table>": "<column>" } }`. Keys are 32 bytes.
110pub fn parse_encryption(
111    value: &Value,
112) -> Result<syncular_client::values::EncryptionConfig, String> {
113    let mut config = syncular_client::values::EncryptionConfig::default();
114    let Some(keys) = value.get("keys").and_then(Value::as_object) else {
115        return Ok(config);
116    };
117    for (key_id, key_val) in keys {
118        let bytes =
119            value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
120        if bytes.len() != 32 {
121            return Err(format!(
122                "encryption key {key_id:?} must be 32 bytes, got {}",
123                bytes.len()
124            ));
125        }
126        config.keys.insert(key_id.clone(), bytes);
127    }
128    if let Some(columns) = value.get("keyIdColumns") {
129        let columns = columns
130            .as_object()
131            .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
132        for (table, column) in columns {
133            let column = column.as_str().ok_or_else(|| {
134                format!("encryption keyIdColumns entry for {table:?} must be a string")
135            })?;
136            if column.is_empty() {
137                return Err(format!(
138                    "encryption keyIdColumns entry for {table:?} must not be empty"
139                ));
140            }
141            config
142                .key_id_columns
143                .insert(table.clone(), column.to_owned());
144        }
145    }
146    Ok(config)
147}
148
149pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
150    let list = value
151        .and_then(Value::as_array)
152        .ok_or_else(|| "mutations must be a list".to_owned())?;
153    let mut out = Vec::with_capacity(list.len());
154    for entry in list {
155        let op = entry
156            .get("op")
157            .and_then(Value::as_str)
158            .ok_or_else(|| "mutation missing op".to_owned())?;
159        let table = entry
160            .get("table")
161            .and_then(Value::as_str)
162            .ok_or_else(|| "mutation missing table".to_owned())?
163            .to_owned();
164        let base_version = entry.get("baseVersion").and_then(Value::as_i64);
165        match op {
166            "upsert" => {
167                let mut values = entry
168                    .get("values")
169                    .and_then(Value::as_object)
170                    .cloned()
171                    .ok_or_else(|| "upsert missing values".to_owned())?;
172                decode_bigint_members(&mut values)?;
173                out.push(Mutation::Upsert {
174                    table,
175                    values,
176                    base_version,
177                });
178            }
179            "delete" => {
180                let row_id = entry
181                    .get("rowId")
182                    .and_then(Value::as_str)
183                    .ok_or_else(|| "delete missing rowId".to_owned())?
184                    .to_owned();
185                out.push(Mutation::Delete {
186                    table,
187                    row_id,
188                    base_version,
189                });
190            }
191            other => return Err(format!("unknown mutation op {other:?}")),
192        }
193    }
194    Ok(out)
195}
196
197fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
198    for value in values.values_mut() {
199        let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
200            continue;
201        };
202        let integer = decimal
203            .parse::<i64>()
204            .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
205        *value = Value::from(integer);
206    }
207    Ok(())
208}
209
210fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
211    match value {
212        Some(v) => syncular_client::values::json_to_scope_map(v),
213        None => Ok(Vec::new()),
214    }
215}
216
217/// §4.8: parse a window base descriptor `{ table, variable, fixedScopes?,
218/// params? }` from a command's `base` param.
219fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
220    let object = value
221        .and_then(Value::as_object)
222        .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
223    let table = object
224        .get("table")
225        .and_then(Value::as_str)
226        .ok_or_else(|| "window base missing table".to_owned())?
227        .to_owned();
228    let variable = object
229        .get("variable")
230        .and_then(Value::as_str)
231        .ok_or_else(|| "window base missing variable".to_owned())?
232        .to_owned();
233    let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
234    let params = object
235        .get("params")
236        .and_then(Value::as_str)
237        .map(str::to_owned);
238    Ok(WindowBase {
239        table,
240        variable,
241        fixed_scopes,
242        params,
243    })
244}
245
246/// §5.10.5: parse the common `(table, rowId, column, name)` target of a crdt
247/// command. `name` selects the shared type inside the doc (default `"text"`,
248/// matching the TS `YjsColumn.text()` default).
249#[cfg(feature = "crdt-yjs")]
250fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
251    let table = params
252        .get("table")
253        .and_then(Value::as_str)
254        .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
255        .to_owned();
256    let row_id = params
257        .get("rowId")
258        .and_then(Value::as_str)
259        .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
260        .to_owned();
261    let column = params
262        .get("column")
263        .and_then(Value::as_str)
264        .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
265        .to_owned();
266    let name = params
267        .get("name")
268        .and_then(Value::as_str)
269        .unwrap_or("text")
270        .to_owned();
271    Ok((table, row_id, column, name))
272}
273
274/// Dispatch one command against the client instance over `transport`.
275///
276/// The `create` command installs a fresh `SyncClient` into `client` and
277/// returns its parsed `CreateEffects` in the `Ok` result via `effects`; every
278/// other command mutates the existing instance. Errors come back as the
279/// driver-protocol `(code, message)` pair.
280///
281/// Generic over `T: Transport` so the shim (host-inverted transport) and the
282/// FFI core (native HTTP+WS transport) share this exact router.
283pub fn dispatch<T: Transport>(
284    transport: &mut T,
285    client: &mut Option<SyncClient>,
286    effects: &mut CreateEffects,
287    method: &str,
288    params: &Value,
289) -> Result<Value, CommandError> {
290    if method != "create" {
291        let allowed_during_preflight = matches!(
292            method,
293            "securityLifecycle"
294                | "beginSecurityPreflight"
295                | "activateSecurity"
296                | "purgeLocalData"
297                | "localRevision"
298                | "statusSnapshot"
299                | "shutdown"
300        );
301        if client
302            .as_ref()
303            .is_some_and(|running| running.security_preflight())
304            && !allowed_during_preflight
305        {
306            return Err((
307                syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
308                "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data".to_owned(),
309            ));
310        }
311    }
312    match method {
313        "create" => {
314            let client_id = params
315                .get("clientId")
316                .and_then(Value::as_str)
317                .map(str::to_owned);
318            let schema = params
319                .get("schema")
320                .ok_or_else(|| client_err("create missing schema".to_owned()))?;
321            let limits = parse_limits(params.get("limits"));
322            // §native: a `dbPath` installs a file-backed rusqlite connection so
323            // native hosts (Tauri plugin, FFI file variant) persist across
324            // restarts; absent it, the default in-memory core (the shim's mode).
325            let mut instance = match params.get("dbPath").and_then(Value::as_str) {
326                Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
327                    .map_err(client_err)?,
328                None => {
329                    SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
330                }
331            };
332            // Harness clock pin (§5.4 expiry runs on the virtual clock).
333            if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
334                instance.set_now_ms(now_ms);
335            }
336            // §5.4 capability of the host endpoint set (accept bit 3) — the
337            // host applies it to its own transport.
338            effects.signed_urls = params
339                .get("signedUrls")
340                .and_then(Value::as_bool)
341                .unwrap_or(false);
342            // §5.11: install client-side encryption keys. Shape:
343            // { encryption: { keys: { "<keyId>": {"$bytes": "<hex>"} },
344            //                 keyIdColumns: { "<table>": "<column>" } } }.
345            let security_preflight = params
346                .get("securityPreflight")
347                .and_then(Value::as_bool)
348                .unwrap_or(false);
349            if security_preflight && params.get("encryption").is_some() {
350                return Err(client_err(
351                    "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
352                        .to_owned(),
353                ));
354            }
355            if let Some(enc) = params.get("encryption") {
356                let config = parse_encryption(enc).map_err(client_err)?;
357                instance.set_encryption(config);
358            }
359            if security_preflight {
360                instance.begin_security_preflight();
361            }
362            *client = Some(instance);
363            Ok(json!({}))
364        }
365        "securityLifecycle" => Ok(json!({
366            "state": need_client(client)?.security_lifecycle()
367        })),
368        "beginSecurityPreflight" => {
369            let running = need_client(client)?;
370            running.disconnect_realtime(transport);
371            running.begin_security_preflight();
372            Ok(json!({}))
373        }
374        "activateSecurity" => {
375            let encryption = match params.get("encryption") {
376                Some(value) => parse_encryption(value).map_err(client_err)?,
377                None => syncular_client::values::EncryptionConfig::default(),
378            };
379            need_client(client)?
380                .activate_security(encryption)
381                .map_err(client_err)?;
382            Ok(json!({}))
383        }
384        "shutdown" => {
385            if let Some(running) = client.as_mut() {
386                running.disconnect_realtime(transport);
387                running.begin_security_preflight();
388            }
389            *client = None;
390            Ok(json!({}))
391        }
392        "subscribe" => {
393            let id = params
394                .get("id")
395                .and_then(Value::as_str)
396                .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
397                .to_owned();
398            let table = params
399                .get("table")
400                .and_then(Value::as_str)
401                .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
402                .to_owned();
403            let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
404            let sub_params = params
405                .get("params")
406                .and_then(Value::as_str)
407                .map(str::to_owned);
408            need_client(client)?
409                .subscribe(id, table, scopes, sub_params)
410                .map_err(client_err)?;
411            Ok(json!({}))
412        }
413        "unsubscribe" => {
414            let id = params
415                .get("id")
416                .and_then(Value::as_str)
417                .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
418            need_client(client)?.unsubscribe(id);
419            Ok(json!({}))
420        }
421        "setWindow" => {
422            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
423            let units: Vec<String> = params
424                .get("units")
425                .and_then(Value::as_array)
426                .map(|arr| {
427                    arr.iter()
428                        .filter_map(|v| v.as_str().map(str::to_owned))
429                        .collect()
430                })
431                .unwrap_or_default();
432            let command_effects = need_client(client)?
433                .set_window(&base, &units)
434                .map_err(client_err)?;
435            Ok(json!({ "effects": command_effects }))
436        }
437        "windowState" => {
438            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
439            let state = need_client(client)?.window_state(&base);
440            Ok(json!({ "units": state.units, "pending": state.pending }))
441        }
442        "mutate" => {
443            let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
444            let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
445            Ok(json!({
446                "clientCommitId": id,
447                "effects": CommandEffects::interactive()
448            }))
449        }
450        "patch" => {
451            let table = params
452                .get("table")
453                .and_then(Value::as_str)
454                .ok_or_else(|| client_err("patch missing table".to_owned()))?;
455            let row_id = params
456                .get("rowId")
457                .and_then(Value::as_str)
458                .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
459            let mut partial = params
460                .get("partial")
461                .and_then(Value::as_object)
462                .cloned()
463                .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
464            decode_bigint_members(&mut partial).map_err(client_err)?;
465            let base_version = params.get("baseVersion").and_then(Value::as_i64);
466            let id = need_client(client)?
467                .patch(table, row_id, partial, base_version)
468                .map_err(client_err)?;
469            Ok(json!({
470                "clientCommitId": id,
471                "effects": CommandEffects::interactive()
472            }))
473        }
474        "purgeLocalData" => {
475            let input: LocalDataPurgeInput =
476                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
477                    client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
478                })?)
479                .map_err(|error| {
480                    client_err(format!(
481                        "sync.invalid_request: invalid purgeLocalData input: {error}"
482                    ))
483                })?;
484            let result = need_client(client)?
485                .purge_local_data(&input)
486                .map_err(client_err)?;
487            serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
488        }
489        "sync" => {
490            let outcome = need_client(client)?.sync(transport);
491            Ok(outcome.to_json())
492        }
493        "syncUntilIdle" => {
494            let max_rounds = params
495                .get("maxRounds")
496                .and_then(Value::as_u64)
497                .map(|v| v as u32);
498            let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
499            Ok(outcome.to_json())
500        }
501        "readRows" => {
502            let table = params
503                .get("table")
504                .and_then(Value::as_str)
505                .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
506            let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
507            Ok(json!({ "rows": rows }))
508        }
509        "query" => {
510            // The React `useSyncQuery` live-query fast path: arbitrary read-only
511            // SQL over the local visible tables/views. Params ride as the driver
512            // value forms (bytes as `{"$bytes": hex}`); rows come back the same.
513            let sql = params
514                .get("sql")
515                .and_then(Value::as_str)
516                .ok_or_else(|| client_err("query missing sql".to_owned()))?;
517            let bind = match params.get("params") {
518                Some(Value::Array(list)) => list.clone(),
519                None | Some(Value::Null) => Vec::new(),
520                Some(_) => return Err(client_err("query params must be a list".to_owned())),
521            };
522            let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
523            Ok(json!({ "rows": rows }))
524        }
525        "querySnapshot" => {
526            let sql = params
527                .get("sql")
528                .and_then(Value::as_str)
529                .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
530            let bind = match params.get("params") {
531                Some(Value::Array(list)) => list.clone(),
532                None | Some(Value::Null) => Vec::new(),
533                Some(_) => {
534                    return Err(client_err("querySnapshot params must be a list".to_owned()))
535                }
536            };
537            let mut coverage = Vec::new();
538            for entry in params
539                .get("coverage")
540                .and_then(Value::as_array)
541                .into_iter()
542                .flatten()
543            {
544                let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
545                let units = entry
546                    .get("units")
547                    .and_then(Value::as_array)
548                    .map(|values| {
549                        values
550                            .iter()
551                            .filter_map(|value| value.as_str().map(str::to_owned))
552                            .collect()
553                    })
554                    .unwrap_or_default();
555                coverage.push(WindowCoverage { base, units });
556            }
557            let snapshot = need_client(client)?
558                .query_snapshot(sql, &bind, &coverage)
559                .map_err(client_err)?;
560            Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
561        }
562        "localRevision" => Ok(json!({
563            "revision": need_client(client)?.local_revision().to_string()
564        })),
565        "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
566            .expect("status serializes")),
567        "diagnosticsSnapshot" => {
568            let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
569                .map_err(|error| {
570                    client_err(format!(
571                        "sync.invalid_request: invalid diagnostics request: {error}"
572                    ))
573                })?;
574            let snapshot = need_client(client)?
575                .diagnostics_snapshot(&request)
576                .map_err(client_err)?;
577            Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
578        }
579        // Conformance/debug drains. Production hosts normally drain these
580        // immediately after every command, but exposing the exact core output
581        // here lets both client implementations consume one observation
582        // vector catalog without bridge inference.
583        "drainChangeBatches" => Ok(json!({
584            "batches": need_client(client)?.drain_change_batches()
585        })),
586        "drainSyncIntents" => Ok(json!({
587            "intents": need_client(client)?.drain_sync_intents()
588        })),
589        // -- §5.10.5 native CRDT (the `crdt-yjs` feature) -----------------------
590        // Thin forwards to the client core's yrs helpers. The command surface
591        // stays present-but-unavailable in a lean build: without the feature
592        // these fail loudly (`client.crdt_unavailable`) rather than being an
593        // unknown method, so a wrapper's typed method gives a clear error.
594        #[cfg(feature = "crdt-yjs")]
595        "crdtText" => {
596            let (table, row_id, column, name) = crdt_target(params)?;
597            let text = need_client(client)?
598                .crdt_text(&table, &row_id, &column, &name)
599                .map_err(client_err)?;
600            Ok(json!({ "text": text }))
601        }
602        #[cfg(feature = "crdt-yjs")]
603        "crdtInsertText" => {
604            let (table, row_id, column, name) = crdt_target(params)?;
605            let index = params
606                .get("index")
607                .and_then(Value::as_u64)
608                .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
609                as u32;
610            let value = params
611                .get("value")
612                .and_then(Value::as_str)
613                .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
614            let id = need_client(client)?
615                .crdt_insert_text(&table, &row_id, &column, &name, index, value)
616                .map_err(client_err)?;
617            Ok(json!({ "clientCommitId": id }))
618        }
619        #[cfg(feature = "crdt-yjs")]
620        "crdtDeleteText" => {
621            let (table, row_id, column, name) = crdt_target(params)?;
622            let index = params
623                .get("index")
624                .and_then(Value::as_u64)
625                .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
626                as u32;
627            let len = params
628                .get("len")
629                .and_then(Value::as_u64)
630                .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
631                as u32;
632            let id = need_client(client)?
633                .crdt_delete_text(&table, &row_id, &column, &name, index, len)
634                .map_err(client_err)?;
635            Ok(json!({ "clientCommitId": id }))
636        }
637        #[cfg(feature = "crdt-yjs")]
638        "crdtApplyUpdate" => {
639            let (table, row_id, column, _name) = crdt_target(params)?;
640            let update = value_bytes(params.get("update")).map_err(client_err)?;
641            let id = need_client(client)?
642                .crdt_apply_update(&table, &row_id, &column, &update)
643                .map_err(client_err)?;
644            Ok(json!({ "clientCommitId": id }))
645        }
646        #[cfg(not(feature = "crdt-yjs"))]
647        "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
648            "client.crdt_unavailable".to_owned(),
649            "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
650        )),
651
652        "uploadBlob" => {
653            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
654            let media_type = params
655                .get("mediaType")
656                .and_then(Value::as_str)
657                .map(str::to_owned);
658            let name = params
659                .get("name")
660                .and_then(Value::as_str)
661                .map(str::to_owned);
662            let reference = need_client(client)?
663                .upload_blob(&bytes, media_type, name)
664                .map_err(client_err)?;
665            Ok(json!({ "ref": reference }))
666        }
667        "fetchBlob" => {
668            let blob = params
669                .get("blob")
670                .and_then(Value::as_str)
671                .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
672                .to_owned();
673            // fetch_blob returns (code, message) so the server's blob.* code
674            // reaches the caller (§5.9.5 cross-scope probe).
675            let value = need_client(client)?.fetch_blob(transport, &blob)?;
676            Ok(json!({ "blob": value }))
677        }
678        "conflicts" => {
679            let conflicts = need_client(client)?.conflicts().to_vec();
680            Ok(json!({ "conflicts": conflicts }))
681        }
682        "rejections" => {
683            let rejections = need_client(client)?.rejections().to_vec();
684            Ok(json!({ "rejections": rejections }))
685        }
686        "commitOutcome" => {
687            let client_commit_id = params
688                .get("clientCommitId")
689                .and_then(Value::as_str)
690                .ok_or_else(|| {
691                    client_err(
692                        "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
693                    )
694                })?;
695            let outcome = need_client(client)?
696                .commit_outcome(client_commit_id)
697                .map_err(client_err)?;
698            Ok(json!({ "outcome": outcome }))
699        }
700        "commitOutcomes" => {
701            let query = serde_json::from_value::<CommitOutcomeQuery>(
702                params.get("query").cloned().unwrap_or_else(|| json!({})),
703            )
704            .map_err(|error| {
705                client_err(format!(
706                    "sync.invalid_request: invalid commit outcome query: {error}"
707                ))
708            })?;
709            let outcomes = need_client(client)?
710                .commit_outcomes(query)
711                .map_err(client_err)?;
712            Ok(json!({ "outcomes": outcomes }))
713        }
714        "resolveCommitOutcome" => {
715            let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
716                params.get("input").cloned().ok_or_else(|| {
717                    client_err(
718                        "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
719                    )
720                })?,
721            )
722            .map_err(|error| {
723                client_err(format!(
724                    "sync.invalid_request: invalid outcome resolution: {error}"
725                ))
726            })?;
727            let outcome = need_client(client)?
728                .resolve_commit_outcome(input)
729                .map_err(client_err)?;
730            Ok(json!({ "outcome": outcome }))
731        }
732        "pendingCommitIds" => {
733            let ids = need_client(client)?.pending_commit_ids();
734            Ok(json!({ "ids": ids }))
735        }
736        "subscriptionState" => {
737            let id = params
738                .get("id")
739                .and_then(Value::as_str)
740                .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
741            let state = need_client(client)?.subscription_state(id);
742            Ok(json!({ "state": state }))
743        }
744        "schemaFloor" => {
745            let floor = need_client(client)?.schema_floor().cloned();
746            Ok(json!({ "floor": floor }))
747        }
748        "leaseState" => {
749            let lease = need_client(client)?.lease_state().cloned();
750            Ok(json!({ "lease": lease }))
751        }
752        "upgrading" => {
753            // §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
754            let value = need_client(client)?.upgrading();
755            Ok(json!({ "value": value }))
756        }
757        "recreateWithSchema" => {
758            // §7.4.2 "app ships new code": swap to the new schema on the SAME
759            // in-memory database (the Rust core has no persistent restart, so
760            // recreation IS the boot). Fires the §7.4.1 marker check.
761            let schema = params
762                .get("schema")
763                .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
764            need_client(client)?
765                .recreate_with_schema(schema)
766                .map_err(client_err)?;
767            Ok(json!({}))
768        }
769        "connectRealtime" => {
770            need_client(client)?
771                .connect_realtime(transport)
772                .map_err(client_err)?;
773            Ok(json!({}))
774        }
775        "disconnectRealtime" => {
776            need_client(client)?.disconnect_realtime(transport);
777            Ok(json!({}))
778        }
779        "syncNeeded" => {
780            let value = need_client(client)?.sync_needed();
781            Ok(json!({ "value": value }))
782        }
783        "setPresence" => {
784            let scope_key = params
785                .get("scopeKey")
786                .and_then(Value::as_str)
787                .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
788                .to_owned();
789            // §8.6.2: `doc` may be a JSON object or null (a leave).
790            let doc = params.get("doc");
791            let doc_ref = match doc {
792                None | Some(Value::Null) => None,
793                Some(v) => Some(v),
794            };
795            need_client(client)?
796                .set_presence(transport, &scope_key, doc_ref)
797                .map_err(client_err)?;
798            Ok(json!({}))
799        }
800        "presence" => {
801            let scope_key = params
802                .get("scopeKey")
803                .and_then(Value::as_str)
804                .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
805            let peers = need_client(client)?.presence(scope_key);
806            Ok(json!({ "peers": peers }))
807        }
808
809        // -- CodecDriver surface (Appendix A) — no client instance needed --
810        "messageRoundtrip" => {
811            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
812            match decode_message(&bytes) {
813                Ok(message) => Ok(json!({
814                    "ok": true,
815                    "bytes": bytes_value(&encode_message(&message)),
816                    "renderedJson": render_message(&message).to_string(),
817                })),
818                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
819            }
820        }
821        "segmentRoundtrip" => {
822            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
823            match decode_rows_segment(&bytes) {
824                Ok(segment) => Ok(json!({
825                    "ok": true,
826                    "bytes": bytes_value(&encode_rows_segment(&segment)),
827                    "renderedJson": render_rows_segment(&segment).to_string(),
828                })),
829                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
830            }
831        }
832        "realtimeKnown" => {
833            let text = params
834                .get("text")
835                .and_then(Value::as_str)
836                .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
837            let known = matches!(
838                parse_control(text),
839                Ok(ControlMessage::Hello { .. })
840                    | Ok(ControlMessage::Wake { .. })
841                    | Ok(ControlMessage::Heartbeat { .. })
842                    | Ok(ControlMessage::Presence { .. })
843            );
844            Ok(json!({ "value": known }))
845        }
846
847        other => Err(client_err(format!("unknown method {other:?}"))),
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use serde_json::{json, Value};
854    use syncular_client::{
855        SegmentRequest, SyncClient, Transport, TransportError, SECURITY_PREFLIGHT_REQUIRED_CODE,
856    };
857
858    use super::{dispatch, parse_encryption, CreateEffects};
859
860    struct NoNetwork;
861
862    impl Transport for NoNetwork {
863        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
864            Err(TransportError::new("sync.transport_failed", "offline"))
865        }
866
867        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
868            Err(TransportError::new("sync.transport_failed", "offline"))
869        }
870
871        fn download_segment(
872            &mut self,
873            _request: &SegmentRequest,
874        ) -> Result<Vec<u8>, TransportError> {
875            Err(TransportError::new("sync.transport_failed", "offline"))
876        }
877
878        fn realtime_connect(&mut self) -> Result<(), TransportError> {
879            Ok(())
880        }
881
882        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
883            Ok(())
884        }
885
886        fn realtime_close(&mut self) -> Result<(), TransportError> {
887            Ok(())
888        }
889    }
890
891    fn schema() -> Value {
892        json!({
893            "version": 1,
894            "tables": [{
895                "name": "todos",
896                "columns": [
897                    { "name": "id", "type": "string", "nullable": false },
898                    { "name": "list_id", "type": "string", "nullable": false }
899                ],
900                "primaryKey": "id",
901                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
902            }]
903        })
904    }
905
906    #[test]
907    fn parses_portable_encryption_keyring_and_key_id_columns() {
908        let key_hex = "2a".repeat(32);
909        let config = parse_encryption(&json!({
910            "keys": { "practice-key-v1": { "$bytes": key_hex } },
911            "keyIdColumns": { "patients": "encryption_key_id" }
912        }))
913        .expect("portable keyring parses");
914        assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
915        assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
916    }
917
918    #[test]
919    fn rejects_non_string_key_id_columns() {
920        let error = parse_encryption(&json!({
921            "keys": {},
922            "keyIdColumns": { "patients": 7 }
923        }))
924        .expect_err("invalid selector must fail");
925        assert!(error.contains("must be a string"), "{error}");
926    }
927
928    #[test]
929    fn native_command_hosts_reject_subscription_identity_rebinds() {
930        let mut transport = NoNetwork;
931        let mut client: Option<SyncClient> = None;
932        let mut effects = CreateEffects::default();
933        dispatch(
934            &mut transport,
935            &mut client,
936            &mut effects,
937            "create",
938            &json!({ "schema": schema() }),
939        )
940        .expect("create client");
941
942        let original = json!({
943            "id": "stable-subscription",
944            "table": "todos",
945            "scopes": { "list_id": ["list-2", "list-1"] },
946            "params": "{\"view\":\"v1\"}"
947        });
948        dispatch(
949            &mut transport,
950            &mut client,
951            &mut effects,
952            "subscribe",
953            &original,
954        )
955        .expect("register subscription");
956        dispatch(
957            &mut transport,
958            &mut client,
959            &mut effects,
960            "subscribe",
961            &json!({
962                "id": "stable-subscription",
963                "table": "todos",
964                "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
965                "params": "{\"view\":\"v1\"}"
966            }),
967        )
968        .expect("canonical re-declaration is idempotent");
969
970        let error = dispatch(
971            &mut transport,
972            &mut client,
973            &mut effects,
974            "subscribe",
975            &json!({
976                "id": "stable-subscription",
977                "table": "todos",
978                "scopes": { "list_id": ["list-2"] },
979                "params": "{\"view\":\"v1\"}"
980            }),
981        )
982        .expect_err("changed native query identity must fail");
983        assert_eq!(error.0, "client.subscription_intent_mismatch");
984
985        let state = dispatch(
986            &mut transport,
987            &mut client,
988            &mut effects,
989            "subscriptionState",
990            &json!({ "id": "stable-subscription" }),
991        )
992        .expect("subscription state");
993        assert_eq!(state["state"]["cursor"], -1);
994        assert_eq!(state["state"]["table"], "todos");
995    }
996
997    #[test]
998    fn security_preflight_is_fail_closed_until_exact_activation() {
999        let mut transport = NoNetwork;
1000        let mut client: Option<SyncClient> = None;
1001        let mut effects = CreateEffects::default();
1002        dispatch(
1003            &mut transport,
1004            &mut client,
1005            &mut effects,
1006            "create",
1007            &json!({ "schema": schema(), "securityPreflight": true }),
1008        )
1009        .expect("preflight create");
1010
1011        let lifecycle = dispatch(
1012            &mut transport,
1013            &mut client,
1014            &mut effects,
1015            "securityLifecycle",
1016            &json!({}),
1017        )
1018        .expect("lifecycle");
1019        assert_eq!(lifecycle, json!({ "state": "preflight" }));
1020
1021        let query_error = dispatch(
1022            &mut transport,
1023            &mut client,
1024            &mut effects,
1025            "query",
1026            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1027        )
1028        .expect_err("protected query must fail");
1029        assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1030        let diagnostics_error = dispatch(
1031            &mut transport,
1032            &mut client,
1033            &mut effects,
1034            "diagnosticsSnapshot",
1035            &json!({}),
1036        )
1037        .expect_err("diagnostics table/subscription evidence remains protected");
1038        assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1039
1040        dispatch(
1041            &mut transport,
1042            &mut client,
1043            &mut effects,
1044            "purgeLocalData",
1045            &json!({
1046                "input": {
1047                    "purgeId": "directive-1",
1048                    "targets": [{
1049                        "table": "todos",
1050                        "selectors": { "list_id": ["list-1"] }
1051                    }]
1052                }
1053            }),
1054        )
1055        .expect("authorized local purge remains available");
1056
1057        dispatch(
1058            &mut transport,
1059            &mut client,
1060            &mut effects,
1061            "activateSecurity",
1062            &json!({}),
1063        )
1064        .expect("activation");
1065        let rows = dispatch(
1066            &mut transport,
1067            &mut client,
1068            &mut effects,
1069            "query",
1070            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1071        )
1072        .expect("active query");
1073        assert_eq!(rows, json!({ "rows": [] }));
1074
1075        dispatch(
1076            &mut transport,
1077            &mut client,
1078            &mut effects,
1079            "beginSecurityPreflight",
1080            &json!({}),
1081        )
1082        .expect("re-enter preflight");
1083        let blocked = dispatch(
1084            &mut transport,
1085            &mut client,
1086            &mut effects,
1087            "mutate",
1088            &json!({ "mutations": [] }),
1089        )
1090        .expect_err("mutation must be gated");
1091        assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1092    }
1093}