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, LocalDataRebootstrapInput, Mutation, ResolveCommitOutcomeInput,
25    SyncClient, Transport, WindowBase, 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        "rebootstrapLocalData" => {
490            let input: LocalDataRebootstrapInput =
491                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
492                    client_err(
493                        "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
494                    )
495                })?)
496                .map_err(|error| {
497                    client_err(format!(
498                        "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
499                    ))
500                })?;
501            let result = need_client(client)?
502                .rebootstrap_local_data(&input)
503                .map_err(client_err)?;
504            Ok(json!({
505                "alreadyApplied": result.already_applied,
506                "retainedCommits": result.retained_commits,
507                "resetSubscriptions": result.reset_subscriptions,
508                "effects": if result.already_applied {
509                    CommandEffects::none()
510                } else {
511                    CommandEffects::interactive()
512                }
513            }))
514        }
515        "sync" => {
516            let outcome = need_client(client)?.sync(transport);
517            Ok(outcome.to_json())
518        }
519        "syncUntilIdle" => {
520            let max_rounds = params
521                .get("maxRounds")
522                .and_then(Value::as_u64)
523                .map(|v| v as u32);
524            let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
525            Ok(outcome.to_json())
526        }
527        "readRows" => {
528            let table = params
529                .get("table")
530                .and_then(Value::as_str)
531                .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
532            let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
533            Ok(json!({ "rows": rows }))
534        }
535        "query" => {
536            // The React `useSyncQuery` live-query fast path: arbitrary read-only
537            // SQL over the local visible tables/views. Params ride as the driver
538            // value forms (bytes as `{"$bytes": hex}`); rows come back the same.
539            let sql = params
540                .get("sql")
541                .and_then(Value::as_str)
542                .ok_or_else(|| client_err("query missing sql".to_owned()))?;
543            let bind = match params.get("params") {
544                Some(Value::Array(list)) => list.clone(),
545                None | Some(Value::Null) => Vec::new(),
546                Some(_) => return Err(client_err("query params must be a list".to_owned())),
547            };
548            let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
549            Ok(json!({ "rows": rows }))
550        }
551        "querySnapshot" => {
552            let sql = params
553                .get("sql")
554                .and_then(Value::as_str)
555                .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
556            let bind = match params.get("params") {
557                Some(Value::Array(list)) => list.clone(),
558                None | Some(Value::Null) => Vec::new(),
559                Some(_) => {
560                    return Err(client_err("querySnapshot params must be a list".to_owned()))
561                }
562            };
563            let mut coverage = Vec::new();
564            for entry in params
565                .get("coverage")
566                .and_then(Value::as_array)
567                .into_iter()
568                .flatten()
569            {
570                let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
571                let units = entry
572                    .get("units")
573                    .and_then(Value::as_array)
574                    .map(|values| {
575                        values
576                            .iter()
577                            .filter_map(|value| value.as_str().map(str::to_owned))
578                            .collect()
579                    })
580                    .unwrap_or_default();
581                coverage.push(WindowCoverage { base, units });
582            }
583            let snapshot = need_client(client)?
584                .query_snapshot(sql, &bind, &coverage)
585                .map_err(client_err)?;
586            Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
587        }
588        "localRevision" => Ok(json!({
589            "revision": need_client(client)?.local_revision().to_string()
590        })),
591        "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
592            .expect("status serializes")),
593        "diagnosticsSnapshot" => {
594            let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
595                .map_err(|error| {
596                    client_err(format!(
597                        "sync.invalid_request: invalid diagnostics request: {error}"
598                    ))
599                })?;
600            let snapshot = need_client(client)?
601                .diagnostics_snapshot(&request)
602                .map_err(client_err)?;
603            Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
604        }
605        // Conformance/debug drains. Production hosts normally drain these
606        // immediately after every command, but exposing the exact core output
607        // here lets both client implementations consume one observation
608        // vector catalog without bridge inference.
609        "drainChangeBatches" => Ok(json!({
610            "batches": need_client(client)?.drain_change_batches()
611        })),
612        "drainSyncIntents" => Ok(json!({
613            "intents": need_client(client)?.drain_sync_intents()
614        })),
615        // -- §5.10.5 native CRDT (the `crdt-yjs` feature) -----------------------
616        // Thin forwards to the client core's yrs helpers. The command surface
617        // stays present-but-unavailable in a lean build: without the feature
618        // these fail loudly (`client.crdt_unavailable`) rather than being an
619        // unknown method, so a wrapper's typed method gives a clear error.
620        #[cfg(feature = "crdt-yjs")]
621        "crdtText" => {
622            let (table, row_id, column, name) = crdt_target(params)?;
623            let text = need_client(client)?
624                .crdt_text(&table, &row_id, &column, &name)
625                .map_err(client_err)?;
626            Ok(json!({ "text": text }))
627        }
628        #[cfg(feature = "crdt-yjs")]
629        "crdtInsertText" => {
630            let (table, row_id, column, name) = crdt_target(params)?;
631            let index = params
632                .get("index")
633                .and_then(Value::as_u64)
634                .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
635                as u32;
636            let value = params
637                .get("value")
638                .and_then(Value::as_str)
639                .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
640            let id = need_client(client)?
641                .crdt_insert_text(&table, &row_id, &column, &name, index, value)
642                .map_err(client_err)?;
643            Ok(json!({ "clientCommitId": id }))
644        }
645        #[cfg(feature = "crdt-yjs")]
646        "crdtDeleteText" => {
647            let (table, row_id, column, name) = crdt_target(params)?;
648            let index = params
649                .get("index")
650                .and_then(Value::as_u64)
651                .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
652                as u32;
653            let len = params
654                .get("len")
655                .and_then(Value::as_u64)
656                .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
657                as u32;
658            let id = need_client(client)?
659                .crdt_delete_text(&table, &row_id, &column, &name, index, len)
660                .map_err(client_err)?;
661            Ok(json!({ "clientCommitId": id }))
662        }
663        #[cfg(feature = "crdt-yjs")]
664        "crdtApplyUpdate" => {
665            let (table, row_id, column, _name) = crdt_target(params)?;
666            let update = value_bytes(params.get("update")).map_err(client_err)?;
667            let id = need_client(client)?
668                .crdt_apply_update(&table, &row_id, &column, &update)
669                .map_err(client_err)?;
670            Ok(json!({ "clientCommitId": id }))
671        }
672        #[cfg(not(feature = "crdt-yjs"))]
673        "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
674            "client.crdt_unavailable".to_owned(),
675            "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
676        )),
677
678        "uploadBlob" => {
679            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
680            let media_type = params
681                .get("mediaType")
682                .and_then(Value::as_str)
683                .map(str::to_owned);
684            let name = params
685                .get("name")
686                .and_then(Value::as_str)
687                .map(str::to_owned);
688            let reference = need_client(client)?
689                .upload_blob(&bytes, media_type, name)
690                .map_err(client_err)?;
691            Ok(json!({ "ref": reference }))
692        }
693        "fetchBlob" => {
694            let blob = params
695                .get("blob")
696                .and_then(Value::as_str)
697                .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
698                .to_owned();
699            // fetch_blob returns (code, message) so the server's blob.* code
700            // reaches the caller (§5.9.5 cross-scope probe).
701            let value = need_client(client)?.fetch_blob(transport, &blob)?;
702            Ok(json!({ "blob": value }))
703        }
704        "conflicts" => {
705            let conflicts = need_client(client)?.conflicts().to_vec();
706            Ok(json!({ "conflicts": conflicts }))
707        }
708        "rejections" => {
709            let rejections = need_client(client)?.rejections().to_vec();
710            Ok(json!({ "rejections": rejections }))
711        }
712        "commitOutcome" => {
713            let client_commit_id = params
714                .get("clientCommitId")
715                .and_then(Value::as_str)
716                .ok_or_else(|| {
717                    client_err(
718                        "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
719                    )
720                })?;
721            let outcome = need_client(client)?
722                .commit_outcome(client_commit_id)
723                .map_err(client_err)?;
724            Ok(json!({ "outcome": outcome }))
725        }
726        "commitOutcomes" => {
727            let query = serde_json::from_value::<CommitOutcomeQuery>(
728                params.get("query").cloned().unwrap_or_else(|| json!({})),
729            )
730            .map_err(|error| {
731                client_err(format!(
732                    "sync.invalid_request: invalid commit outcome query: {error}"
733                ))
734            })?;
735            let outcomes = need_client(client)?
736                .commit_outcomes(query)
737                .map_err(client_err)?;
738            Ok(json!({ "outcomes": outcomes }))
739        }
740        "resolveCommitOutcome" => {
741            let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
742                params.get("input").cloned().ok_or_else(|| {
743                    client_err(
744                        "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
745                    )
746                })?,
747            )
748            .map_err(|error| {
749                client_err(format!(
750                    "sync.invalid_request: invalid outcome resolution: {error}"
751                ))
752            })?;
753            let outcome = need_client(client)?
754                .resolve_commit_outcome(input)
755                .map_err(client_err)?;
756            Ok(json!({ "outcome": outcome }))
757        }
758        "pendingCommitIds" => {
759            let ids = need_client(client)?.pending_commit_ids();
760            Ok(json!({ "ids": ids }))
761        }
762        "subscriptionState" => {
763            let id = params
764                .get("id")
765                .and_then(Value::as_str)
766                .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
767            let state = need_client(client)?.subscription_state(id);
768            Ok(json!({ "state": state }))
769        }
770        "schemaFloor" => {
771            let floor = need_client(client)?.schema_floor().cloned();
772            Ok(json!({ "floor": floor }))
773        }
774        "leaseState" => {
775            let lease = need_client(client)?.lease_state().cloned();
776            Ok(json!({ "lease": lease }))
777        }
778        "upgrading" => {
779            // §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
780            let value = need_client(client)?.upgrading();
781            Ok(json!({ "value": value }))
782        }
783        "recreateWithSchema" => {
784            // §7.4.2 "app ships new code": swap to the new schema on the SAME
785            // in-memory database (the Rust core has no persistent restart, so
786            // recreation IS the boot). Fires the §7.4.1 marker check.
787            let schema = params
788                .get("schema")
789                .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
790            need_client(client)?
791                .recreate_with_schema(schema)
792                .map_err(client_err)?;
793            Ok(json!({}))
794        }
795        "connectRealtime" => {
796            need_client(client)?
797                .connect_realtime(transport)
798                .map_err(client_err)?;
799            Ok(json!({}))
800        }
801        "disconnectRealtime" => {
802            need_client(client)?.disconnect_realtime(transport);
803            Ok(json!({}))
804        }
805        "syncNeeded" => {
806            let value = need_client(client)?.sync_needed();
807            Ok(json!({ "value": value }))
808        }
809        "setPresence" => {
810            let scope_key = params
811                .get("scopeKey")
812                .and_then(Value::as_str)
813                .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
814                .to_owned();
815            // §8.6.2: `doc` may be a JSON object or null (a leave).
816            let doc = params.get("doc");
817            let doc_ref = match doc {
818                None | Some(Value::Null) => None,
819                Some(v) => Some(v),
820            };
821            need_client(client)?
822                .set_presence(transport, &scope_key, doc_ref)
823                .map_err(client_err)?;
824            Ok(json!({}))
825        }
826        "presence" => {
827            let scope_key = params
828                .get("scopeKey")
829                .and_then(Value::as_str)
830                .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
831            let peers = need_client(client)?.presence(scope_key);
832            Ok(json!({ "peers": peers }))
833        }
834
835        // -- CodecDriver surface (Appendix A) — no client instance needed --
836        "messageRoundtrip" => {
837            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
838            match decode_message(&bytes) {
839                Ok(message) => Ok(json!({
840                    "ok": true,
841                    "bytes": bytes_value(&encode_message(&message)),
842                    "renderedJson": render_message(&message).to_string(),
843                })),
844                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
845            }
846        }
847        "segmentRoundtrip" => {
848            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
849            match decode_rows_segment(&bytes) {
850                Ok(segment) => Ok(json!({
851                    "ok": true,
852                    "bytes": bytes_value(&encode_rows_segment(&segment)),
853                    "renderedJson": render_rows_segment(&segment).to_string(),
854                })),
855                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
856            }
857        }
858        "realtimeKnown" => {
859            let text = params
860                .get("text")
861                .and_then(Value::as_str)
862                .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
863            let known = matches!(
864                parse_control(text),
865                Ok(ControlMessage::Hello { .. })
866                    | Ok(ControlMessage::Wake { .. })
867                    | Ok(ControlMessage::Heartbeat { .. })
868                    | Ok(ControlMessage::Presence { .. })
869            );
870            Ok(json!({ "value": known }))
871        }
872
873        other => Err(client_err(format!("unknown method {other:?}"))),
874    }
875}
876
877#[cfg(test)]
878mod tests {
879    use serde_json::{json, Value};
880    use syncular_client::{
881        SegmentRequest, SyncClient, Transport, TransportError, SECURITY_PREFLIGHT_REQUIRED_CODE,
882    };
883
884    use super::{dispatch, parse_encryption, CreateEffects};
885
886    struct NoNetwork;
887
888    impl Transport for NoNetwork {
889        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
890            Err(TransportError::new("sync.transport_failed", "offline"))
891        }
892
893        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
894            Err(TransportError::new("sync.transport_failed", "offline"))
895        }
896
897        fn download_segment(
898            &mut self,
899            _request: &SegmentRequest,
900        ) -> Result<Vec<u8>, TransportError> {
901            Err(TransportError::new("sync.transport_failed", "offline"))
902        }
903
904        fn realtime_connect(&mut self) -> Result<(), TransportError> {
905            Ok(())
906        }
907
908        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
909            Ok(())
910        }
911
912        fn realtime_close(&mut self) -> Result<(), TransportError> {
913            Ok(())
914        }
915    }
916
917    fn schema() -> Value {
918        json!({
919            "version": 1,
920            "tables": [{
921                "name": "todos",
922                "columns": [
923                    { "name": "id", "type": "string", "nullable": false },
924                    { "name": "list_id", "type": "string", "nullable": false }
925                ],
926                "primaryKey": "id",
927                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
928            }]
929        })
930    }
931
932    #[test]
933    fn parses_portable_encryption_keyring_and_key_id_columns() {
934        let key_hex = "2a".repeat(32);
935        let config = parse_encryption(&json!({
936            "keys": { "practice-key-v1": { "$bytes": key_hex } },
937            "keyIdColumns": { "patients": "encryption_key_id" }
938        }))
939        .expect("portable keyring parses");
940        assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
941        assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
942    }
943
944    #[test]
945    fn rejects_non_string_key_id_columns() {
946        let error = parse_encryption(&json!({
947            "keys": {},
948            "keyIdColumns": { "patients": 7 }
949        }))
950        .expect_err("invalid selector must fail");
951        assert!(error.contains("must be a string"), "{error}");
952    }
953
954    #[test]
955    fn native_command_hosts_reject_subscription_identity_rebinds() {
956        let mut transport = NoNetwork;
957        let mut client: Option<SyncClient> = None;
958        let mut effects = CreateEffects::default();
959        dispatch(
960            &mut transport,
961            &mut client,
962            &mut effects,
963            "create",
964            &json!({ "schema": schema() }),
965        )
966        .expect("create client");
967
968        let original = json!({
969            "id": "stable-subscription",
970            "table": "todos",
971            "scopes": { "list_id": ["list-2", "list-1"] },
972            "params": "{\"view\":\"v1\"}"
973        });
974        dispatch(
975            &mut transport,
976            &mut client,
977            &mut effects,
978            "subscribe",
979            &original,
980        )
981        .expect("register subscription");
982        dispatch(
983            &mut transport,
984            &mut client,
985            &mut effects,
986            "subscribe",
987            &json!({
988                "id": "stable-subscription",
989                "table": "todos",
990                "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
991                "params": "{\"view\":\"v1\"}"
992            }),
993        )
994        .expect("canonical re-declaration is idempotent");
995
996        let error = dispatch(
997            &mut transport,
998            &mut client,
999            &mut effects,
1000            "subscribe",
1001            &json!({
1002                "id": "stable-subscription",
1003                "table": "todos",
1004                "scopes": { "list_id": ["list-2"] },
1005                "params": "{\"view\":\"v1\"}"
1006            }),
1007        )
1008        .expect_err("changed native query identity must fail");
1009        assert_eq!(error.0, "client.subscription_intent_mismatch");
1010
1011        let state = dispatch(
1012            &mut transport,
1013            &mut client,
1014            &mut effects,
1015            "subscriptionState",
1016            &json!({ "id": "stable-subscription" }),
1017        )
1018        .expect("subscription state");
1019        assert_eq!(state["state"]["cursor"], -1);
1020        assert_eq!(state["state"]["table"], "todos");
1021    }
1022
1023    #[test]
1024    fn security_preflight_is_fail_closed_until_exact_activation() {
1025        let mut transport = NoNetwork;
1026        let mut client: Option<SyncClient> = None;
1027        let mut effects = CreateEffects::default();
1028        dispatch(
1029            &mut transport,
1030            &mut client,
1031            &mut effects,
1032            "create",
1033            &json!({ "schema": schema(), "securityPreflight": true }),
1034        )
1035        .expect("preflight create");
1036
1037        let lifecycle = dispatch(
1038            &mut transport,
1039            &mut client,
1040            &mut effects,
1041            "securityLifecycle",
1042            &json!({}),
1043        )
1044        .expect("lifecycle");
1045        assert_eq!(lifecycle, json!({ "state": "preflight" }));
1046
1047        let query_error = dispatch(
1048            &mut transport,
1049            &mut client,
1050            &mut effects,
1051            "query",
1052            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1053        )
1054        .expect_err("protected query must fail");
1055        assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1056        let diagnostics_error = dispatch(
1057            &mut transport,
1058            &mut client,
1059            &mut effects,
1060            "diagnosticsSnapshot",
1061            &json!({}),
1062        )
1063        .expect_err("diagnostics table/subscription evidence remains protected");
1064        assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1065
1066        dispatch(
1067            &mut transport,
1068            &mut client,
1069            &mut effects,
1070            "purgeLocalData",
1071            &json!({
1072                "input": {
1073                    "purgeId": "directive-1",
1074                    "targets": [{
1075                        "table": "todos",
1076                        "selectors": { "list_id": ["list-1"] }
1077                    }]
1078                }
1079            }),
1080        )
1081        .expect("authorized local purge remains available");
1082
1083        let repair_error = dispatch(
1084            &mut transport,
1085            &mut client,
1086            &mut effects,
1087            "rebootstrapLocalData",
1088            &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1089        )
1090        .expect_err("projection repair must remain protected during preflight");
1091        assert_eq!(repair_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1092
1093        dispatch(
1094            &mut transport,
1095            &mut client,
1096            &mut effects,
1097            "activateSecurity",
1098            &json!({}),
1099        )
1100        .expect("activation");
1101        let rows = dispatch(
1102            &mut transport,
1103            &mut client,
1104            &mut effects,
1105            "query",
1106            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1107        )
1108        .expect("active query");
1109        assert_eq!(rows, json!({ "rows": [] }));
1110
1111        dispatch(
1112            &mut transport,
1113            &mut client,
1114            &mut effects,
1115            "beginSecurityPreflight",
1116            &json!({}),
1117        )
1118        .expect("re-enter preflight");
1119        let blocked = dispatch(
1120            &mut transport,
1121            &mut client,
1122            &mut effects,
1123            "mutate",
1124            &json!({ "mutations": [] }),
1125        )
1126        .expect_err("mutation must be gated");
1127        assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1128    }
1129}