1use serde_json::{json, Value};
17use ssp2::segment::{decode_rows_segment, encode_rows_segment};
18use ssp2::{
19 decode_message, encode_message, parse_control, render_message, render_rows_segment,
20 ControlMessage,
21};
22use syncular_client::{
23 ClientLimits, CommandEffects, CommitOutcomeQuery, Mutation, ResolveCommitOutcomeInput,
24 SyncClient, Transport, WindowBase, WindowCoverage,
25};
26
27pub fn bytes_to_hex(bytes: &[u8]) -> String {
30 syncular_client::values::bytes_to_hex(bytes)
31}
32
33pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
34 syncular_client::values::hex_to_bytes(hex)
35}
36
37pub fn bytes_value(bytes: &[u8]) -> Value {
38 json!({ "$bytes": bytes_to_hex(bytes) })
39}
40
41pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
42 let hex = value
43 .and_then(|v| v.get("$bytes"))
44 .and_then(Value::as_str)
45 .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
46 hex_to_bytes(hex)
47}
48
49pub type CommandError = (String, String);
51
52#[derive(Debug, Default, Clone)]
56pub struct CreateEffects {
57 pub signed_urls: bool,
60}
61
62fn client_err(message: String) -> CommandError {
63 let code = message
64 .split_once(':')
65 .map(|(candidate, _)| candidate)
66 .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
67 .unwrap_or("client.failed");
68 (code.to_owned(), message)
69}
70
71fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
72 client
73 .as_mut()
74 .ok_or_else(|| client_err("no client instance created".to_owned()))
75}
76
77pub fn parse_limits(value: Option<&Value>) -> ClientLimits {
78 let mut limits = ClientLimits::default();
79 let Some(object) = value.and_then(Value::as_object) else {
80 return limits;
81 };
82 limits.limit_commits = object
83 .get("limitCommits")
84 .and_then(Value::as_i64)
85 .map(|v| v as i32);
86 limits.limit_snapshot_rows = object
87 .get("limitSnapshotRows")
88 .and_then(Value::as_i64)
89 .map(|v| v as i32);
90 limits.max_snapshot_pages = object
91 .get("maxSnapshotPages")
92 .and_then(Value::as_i64)
93 .map(|v| v as i32);
94 limits.accept = object
95 .get("accept")
96 .and_then(Value::as_u64)
97 .map(|v| v as u8);
98 limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
99 limits.outcome_retention_max_entries = object
100 .get("outcomeRetentionMaxEntries")
101 .and_then(Value::as_u64)
102 .map(|value| value as usize);
103 limits
104}
105
106pub fn parse_encryption(
109 value: &Value,
110) -> Result<syncular_client::values::EncryptionConfig, String> {
111 let mut config = syncular_client::values::EncryptionConfig::default();
112 let Some(keys) = value.get("keys").and_then(Value::as_object) else {
113 return Ok(config);
114 };
115 for (key_id, key_val) in keys {
116 let bytes =
117 value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
118 if bytes.len() != 32 {
119 return Err(format!(
120 "encryption key {key_id:?} must be 32 bytes, got {}",
121 bytes.len()
122 ));
123 }
124 config.keys.insert(key_id.clone(), bytes);
125 }
126 Ok(config)
127}
128
129pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
130 let list = value
131 .and_then(Value::as_array)
132 .ok_or_else(|| "mutations must be a list".to_owned())?;
133 let mut out = Vec::with_capacity(list.len());
134 for entry in list {
135 let op = entry
136 .get("op")
137 .and_then(Value::as_str)
138 .ok_or_else(|| "mutation missing op".to_owned())?;
139 let table = entry
140 .get("table")
141 .and_then(Value::as_str)
142 .ok_or_else(|| "mutation missing table".to_owned())?
143 .to_owned();
144 let base_version = entry.get("baseVersion").and_then(Value::as_i64);
145 match op {
146 "upsert" => {
147 let mut values = entry
148 .get("values")
149 .and_then(Value::as_object)
150 .cloned()
151 .ok_or_else(|| "upsert missing values".to_owned())?;
152 decode_bigint_members(&mut values)?;
153 out.push(Mutation::Upsert {
154 table,
155 values,
156 base_version,
157 });
158 }
159 "delete" => {
160 let row_id = entry
161 .get("rowId")
162 .and_then(Value::as_str)
163 .ok_or_else(|| "delete missing rowId".to_owned())?
164 .to_owned();
165 out.push(Mutation::Delete {
166 table,
167 row_id,
168 base_version,
169 });
170 }
171 other => return Err(format!("unknown mutation op {other:?}")),
172 }
173 }
174 Ok(out)
175}
176
177fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
178 for value in values.values_mut() {
179 let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
180 continue;
181 };
182 let integer = decimal
183 .parse::<i64>()
184 .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
185 *value = Value::from(integer);
186 }
187 Ok(())
188}
189
190fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
191 match value {
192 Some(v) => syncular_client::values::json_to_scope_map(v),
193 None => Ok(Vec::new()),
194 }
195}
196
197fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
200 let object = value
201 .and_then(Value::as_object)
202 .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
203 let table = object
204 .get("table")
205 .and_then(Value::as_str)
206 .ok_or_else(|| "window base missing table".to_owned())?
207 .to_owned();
208 let variable = object
209 .get("variable")
210 .and_then(Value::as_str)
211 .ok_or_else(|| "window base missing variable".to_owned())?
212 .to_owned();
213 let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
214 let params = object
215 .get("params")
216 .and_then(Value::as_str)
217 .map(str::to_owned);
218 Ok(WindowBase {
219 table,
220 variable,
221 fixed_scopes,
222 params,
223 })
224}
225
226#[cfg(feature = "crdt-yjs")]
230fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
231 let table = params
232 .get("table")
233 .and_then(Value::as_str)
234 .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
235 .to_owned();
236 let row_id = params
237 .get("rowId")
238 .and_then(Value::as_str)
239 .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
240 .to_owned();
241 let column = params
242 .get("column")
243 .and_then(Value::as_str)
244 .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
245 .to_owned();
246 let name = params
247 .get("name")
248 .and_then(Value::as_str)
249 .unwrap_or("text")
250 .to_owned();
251 Ok((table, row_id, column, name))
252}
253
254pub fn dispatch<T: Transport>(
264 transport: &mut T,
265 client: &mut Option<SyncClient>,
266 effects: &mut CreateEffects,
267 method: &str,
268 params: &Value,
269) -> Result<Value, CommandError> {
270 match method {
271 "create" => {
272 let client_id = params
273 .get("clientId")
274 .and_then(Value::as_str)
275 .map(str::to_owned);
276 let schema = params
277 .get("schema")
278 .ok_or_else(|| client_err("create missing schema".to_owned()))?;
279 let limits = parse_limits(params.get("limits"));
280 let mut instance = match params.get("dbPath").and_then(Value::as_str) {
284 Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
285 .map_err(client_err)?,
286 None => {
287 SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
288 }
289 };
290 if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
292 instance.set_now_ms(now_ms);
293 }
294 effects.signed_urls = params
297 .get("signedUrls")
298 .and_then(Value::as_bool)
299 .unwrap_or(false);
300 if let Some(enc) = params.get("encryption") {
303 let config = parse_encryption(enc).map_err(client_err)?;
304 instance.set_encryption(config);
305 }
306 *client = Some(instance);
307 Ok(json!({}))
308 }
309 "subscribe" => {
310 let id = params
311 .get("id")
312 .and_then(Value::as_str)
313 .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
314 .to_owned();
315 let table = params
316 .get("table")
317 .and_then(Value::as_str)
318 .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
319 .to_owned();
320 let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
321 let sub_params = params
322 .get("params")
323 .and_then(Value::as_str)
324 .map(str::to_owned);
325 need_client(client)?
326 .subscribe(id, table, scopes, sub_params)
327 .map_err(client_err)?;
328 Ok(json!({}))
329 }
330 "unsubscribe" => {
331 let id = params
332 .get("id")
333 .and_then(Value::as_str)
334 .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
335 need_client(client)?.unsubscribe(id);
336 Ok(json!({}))
337 }
338 "setWindow" => {
339 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
340 let units: Vec<String> = params
341 .get("units")
342 .and_then(Value::as_array)
343 .map(|arr| {
344 arr.iter()
345 .filter_map(|v| v.as_str().map(str::to_owned))
346 .collect()
347 })
348 .unwrap_or_default();
349 let command_effects = need_client(client)?
350 .set_window(&base, &units)
351 .map_err(client_err)?;
352 Ok(json!({ "effects": command_effects }))
353 }
354 "windowState" => {
355 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
356 let state = need_client(client)?.window_state(&base);
357 Ok(json!({ "units": state.units, "pending": state.pending }))
358 }
359 "mutate" => {
360 let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
361 let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
362 Ok(json!({
363 "clientCommitId": id,
364 "effects": CommandEffects::interactive()
365 }))
366 }
367 "patch" => {
368 let table = params
369 .get("table")
370 .and_then(Value::as_str)
371 .ok_or_else(|| client_err("patch missing table".to_owned()))?;
372 let row_id = params
373 .get("rowId")
374 .and_then(Value::as_str)
375 .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
376 let mut partial = params
377 .get("partial")
378 .and_then(Value::as_object)
379 .cloned()
380 .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
381 decode_bigint_members(&mut partial).map_err(client_err)?;
382 let base_version = params.get("baseVersion").and_then(Value::as_i64);
383 let id = need_client(client)?
384 .patch(table, row_id, partial, base_version)
385 .map_err(client_err)?;
386 Ok(json!({
387 "clientCommitId": id,
388 "effects": CommandEffects::interactive()
389 }))
390 }
391 "sync" => {
392 let outcome = need_client(client)?.sync(transport);
393 Ok(outcome.to_json())
394 }
395 "syncUntilIdle" => {
396 let max_rounds = params
397 .get("maxRounds")
398 .and_then(Value::as_u64)
399 .map(|v| v as u32);
400 let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
401 Ok(outcome.to_json())
402 }
403 "readRows" => {
404 let table = params
405 .get("table")
406 .and_then(Value::as_str)
407 .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
408 let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
409 Ok(json!({ "rows": rows }))
410 }
411 "query" => {
412 let sql = params
416 .get("sql")
417 .and_then(Value::as_str)
418 .ok_or_else(|| client_err("query missing sql".to_owned()))?;
419 let bind = match params.get("params") {
420 Some(Value::Array(list)) => list.clone(),
421 None | Some(Value::Null) => Vec::new(),
422 Some(_) => return Err(client_err("query params must be a list".to_owned())),
423 };
424 let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
425 Ok(json!({ "rows": rows }))
426 }
427 "querySnapshot" => {
428 let sql = params
429 .get("sql")
430 .and_then(Value::as_str)
431 .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
432 let bind = match params.get("params") {
433 Some(Value::Array(list)) => list.clone(),
434 None | Some(Value::Null) => Vec::new(),
435 Some(_) => {
436 return Err(client_err("querySnapshot params must be a list".to_owned()))
437 }
438 };
439 let mut coverage = Vec::new();
440 for entry in params
441 .get("coverage")
442 .and_then(Value::as_array)
443 .into_iter()
444 .flatten()
445 {
446 let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
447 let units = entry
448 .get("units")
449 .and_then(Value::as_array)
450 .map(|values| {
451 values
452 .iter()
453 .filter_map(|value| value.as_str().map(str::to_owned))
454 .collect()
455 })
456 .unwrap_or_default();
457 coverage.push(WindowCoverage { base, units });
458 }
459 let snapshot = need_client(client)?
460 .query_snapshot(sql, &bind, &coverage)
461 .map_err(client_err)?;
462 Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
463 }
464 "localRevision" => Ok(json!({
465 "revision": need_client(client)?.local_revision().to_string()
466 })),
467 "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
468 .expect("status serializes")),
469 "drainChangeBatches" => Ok(json!({
474 "batches": need_client(client)?.drain_change_batches()
475 })),
476 "drainSyncIntents" => Ok(json!({
477 "intents": need_client(client)?.drain_sync_intents()
478 })),
479 #[cfg(feature = "crdt-yjs")]
485 "crdtText" => {
486 let (table, row_id, column, name) = crdt_target(params)?;
487 let text = need_client(client)?
488 .crdt_text(&table, &row_id, &column, &name)
489 .map_err(client_err)?;
490 Ok(json!({ "text": text }))
491 }
492 #[cfg(feature = "crdt-yjs")]
493 "crdtInsertText" => {
494 let (table, row_id, column, name) = crdt_target(params)?;
495 let index = params
496 .get("index")
497 .and_then(Value::as_u64)
498 .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
499 as u32;
500 let value = params
501 .get("value")
502 .and_then(Value::as_str)
503 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
504 let id = need_client(client)?
505 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
506 .map_err(client_err)?;
507 Ok(json!({ "clientCommitId": id }))
508 }
509 #[cfg(feature = "crdt-yjs")]
510 "crdtDeleteText" => {
511 let (table, row_id, column, name) = crdt_target(params)?;
512 let index = params
513 .get("index")
514 .and_then(Value::as_u64)
515 .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
516 as u32;
517 let len = params
518 .get("len")
519 .and_then(Value::as_u64)
520 .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
521 as u32;
522 let id = need_client(client)?
523 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
524 .map_err(client_err)?;
525 Ok(json!({ "clientCommitId": id }))
526 }
527 #[cfg(feature = "crdt-yjs")]
528 "crdtApplyUpdate" => {
529 let (table, row_id, column, _name) = crdt_target(params)?;
530 let update = value_bytes(params.get("update")).map_err(client_err)?;
531 let id = need_client(client)?
532 .crdt_apply_update(&table, &row_id, &column, &update)
533 .map_err(client_err)?;
534 Ok(json!({ "clientCommitId": id }))
535 }
536 #[cfg(not(feature = "crdt-yjs"))]
537 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
538 "client.crdt_unavailable".to_owned(),
539 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
540 )),
541
542 "uploadBlob" => {
543 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
544 let media_type = params
545 .get("mediaType")
546 .and_then(Value::as_str)
547 .map(str::to_owned);
548 let name = params
549 .get("name")
550 .and_then(Value::as_str)
551 .map(str::to_owned);
552 let reference = need_client(client)?
553 .upload_blob(&bytes, media_type, name)
554 .map_err(client_err)?;
555 Ok(json!({ "ref": reference }))
556 }
557 "fetchBlob" => {
558 let blob = params
559 .get("blob")
560 .and_then(Value::as_str)
561 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
562 .to_owned();
563 let value = need_client(client)?.fetch_blob(transport, &blob)?;
566 Ok(json!({ "blob": value }))
567 }
568 "conflicts" => {
569 let conflicts = need_client(client)?.conflicts().to_vec();
570 Ok(json!({ "conflicts": conflicts }))
571 }
572 "rejections" => {
573 let rejections = need_client(client)?.rejections().to_vec();
574 Ok(json!({ "rejections": rejections }))
575 }
576 "commitOutcome" => {
577 let client_commit_id = params
578 .get("clientCommitId")
579 .and_then(Value::as_str)
580 .ok_or_else(|| {
581 client_err(
582 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
583 )
584 })?;
585 let outcome = need_client(client)?
586 .commit_outcome(client_commit_id)
587 .map_err(client_err)?;
588 Ok(json!({ "outcome": outcome }))
589 }
590 "commitOutcomes" => {
591 let query = serde_json::from_value::<CommitOutcomeQuery>(
592 params.get("query").cloned().unwrap_or_else(|| json!({})),
593 )
594 .map_err(|error| {
595 client_err(format!(
596 "sync.invalid_request: invalid commit outcome query: {error}"
597 ))
598 })?;
599 let outcomes = need_client(client)?
600 .commit_outcomes(query)
601 .map_err(client_err)?;
602 Ok(json!({ "outcomes": outcomes }))
603 }
604 "resolveCommitOutcome" => {
605 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
606 params.get("input").cloned().ok_or_else(|| {
607 client_err(
608 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
609 )
610 })?,
611 )
612 .map_err(|error| {
613 client_err(format!(
614 "sync.invalid_request: invalid outcome resolution: {error}"
615 ))
616 })?;
617 let outcome = need_client(client)?
618 .resolve_commit_outcome(input)
619 .map_err(client_err)?;
620 Ok(json!({ "outcome": outcome }))
621 }
622 "pendingCommitIds" => {
623 let ids = need_client(client)?.pending_commit_ids();
624 Ok(json!({ "ids": ids }))
625 }
626 "subscriptionState" => {
627 let id = params
628 .get("id")
629 .and_then(Value::as_str)
630 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
631 let state = need_client(client)?.subscription_state(id);
632 Ok(json!({ "state": state }))
633 }
634 "schemaFloor" => {
635 let floor = need_client(client)?.schema_floor().cloned();
636 Ok(json!({ "floor": floor }))
637 }
638 "leaseState" => {
639 let lease = need_client(client)?.lease_state().cloned();
640 Ok(json!({ "lease": lease }))
641 }
642 "upgrading" => {
643 let value = need_client(client)?.upgrading();
645 Ok(json!({ "value": value }))
646 }
647 "recreateWithSchema" => {
648 let schema = params
652 .get("schema")
653 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
654 need_client(client)?
655 .recreate_with_schema(schema)
656 .map_err(client_err)?;
657 Ok(json!({}))
658 }
659 "connectRealtime" => {
660 need_client(client)?
661 .connect_realtime(transport)
662 .map_err(client_err)?;
663 Ok(json!({}))
664 }
665 "disconnectRealtime" => {
666 need_client(client)?.disconnect_realtime(transport);
667 Ok(json!({}))
668 }
669 "syncNeeded" => {
670 let value = need_client(client)?.sync_needed();
671 Ok(json!({ "value": value }))
672 }
673 "setPresence" => {
674 let scope_key = params
675 .get("scopeKey")
676 .and_then(Value::as_str)
677 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
678 .to_owned();
679 let doc = params.get("doc");
681 let doc_ref = match doc {
682 None | Some(Value::Null) => None,
683 Some(v) => Some(v),
684 };
685 need_client(client)?
686 .set_presence(transport, &scope_key, doc_ref)
687 .map_err(client_err)?;
688 Ok(json!({}))
689 }
690 "presence" => {
691 let scope_key = params
692 .get("scopeKey")
693 .and_then(Value::as_str)
694 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
695 let peers = need_client(client)?.presence(scope_key);
696 Ok(json!({ "peers": peers }))
697 }
698
699 "messageRoundtrip" => {
701 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
702 match decode_message(&bytes) {
703 Ok(message) => Ok(json!({
704 "ok": true,
705 "bytes": bytes_value(&encode_message(&message)),
706 "renderedJson": render_message(&message).to_string(),
707 })),
708 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
709 }
710 }
711 "segmentRoundtrip" => {
712 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
713 match decode_rows_segment(&bytes) {
714 Ok(segment) => Ok(json!({
715 "ok": true,
716 "bytes": bytes_value(&encode_rows_segment(&segment)),
717 "renderedJson": render_rows_segment(&segment).to_string(),
718 })),
719 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
720 }
721 }
722 "realtimeKnown" => {
723 let text = params
724 .get("text")
725 .and_then(Value::as_str)
726 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
727 let known = matches!(
728 parse_control(text),
729 Ok(ControlMessage::Hello { .. })
730 | Ok(ControlMessage::Wake { .. })
731 | Ok(ControlMessage::Heartbeat { .. })
732 | Ok(ControlMessage::Presence { .. })
733 );
734 Ok(json!({ "value": known }))
735 }
736
737 other => Err(client_err(format!("unknown method {other:?}"))),
738 }
739}