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::{ClientLimits, Mutation, SyncClient, Transport, WindowBase};
23
24// -- bytes <-> {"$bytes": hex} (the driver-protocol byte envelope) ----------
25
26pub fn bytes_to_hex(bytes: &[u8]) -> String {
27    syncular_client::values::bytes_to_hex(bytes)
28}
29
30pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
31    syncular_client::values::hex_to_bytes(hex)
32}
33
34pub fn bytes_value(bytes: &[u8]) -> Value {
35    json!({ "$bytes": bytes_to_hex(bytes) })
36}
37
38pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
39    let hex = value
40        .and_then(|v| v.get("$bytes"))
41        .and_then(Value::as_str)
42        .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
43    hex_to_bytes(hex)
44}
45
46/// The `(code, message)` pair the driver protocol carries in an `error`.
47pub type CommandError = (String, String);
48
49/// Parsed side effects of a `create` command that the host must apply to its
50/// own transport/clock (the router stays transport-agnostic). The client is
51/// already installed into the `Option<SyncClient>` slot by `dispatch`.
52#[derive(Debug, Default, Clone)]
53pub struct CreateEffects {
54    /// §5.4 capability the harness/host announced for its endpoints — the
55    /// host sets its transport's `supports_url_fetch` accordingly.
56    pub signed_urls: bool,
57}
58
59fn client_err(message: String) -> CommandError {
60    ("client.failed".to_owned(), message)
61}
62
63fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
64    client
65        .as_mut()
66        .ok_or_else(|| client_err("no client instance created".to_owned()))
67}
68
69pub fn parse_limits(value: Option<&Value>) -> ClientLimits {
70    let mut limits = ClientLimits::default();
71    let Some(object) = value.and_then(Value::as_object) else {
72        return limits;
73    };
74    limits.limit_commits = object
75        .get("limitCommits")
76        .and_then(Value::as_i64)
77        .map(|v| v as i32);
78    limits.limit_snapshot_rows = object
79        .get("limitSnapshotRows")
80        .and_then(Value::as_i64)
81        .map(|v| v as i32);
82    limits.max_snapshot_pages = object
83        .get("maxSnapshotPages")
84        .and_then(Value::as_i64)
85        .map(|v| v as i32);
86    limits.accept = object
87        .get("accept")
88        .and_then(Value::as_u64)
89        .map(|v| v as u8);
90    limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
91    limits
92}
93
94/// §5.11: parse the `encryption` config into the client's key map. Shape:
95/// `{ keys: { "<keyId>": {"$bytes": "<hex>"} } }`. Keys are 32 bytes.
96pub fn parse_encryption(
97    value: &Value,
98) -> Result<syncular_client::values::EncryptionConfig, String> {
99    let mut config = syncular_client::values::EncryptionConfig::default();
100    let Some(keys) = value.get("keys").and_then(Value::as_object) else {
101        return Ok(config);
102    };
103    for (key_id, key_val) in keys {
104        let bytes =
105            value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
106        if bytes.len() != 32 {
107            return Err(format!(
108                "encryption key {key_id:?} must be 32 bytes, got {}",
109                bytes.len()
110            ));
111        }
112        config.keys.insert(key_id.clone(), bytes);
113    }
114    Ok(config)
115}
116
117pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
118    let list = value
119        .and_then(Value::as_array)
120        .ok_or_else(|| "mutations must be a list".to_owned())?;
121    let mut out = Vec::with_capacity(list.len());
122    for entry in list {
123        let op = entry
124            .get("op")
125            .and_then(Value::as_str)
126            .ok_or_else(|| "mutation missing op".to_owned())?;
127        let table = entry
128            .get("table")
129            .and_then(Value::as_str)
130            .ok_or_else(|| "mutation missing table".to_owned())?
131            .to_owned();
132        let base_version = entry.get("baseVersion").and_then(Value::as_i64);
133        match op {
134            "upsert" => {
135                let values = entry
136                    .get("values")
137                    .and_then(Value::as_object)
138                    .cloned()
139                    .ok_or_else(|| "upsert missing values".to_owned())?;
140                out.push(Mutation::Upsert {
141                    table,
142                    values,
143                    base_version,
144                });
145            }
146            "delete" => {
147                let row_id = entry
148                    .get("rowId")
149                    .and_then(Value::as_str)
150                    .ok_or_else(|| "delete missing rowId".to_owned())?
151                    .to_owned();
152                out.push(Mutation::Delete {
153                    table,
154                    row_id,
155                    base_version,
156                });
157            }
158            other => return Err(format!("unknown mutation op {other:?}")),
159        }
160    }
161    Ok(out)
162}
163
164fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
165    match value {
166        Some(v) => syncular_client::values::json_to_scope_map(v),
167        None => Ok(Vec::new()),
168    }
169}
170
171/// §4.8: parse a window base descriptor `{ table, variable, fixedScopes?,
172/// params? }` from a command's `base` param.
173fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
174    let object = value
175        .and_then(Value::as_object)
176        .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
177    let table = object
178        .get("table")
179        .and_then(Value::as_str)
180        .ok_or_else(|| "window base missing table".to_owned())?
181        .to_owned();
182    let variable = object
183        .get("variable")
184        .and_then(Value::as_str)
185        .ok_or_else(|| "window base missing variable".to_owned())?
186        .to_owned();
187    let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
188    let params = object
189        .get("params")
190        .and_then(Value::as_str)
191        .map(str::to_owned);
192    Ok(WindowBase {
193        table,
194        variable,
195        fixed_scopes,
196        params,
197    })
198}
199
200/// §5.10.5: parse the common `(table, rowId, column, name)` target of a crdt
201/// command. `name` selects the shared type inside the doc (default `"text"`,
202/// matching the TS `YjsColumn.text()` default).
203#[cfg(feature = "crdt-yjs")]
204fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
205    let table = params
206        .get("table")
207        .and_then(Value::as_str)
208        .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
209        .to_owned();
210    let row_id = params
211        .get("rowId")
212        .and_then(Value::as_str)
213        .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
214        .to_owned();
215    let column = params
216        .get("column")
217        .and_then(Value::as_str)
218        .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
219        .to_owned();
220    let name = params
221        .get("name")
222        .and_then(Value::as_str)
223        .unwrap_or("text")
224        .to_owned();
225    Ok((table, row_id, column, name))
226}
227
228/// Dispatch one command against the client instance over `transport`.
229///
230/// The `create` command installs a fresh `SyncClient` into `client` and
231/// returns its parsed `CreateEffects` in the `Ok` result via `effects`; every
232/// other command mutates the existing instance. Errors come back as the
233/// driver-protocol `(code, message)` pair.
234///
235/// Generic over `T: Transport` so the shim (host-inverted transport) and the
236/// FFI core (native HTTP+WS transport) share this exact router.
237pub fn dispatch<T: Transport>(
238    transport: &mut T,
239    client: &mut Option<SyncClient>,
240    effects: &mut CreateEffects,
241    method: &str,
242    params: &Value,
243) -> Result<Value, CommandError> {
244    match method {
245        "create" => {
246            let client_id = params
247                .get("clientId")
248                .and_then(Value::as_str)
249                .ok_or_else(|| client_err("create missing clientId".to_owned()))?
250                .to_owned();
251            let schema = params
252                .get("schema")
253                .ok_or_else(|| client_err("create missing schema".to_owned()))?;
254            let limits = parse_limits(params.get("limits"));
255            // §native: a `dbPath` installs a file-backed rusqlite connection so
256            // native hosts (Tauri plugin, FFI file variant) persist across
257            // restarts; absent it, the default in-memory core (the shim's mode).
258            let mut instance = match params.get("dbPath").and_then(Value::as_str) {
259                Some(path) => {
260                    SyncClient::open_path(client_id, schema, limits, path).map_err(client_err)?
261                }
262                None => SyncClient::new(client_id, schema, limits).map_err(client_err)?,
263            };
264            // Harness clock pin (§5.4 expiry runs on the virtual clock).
265            if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
266                instance.set_now_ms(now_ms);
267            }
268            // §5.4 capability of the host endpoint set (accept bit 3) — the
269            // host applies it to its own transport.
270            effects.signed_urls = params
271                .get("signedUrls")
272                .and_then(Value::as_bool)
273                .unwrap_or(false);
274            // §5.11: install client-side encryption keys. Shape:
275            // { encryption: { keys: { "<keyId>": {"$bytes": "<hex>"} } } }.
276            if let Some(enc) = params.get("encryption") {
277                let config = parse_encryption(enc).map_err(client_err)?;
278                instance.set_encryption(config);
279            }
280            *client = Some(instance);
281            Ok(json!({}))
282        }
283        "subscribe" => {
284            let id = params
285                .get("id")
286                .and_then(Value::as_str)
287                .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
288                .to_owned();
289            let table = params
290                .get("table")
291                .and_then(Value::as_str)
292                .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
293                .to_owned();
294            let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
295            let sub_params = params
296                .get("params")
297                .and_then(Value::as_str)
298                .map(str::to_owned);
299            need_client(client)?
300                .subscribe(id, table, scopes, sub_params)
301                .map_err(client_err)?;
302            Ok(json!({}))
303        }
304        "unsubscribe" => {
305            let id = params
306                .get("id")
307                .and_then(Value::as_str)
308                .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
309            need_client(client)?.unsubscribe(id);
310            Ok(json!({}))
311        }
312        "setWindow" => {
313            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
314            let units: Vec<String> = params
315                .get("units")
316                .and_then(Value::as_array)
317                .map(|arr| {
318                    arr.iter()
319                        .filter_map(|v| v.as_str().map(str::to_owned))
320                        .collect()
321                })
322                .unwrap_or_default();
323            need_client(client)?
324                .set_window(&base, &units)
325                .map_err(client_err)?;
326            Ok(json!({}))
327        }
328        "windowState" => {
329            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
330            let units = need_client(client)?.window_state(&base);
331            Ok(json!({ "units": units }))
332        }
333        "mutate" => {
334            let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
335            let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
336            Ok(json!({ "clientCommitId": id }))
337        }
338        "sync" => {
339            let outcome = need_client(client)?.sync(transport);
340            Ok(outcome.to_json())
341        }
342        "syncUntilIdle" => {
343            let max_rounds = params
344                .get("maxRounds")
345                .and_then(Value::as_u64)
346                .map(|v| v as u32);
347            let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
348            Ok(outcome.to_json())
349        }
350        "readRows" => {
351            let table = params
352                .get("table")
353                .and_then(Value::as_str)
354                .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
355            let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
356            Ok(json!({ "rows": rows }))
357        }
358        "query" => {
359            // The React `useSyncQuery` live-query fast path: arbitrary read-only
360            // SQL over the local visible tables/views. Params ride as the driver
361            // value forms (bytes as `{"$bytes": hex}`); rows come back the same.
362            let sql = params
363                .get("sql")
364                .and_then(Value::as_str)
365                .ok_or_else(|| client_err("query missing sql".to_owned()))?;
366            let bind = match params.get("params") {
367                Some(Value::Array(list)) => list.clone(),
368                None | Some(Value::Null) => Vec::new(),
369                Some(_) => return Err(client_err("query params must be a list".to_owned())),
370            };
371            let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
372            Ok(json!({ "rows": rows }))
373        }
374        // -- §5.10.5 native CRDT (the `crdt-yjs` feature) -----------------------
375        // Thin forwards to the client core's yrs helpers. The command surface
376        // stays present-but-unavailable in a lean build: without the feature
377        // these fail loudly (`client.crdt_unavailable`) rather than being an
378        // unknown method, so a wrapper's typed method gives a clear error.
379        #[cfg(feature = "crdt-yjs")]
380        "crdtText" => {
381            let (table, row_id, column, name) = crdt_target(params)?;
382            let text = need_client(client)?
383                .crdt_text(&table, &row_id, &column, &name)
384                .map_err(client_err)?;
385            Ok(json!({ "text": text }))
386        }
387        #[cfg(feature = "crdt-yjs")]
388        "crdtInsertText" => {
389            let (table, row_id, column, name) = crdt_target(params)?;
390            let index = params
391                .get("index")
392                .and_then(Value::as_u64)
393                .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
394                as u32;
395            let value = params
396                .get("value")
397                .and_then(Value::as_str)
398                .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
399            let id = need_client(client)?
400                .crdt_insert_text(&table, &row_id, &column, &name, index, value)
401                .map_err(client_err)?;
402            Ok(json!({ "clientCommitId": id }))
403        }
404        #[cfg(feature = "crdt-yjs")]
405        "crdtDeleteText" => {
406            let (table, row_id, column, name) = crdt_target(params)?;
407            let index = params
408                .get("index")
409                .and_then(Value::as_u64)
410                .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
411                as u32;
412            let len = params
413                .get("len")
414                .and_then(Value::as_u64)
415                .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
416                as u32;
417            let id = need_client(client)?
418                .crdt_delete_text(&table, &row_id, &column, &name, index, len)
419                .map_err(client_err)?;
420            Ok(json!({ "clientCommitId": id }))
421        }
422        #[cfg(feature = "crdt-yjs")]
423        "crdtApplyUpdate" => {
424            let (table, row_id, column, _name) = crdt_target(params)?;
425            let update = value_bytes(params.get("update")).map_err(client_err)?;
426            let id = need_client(client)?
427                .crdt_apply_update(&table, &row_id, &column, &update)
428                .map_err(client_err)?;
429            Ok(json!({ "clientCommitId": id }))
430        }
431        #[cfg(not(feature = "crdt-yjs"))]
432        "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
433            "client.crdt_unavailable".to_owned(),
434            "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
435        )),
436
437        "uploadBlob" => {
438            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
439            let media_type = params
440                .get("mediaType")
441                .and_then(Value::as_str)
442                .map(str::to_owned);
443            let name = params
444                .get("name")
445                .and_then(Value::as_str)
446                .map(str::to_owned);
447            let reference = need_client(client)?
448                .upload_blob(&bytes, media_type, name)
449                .map_err(client_err)?;
450            Ok(json!({ "ref": reference }))
451        }
452        "fetchBlob" => {
453            let blob = params
454                .get("blob")
455                .and_then(Value::as_str)
456                .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
457                .to_owned();
458            // fetch_blob returns (code, message) so the server's blob.* code
459            // reaches the caller (§5.9.5 cross-scope probe).
460            let value = need_client(client)?.fetch_blob(transport, &blob)?;
461            Ok(json!({ "blob": value }))
462        }
463        "conflicts" => {
464            let conflicts = need_client(client)?.conflicts().to_vec();
465            Ok(json!({ "conflicts": conflicts }))
466        }
467        "rejections" => {
468            let rejections = need_client(client)?.rejections().to_vec();
469            Ok(json!({ "rejections": rejections }))
470        }
471        "pendingCommitIds" => {
472            let ids = need_client(client)?.pending_commit_ids();
473            Ok(json!({ "ids": ids }))
474        }
475        "subscriptionState" => {
476            let id = params
477                .get("id")
478                .and_then(Value::as_str)
479                .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
480            let state = need_client(client)?.subscription_state(id);
481            Ok(json!({ "state": state }))
482        }
483        "schemaFloor" => {
484            let floor = need_client(client)?.schema_floor().cloned();
485            Ok(json!({ "floor": floor }))
486        }
487        "leaseState" => {
488            let lease = need_client(client)?.lease_state().cloned();
489            Ok(json!({ "lease": lease }))
490        }
491        "upgrading" => {
492            // §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
493            let value = need_client(client)?.upgrading();
494            Ok(json!({ "value": value }))
495        }
496        "recreateWithSchema" => {
497            // §7.4.2 "app ships new code": swap to the new schema on the SAME
498            // in-memory database (the Rust core has no persistent restart, so
499            // recreation IS the boot). Fires the §7.4.1 marker check.
500            let schema = params
501                .get("schema")
502                .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
503            need_client(client)?
504                .recreate_with_schema(schema)
505                .map_err(client_err)?;
506            Ok(json!({}))
507        }
508        "connectRealtime" => {
509            need_client(client)?
510                .connect_realtime(transport)
511                .map_err(client_err)?;
512            Ok(json!({}))
513        }
514        "disconnectRealtime" => {
515            need_client(client)?.disconnect_realtime(transport);
516            Ok(json!({}))
517        }
518        "syncNeeded" => {
519            let value = need_client(client)?.sync_needed();
520            Ok(json!({ "value": value }))
521        }
522        "setPresence" => {
523            let scope_key = params
524                .get("scopeKey")
525                .and_then(Value::as_str)
526                .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
527                .to_owned();
528            // §8.6.2: `doc` may be a JSON object or null (a leave).
529            let doc = params.get("doc");
530            let doc_ref = match doc {
531                None | Some(Value::Null) => None,
532                Some(v) => Some(v),
533            };
534            need_client(client)?
535                .set_presence(transport, &scope_key, doc_ref)
536                .map_err(client_err)?;
537            Ok(json!({}))
538        }
539        "presence" => {
540            let scope_key = params
541                .get("scopeKey")
542                .and_then(Value::as_str)
543                .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
544            let peers = need_client(client)?.presence(scope_key);
545            Ok(json!({ "peers": peers }))
546        }
547
548        // -- CodecDriver surface (Appendix A) — no client instance needed --
549        "messageRoundtrip" => {
550            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
551            match decode_message(&bytes) {
552                Ok(message) => Ok(json!({
553                    "ok": true,
554                    "bytes": bytes_value(&encode_message(&message)),
555                    "renderedJson": render_message(&message).to_string(),
556                })),
557                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
558            }
559        }
560        "segmentRoundtrip" => {
561            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
562            match decode_rows_segment(&bytes) {
563                Ok(segment) => Ok(json!({
564                    "ok": true,
565                    "bytes": bytes_value(&encode_rows_segment(&segment)),
566                    "renderedJson": render_rows_segment(&segment).to_string(),
567                })),
568                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
569            }
570        }
571        "realtimeKnown" => {
572            let text = params
573                .get("text")
574                .and_then(Value::as_str)
575                .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
576            let known = matches!(
577                parse_control(text),
578                Ok(ControlMessage::Hello { .. })
579                    | Ok(ControlMessage::Wake { .. })
580                    | Ok(ControlMessage::Heartbeat { .. })
581                    | Ok(ControlMessage::Presence { .. })
582            );
583            Ok(json!({ "value": known }))
584        }
585
586        other => Err(client_err(format!("unknown method {other:?}"))),
587    }
588}