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 ClientDiagnosticsRequest, ClientLimits, CommandEffects, CommitOutcomeQuery,
24 LocalDataPurgeInput, LocalDataRebootstrapInput, Mutation, ResolveCommitOutcomeInput,
25 SyncClient, Transport, WindowBase, WindowCoverage,
26};
27
28pub 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
50pub type CommandError = (String, String);
52
53#[derive(Debug, Default, Clone)]
57pub struct CreateEffects {
58 pub signed_urls: bool,
61 pub security_preflight_pending: bool,
77}
78
79fn client_err(message: String) -> CommandError {
80 let code = message
81 .split_once(':')
82 .map(|(candidate, _)| candidate)
83 .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
84 .unwrap_or("client.failed");
85 (code.to_owned(), message)
86}
87
88fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
89 client
90 .as_mut()
91 .ok_or_else(|| client_err("no client instance created".to_owned()))
92}
93
94pub fn parse_limits(value: Option<&Value>) -> ClientLimits {
95 let mut limits = ClientLimits::default();
96 let Some(object) = value.and_then(Value::as_object) else {
97 return limits;
98 };
99 limits.limit_commits = object
100 .get("limitCommits")
101 .and_then(Value::as_i64)
102 .map(|v| v as i32);
103 limits.limit_snapshot_rows = object
104 .get("limitSnapshotRows")
105 .and_then(Value::as_i64)
106 .map(|v| v as i32);
107 limits.max_snapshot_pages = object
108 .get("maxSnapshotPages")
109 .and_then(Value::as_i64)
110 .map(|v| v as i32);
111 limits.accept = object
112 .get("accept")
113 .and_then(Value::as_u64)
114 .map(|v| v as u8);
115 limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
116 limits.outcome_retention_max_entries = object
117 .get("outcomeRetentionMaxEntries")
118 .and_then(Value::as_u64)
119 .map(|value| value as usize);
120 limits
121}
122
123pub fn parse_encryption(
127 value: &Value,
128) -> Result<syncular_client::values::EncryptionConfig, String> {
129 let mut config = syncular_client::values::EncryptionConfig::default();
130 let Some(keys) = value.get("keys").and_then(Value::as_object) else {
131 return Ok(config);
132 };
133 for (key_id, key_val) in keys {
134 let bytes =
135 value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
136 if bytes.len() != 32 {
137 return Err(format!(
138 "encryption key {key_id:?} must be 32 bytes, got {}",
139 bytes.len()
140 ));
141 }
142 config.keys.insert(key_id.clone(), bytes);
143 }
144 if let Some(columns) = value.get("keyIdColumns") {
145 let columns = columns
146 .as_object()
147 .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
148 for (table, column) in columns {
149 let column = column.as_str().ok_or_else(|| {
150 format!("encryption keyIdColumns entry for {table:?} must be a string")
151 })?;
152 if column.is_empty() {
153 return Err(format!(
154 "encryption keyIdColumns entry for {table:?} must not be empty"
155 ));
156 }
157 config
158 .key_id_columns
159 .insert(table.clone(), column.to_owned());
160 }
161 }
162 Ok(config)
163}
164
165pub fn parse_headers(value: &Value) -> Result<Vec<(String, String)>, String> {
170 let object = value.as_object().ok_or_else(|| {
171 "sync.invalid_request: headers must be an object of string values".to_owned()
172 })?;
173 let mut headers = Vec::with_capacity(object.len());
174 for (name, value) in object {
175 let value = value
176 .as_str()
177 .ok_or_else(|| format!("sync.invalid_request: header {name:?} must be a string"))?;
178 headers.push((name.clone(), value.to_owned()));
179 }
180 Ok(headers)
181}
182
183pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
184 let list = value
185 .and_then(Value::as_array)
186 .ok_or_else(|| "mutations must be a list".to_owned())?;
187 let mut out = Vec::with_capacity(list.len());
188 for entry in list {
189 let op = entry
190 .get("op")
191 .and_then(Value::as_str)
192 .ok_or_else(|| "mutation missing op".to_owned())?;
193 let table = entry
194 .get("table")
195 .and_then(Value::as_str)
196 .ok_or_else(|| "mutation missing table".to_owned())?
197 .to_owned();
198 let base_version = entry.get("baseVersion").and_then(Value::as_i64);
199 match op {
200 "upsert" => {
201 let mut values = entry
202 .get("values")
203 .and_then(Value::as_object)
204 .cloned()
205 .ok_or_else(|| "upsert missing values".to_owned())?;
206 decode_bigint_members(&mut values)?;
207 out.push(Mutation::Upsert {
208 table,
209 values,
210 base_version,
211 });
212 }
213 "delete" => {
214 let row_id = entry
215 .get("rowId")
216 .and_then(Value::as_str)
217 .ok_or_else(|| "delete missing rowId".to_owned())?
218 .to_owned();
219 out.push(Mutation::Delete {
220 table,
221 row_id,
222 base_version,
223 });
224 }
225 other => return Err(format!("unknown mutation op {other:?}")),
226 }
227 }
228 Ok(out)
229}
230
231fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
232 for value in values.values_mut() {
233 let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
234 continue;
235 };
236 let integer = decimal
237 .parse::<i64>()
238 .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
239 *value = Value::from(integer);
240 }
241 Ok(())
242}
243
244fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
245 match value {
246 Some(v) => syncular_client::values::json_to_scope_map(v),
247 None => Ok(Vec::new()),
248 }
249}
250
251fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
254 let object = value
255 .and_then(Value::as_object)
256 .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
257 let table = object
258 .get("table")
259 .and_then(Value::as_str)
260 .ok_or_else(|| "window base missing table".to_owned())?
261 .to_owned();
262 let variable = object
263 .get("variable")
264 .and_then(Value::as_str)
265 .ok_or_else(|| "window base missing variable".to_owned())?
266 .to_owned();
267 let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
268 let params = object
269 .get("params")
270 .and_then(Value::as_str)
271 .map(str::to_owned);
272 Ok(WindowBase {
273 table,
274 variable,
275 fixed_scopes,
276 params,
277 })
278}
279
280#[cfg(feature = "crdt-yjs")]
284fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
285 let table = params
286 .get("table")
287 .and_then(Value::as_str)
288 .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
289 .to_owned();
290 let row_id = params
291 .get("rowId")
292 .and_then(Value::as_str)
293 .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
294 .to_owned();
295 let column = params
296 .get("column")
297 .and_then(Value::as_str)
298 .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
299 .to_owned();
300 let name = params
301 .get("name")
302 .and_then(Value::as_str)
303 .unwrap_or("text")
304 .to_owned();
305 Ok((table, row_id, column, name))
306}
307
308pub fn dispatch<T: Transport>(
318 transport: &mut T,
319 client: &mut Option<SyncClient>,
320 effects: &mut CreateEffects,
321 method: &str,
322 params: &Value,
323) -> Result<Value, CommandError> {
324 if method == "create" {
325 let requests_preflight = params
332 .get("securityPreflight")
333 .and_then(Value::as_bool)
334 .unwrap_or(false);
335 let preflight_engaged = client.as_ref().map_or(
336 effects.security_preflight_pending,
337 SyncClient::security_preflight,
338 );
339 if preflight_engaged && !requests_preflight {
340 return Err((
341 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
342 "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
343 ));
344 }
345 } else {
346 let allowed_during_preflight = matches!(
347 method,
348 "securityLifecycle"
349 | "beginSecurityPreflight"
350 | "activateSecurity"
351 | "purgeLocalData"
352 | "localRevision"
353 | "statusSnapshot"
354 | "shutdown"
355 );
356 if client
357 .as_ref()
358 .is_some_and(|running| running.security_preflight())
359 && !allowed_during_preflight
360 {
361 return Err((
362 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
363 "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data".to_owned(),
364 ));
365 }
366 }
367 match method {
368 "create" => {
369 let client_id = params
370 .get("clientId")
371 .and_then(Value::as_str)
372 .map(str::to_owned);
373 let schema = params
374 .get("schema")
375 .ok_or_else(|| client_err("create missing schema".to_owned()))?;
376 let limits = parse_limits(params.get("limits"));
377 let mut instance = match params.get("dbPath").and_then(Value::as_str) {
381 Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
382 .map_err(client_err)?,
383 None => {
384 SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
385 }
386 };
387 if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
389 instance.set_now_ms(now_ms);
390 }
391 effects.signed_urls = params
394 .get("signedUrls")
395 .and_then(Value::as_bool)
396 .unwrap_or(false);
397 let security_preflight = params
401 .get("securityPreflight")
402 .and_then(Value::as_bool)
403 .unwrap_or(false);
404 if instance.security_preflight() && !security_preflight {
412 return Err((
413 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
414 "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
415 ));
416 }
417 if security_preflight && params.get("encryption").is_some() {
418 return Err(client_err(
419 "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
420 .to_owned(),
421 ));
422 }
423 if let Some(enc) = params.get("encryption") {
424 let config = parse_encryption(enc).map_err(client_err)?;
425 instance.set_encryption(config);
426 }
427 if security_preflight {
428 instance.begin_security_preflight();
429 }
430 effects.security_preflight_pending = security_preflight;
431 *client = Some(instance);
432 Ok(json!({}))
433 }
434 "securityLifecycle" => Ok(json!({
435 "state": need_client(client)?.security_lifecycle()
436 })),
437 "beginSecurityPreflight" => {
438 let running = need_client(client)?;
439 running.disconnect_realtime(transport);
440 running.begin_security_preflight();
441 effects.security_preflight_pending = true;
442 Ok(json!({}))
443 }
444 "activateSecurity" => {
445 let encryption = match params.get("encryption") {
446 Some(value) => parse_encryption(value).map_err(client_err)?,
447 None => syncular_client::values::EncryptionConfig::default(),
448 };
449 if let Some(headers) = params.get("headers") {
455 parse_headers(headers).map_err(client_err)?;
456 }
457 need_client(client)?
458 .activate_security(encryption)
459 .map_err(client_err)?;
460 effects.security_preflight_pending = false;
461 Ok(json!({}))
462 }
463 "shutdown" => {
464 if let Some(running) = client.as_mut() {
465 effects.security_preflight_pending = running.security_preflight();
470 running.disconnect_realtime(transport);
471 running.begin_security_preflight();
472 }
473 *client = None;
474 Ok(json!({}))
475 }
476 "subscribe" => {
477 let id = params
478 .get("id")
479 .and_then(Value::as_str)
480 .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
481 .to_owned();
482 let table = params
483 .get("table")
484 .and_then(Value::as_str)
485 .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
486 .to_owned();
487 let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
488 let sub_params = params
489 .get("params")
490 .and_then(Value::as_str)
491 .map(str::to_owned);
492 need_client(client)?
493 .subscribe(id, table, scopes, sub_params)
494 .map_err(client_err)?;
495 Ok(json!({}))
496 }
497 "unsubscribe" => {
498 let id = params
499 .get("id")
500 .and_then(Value::as_str)
501 .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
502 need_client(client)?.unsubscribe(id);
503 Ok(json!({}))
504 }
505 "setWindow" => {
506 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
507 let units: Vec<String> = params
508 .get("units")
509 .and_then(Value::as_array)
510 .map(|arr| {
511 arr.iter()
512 .filter_map(|v| v.as_str().map(str::to_owned))
513 .collect()
514 })
515 .unwrap_or_default();
516 let command_effects = need_client(client)?
517 .set_window(&base, &units)
518 .map_err(client_err)?;
519 Ok(json!({ "effects": command_effects }))
520 }
521 "windowState" => {
522 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
523 let state = need_client(client)?.window_state(&base);
524 Ok(json!({ "units": state.units, "pending": state.pending }))
525 }
526 "mutate" => {
527 let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
528 let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
529 Ok(json!({
530 "clientCommitId": id,
531 "effects": CommandEffects::interactive()
532 }))
533 }
534 "patch" => {
535 let table = params
536 .get("table")
537 .and_then(Value::as_str)
538 .ok_or_else(|| client_err("patch missing table".to_owned()))?;
539 let row_id = params
540 .get("rowId")
541 .and_then(Value::as_str)
542 .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
543 let mut partial = params
544 .get("partial")
545 .and_then(Value::as_object)
546 .cloned()
547 .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
548 decode_bigint_members(&mut partial).map_err(client_err)?;
549 let base_version = params.get("baseVersion").and_then(Value::as_i64);
550 let id = need_client(client)?
551 .patch(table, row_id, partial, base_version)
552 .map_err(client_err)?;
553 Ok(json!({
554 "clientCommitId": id,
555 "effects": CommandEffects::interactive()
556 }))
557 }
558 "purgeLocalData" => {
559 let input: LocalDataPurgeInput =
560 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
561 client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
562 })?)
563 .map_err(|error| {
564 client_err(format!(
565 "sync.invalid_request: invalid purgeLocalData input: {error}"
566 ))
567 })?;
568 let result = need_client(client)?
569 .purge_local_data(&input)
570 .map_err(client_err)?;
571 serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
572 }
573 "rebootstrapLocalData" => {
574 let input: LocalDataRebootstrapInput =
575 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
576 client_err(
577 "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
578 )
579 })?)
580 .map_err(|error| {
581 client_err(format!(
582 "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
583 ))
584 })?;
585 let result = need_client(client)?
586 .rebootstrap_local_data(&input)
587 .map_err(client_err)?;
588 Ok(json!({
589 "alreadyApplied": result.already_applied,
590 "retainedCommits": result.retained_commits,
591 "resetSubscriptions": result.reset_subscriptions,
592 "effects": if result.already_applied {
593 CommandEffects::none()
594 } else {
595 CommandEffects::interactive()
596 }
597 }))
598 }
599 "sync" => {
600 let outcome = need_client(client)?.sync(transport);
601 Ok(outcome.to_json())
602 }
603 "syncUntilIdle" => {
604 let max_rounds = params
605 .get("maxRounds")
606 .and_then(Value::as_u64)
607 .map(|v| v as u32);
608 let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
609 Ok(outcome.to_json())
610 }
611 "readRows" => {
612 let table = params
613 .get("table")
614 .and_then(Value::as_str)
615 .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
616 let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
617 Ok(json!({ "rows": rows }))
618 }
619 "query" => {
620 let sql = params
624 .get("sql")
625 .and_then(Value::as_str)
626 .ok_or_else(|| client_err("query missing sql".to_owned()))?;
627 let bind = match params.get("params") {
628 Some(Value::Array(list)) => list.clone(),
629 None | Some(Value::Null) => Vec::new(),
630 Some(_) => return Err(client_err("query params must be a list".to_owned())),
631 };
632 let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
633 Ok(json!({ "rows": rows }))
634 }
635 "querySnapshot" => {
636 let sql = params
637 .get("sql")
638 .and_then(Value::as_str)
639 .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
640 let bind = match params.get("params") {
641 Some(Value::Array(list)) => list.clone(),
642 None | Some(Value::Null) => Vec::new(),
643 Some(_) => {
644 return Err(client_err("querySnapshot params must be a list".to_owned()))
645 }
646 };
647 let mut coverage = Vec::new();
648 for entry in params
649 .get("coverage")
650 .and_then(Value::as_array)
651 .into_iter()
652 .flatten()
653 {
654 let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
655 let units = entry
656 .get("units")
657 .and_then(Value::as_array)
658 .map(|values| {
659 values
660 .iter()
661 .filter_map(|value| value.as_str().map(str::to_owned))
662 .collect()
663 })
664 .unwrap_or_default();
665 coverage.push(WindowCoverage { base, units });
666 }
667 let snapshot = need_client(client)?
668 .query_snapshot(sql, &bind, &coverage)
669 .map_err(client_err)?;
670 Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
671 }
672 "localRevision" => Ok(json!({
673 "revision": need_client(client)?.local_revision().to_string()
674 })),
675 "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
676 .expect("status serializes")),
677 "diagnosticsSnapshot" => {
678 let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
679 .map_err(|error| {
680 client_err(format!(
681 "sync.invalid_request: invalid diagnostics request: {error}"
682 ))
683 })?;
684 let snapshot = need_client(client)?
685 .diagnostics_snapshot(&request)
686 .map_err(client_err)?;
687 Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
688 }
689 "drainChangeBatches" => Ok(json!({
694 "batches": need_client(client)?.drain_change_batches()
695 })),
696 "drainSyncIntents" => Ok(json!({
697 "intents": need_client(client)?.drain_sync_intents()
698 })),
699 #[cfg(feature = "crdt-yjs")]
705 "crdtText" => {
706 let (table, row_id, column, name) = crdt_target(params)?;
707 let text = need_client(client)?
708 .crdt_text(&table, &row_id, &column, &name)
709 .map_err(client_err)?;
710 Ok(json!({ "text": text }))
711 }
712 #[cfg(feature = "crdt-yjs")]
713 "crdtInsertText" => {
714 let (table, row_id, column, name) = crdt_target(params)?;
715 let index = params
716 .get("index")
717 .and_then(Value::as_u64)
718 .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
719 as u32;
720 let value = params
721 .get("value")
722 .and_then(Value::as_str)
723 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
724 let id = need_client(client)?
725 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
726 .map_err(client_err)?;
727 Ok(json!({ "clientCommitId": id }))
728 }
729 #[cfg(feature = "crdt-yjs")]
730 "crdtDeleteText" => {
731 let (table, row_id, column, name) = crdt_target(params)?;
732 let index = params
733 .get("index")
734 .and_then(Value::as_u64)
735 .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
736 as u32;
737 let len = params
738 .get("len")
739 .and_then(Value::as_u64)
740 .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
741 as u32;
742 let id = need_client(client)?
743 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
744 .map_err(client_err)?;
745 Ok(json!({ "clientCommitId": id }))
746 }
747 #[cfg(feature = "crdt-yjs")]
748 "crdtApplyUpdate" => {
749 let (table, row_id, column, _name) = crdt_target(params)?;
750 let update = value_bytes(params.get("update")).map_err(client_err)?;
751 let id = need_client(client)?
752 .crdt_apply_update(&table, &row_id, &column, &update)
753 .map_err(client_err)?;
754 Ok(json!({ "clientCommitId": id }))
755 }
756 #[cfg(not(feature = "crdt-yjs"))]
757 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
758 "client.crdt_unavailable".to_owned(),
759 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
760 )),
761
762 "uploadBlob" => {
763 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
764 let media_type = params
765 .get("mediaType")
766 .and_then(Value::as_str)
767 .map(str::to_owned);
768 let name = params
769 .get("name")
770 .and_then(Value::as_str)
771 .map(str::to_owned);
772 let reference = need_client(client)?
773 .upload_blob(&bytes, media_type, name)
774 .map_err(client_err)?;
775 Ok(json!({ "ref": reference }))
776 }
777 "fetchBlob" => {
778 let blob = params
779 .get("blob")
780 .and_then(Value::as_str)
781 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
782 .to_owned();
783 let value = need_client(client)?.fetch_blob(transport, &blob)?;
786 Ok(json!({ "blob": value }))
787 }
788 "conflicts" => {
789 let conflicts = need_client(client)?.conflicts().to_vec();
790 Ok(json!({ "conflicts": conflicts }))
791 }
792 "rejections" => {
793 let rejections = need_client(client)?.rejections().to_vec();
794 Ok(json!({ "rejections": rejections }))
795 }
796 "commitOutcome" => {
797 let client_commit_id = params
798 .get("clientCommitId")
799 .and_then(Value::as_str)
800 .ok_or_else(|| {
801 client_err(
802 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
803 )
804 })?;
805 let outcome = need_client(client)?
806 .commit_outcome(client_commit_id)
807 .map_err(client_err)?;
808 Ok(json!({ "outcome": outcome }))
809 }
810 "commitOutcomes" => {
811 let query = serde_json::from_value::<CommitOutcomeQuery>(
812 params.get("query").cloned().unwrap_or_else(|| json!({})),
813 )
814 .map_err(|error| {
815 client_err(format!(
816 "sync.invalid_request: invalid commit outcome query: {error}"
817 ))
818 })?;
819 let outcomes = need_client(client)?
820 .commit_outcomes(query)
821 .map_err(client_err)?;
822 Ok(json!({ "outcomes": outcomes }))
823 }
824 "resolveCommitOutcome" => {
825 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
826 params.get("input").cloned().ok_or_else(|| {
827 client_err(
828 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
829 )
830 })?,
831 )
832 .map_err(|error| {
833 client_err(format!(
834 "sync.invalid_request: invalid outcome resolution: {error}"
835 ))
836 })?;
837 let outcome = need_client(client)?
838 .resolve_commit_outcome(input)
839 .map_err(client_err)?;
840 Ok(json!({ "outcome": outcome }))
841 }
842 "pendingCommitIds" => {
843 let ids = need_client(client)?.pending_commit_ids();
844 Ok(json!({ "ids": ids }))
845 }
846 "subscriptionState" => {
847 let id = params
848 .get("id")
849 .and_then(Value::as_str)
850 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
851 let state = need_client(client)?.subscription_state(id);
852 Ok(json!({ "state": state }))
853 }
854 "schemaFloor" => {
855 let floor = need_client(client)?.schema_floor().cloned();
856 Ok(json!({ "floor": floor }))
857 }
858 "leaseState" => {
859 let lease = need_client(client)?.lease_state().cloned();
860 Ok(json!({ "lease": lease }))
861 }
862 "upgrading" => {
863 let value = need_client(client)?.upgrading();
865 Ok(json!({ "value": value }))
866 }
867 "recreateWithSchema" => {
868 let schema = params
872 .get("schema")
873 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
874 need_client(client)?
875 .recreate_with_schema(schema)
876 .map_err(client_err)?;
877 Ok(json!({}))
878 }
879 "connectRealtime" => {
880 need_client(client)?
881 .connect_realtime(transport)
882 .map_err(client_err)?;
883 Ok(json!({}))
884 }
885 "disconnectRealtime" => {
886 need_client(client)?.disconnect_realtime(transport);
887 Ok(json!({}))
888 }
889 "syncNeeded" => {
890 let value = need_client(client)?.sync_needed();
891 Ok(json!({ "value": value }))
892 }
893 "setPresence" => {
894 let scope_key = params
895 .get("scopeKey")
896 .and_then(Value::as_str)
897 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
898 .to_owned();
899 let doc = params.get("doc");
901 let doc_ref = match doc {
902 None | Some(Value::Null) => None,
903 Some(v) => Some(v),
904 };
905 need_client(client)?
906 .set_presence(transport, &scope_key, doc_ref)
907 .map_err(client_err)?;
908 Ok(json!({}))
909 }
910 "presence" => {
911 let scope_key = params
912 .get("scopeKey")
913 .and_then(Value::as_str)
914 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
915 let peers = need_client(client)?.presence(scope_key);
916 Ok(json!({ "peers": peers }))
917 }
918
919 "messageRoundtrip" => {
921 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
922 match decode_message(&bytes) {
923 Ok(message) => Ok(json!({
924 "ok": true,
925 "bytes": bytes_value(&encode_message(&message)),
926 "renderedJson": render_message(&message).to_string(),
927 })),
928 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
929 }
930 }
931 "segmentRoundtrip" => {
932 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
933 match decode_rows_segment(&bytes) {
934 Ok(segment) => Ok(json!({
935 "ok": true,
936 "bytes": bytes_value(&encode_rows_segment(&segment)),
937 "renderedJson": render_rows_segment(&segment).to_string(),
938 })),
939 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
940 }
941 }
942 "realtimeKnown" => {
943 let text = params
944 .get("text")
945 .and_then(Value::as_str)
946 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
947 let known = matches!(
948 parse_control(text),
949 Ok(ControlMessage::Hello { .. })
950 | Ok(ControlMessage::Wake { .. })
951 | Ok(ControlMessage::Heartbeat { .. })
952 | Ok(ControlMessage::Presence { .. })
953 );
954 Ok(json!({ "value": known }))
955 }
956
957 other => Err(client_err(format!("unknown method {other:?}"))),
958 }
959}
960
961#[cfg(test)]
962mod tests {
963 use serde_json::{json, Value};
964 use syncular_client::{
965 SegmentRequest, SyncClient, Transport, TransportError, SECURITY_PREFLIGHT_REQUIRED_CODE,
966 };
967
968 use super::{dispatch, parse_encryption, parse_headers, CreateEffects};
969
970 #[derive(Default)]
971 struct NoNetwork {
972 realtime_connects: usize,
973 realtime_closes: usize,
974 }
975
976 impl Transport for NoNetwork {
977 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
978 Err(TransportError::new("sync.transport_failed", "offline"))
979 }
980
981 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
982 Err(TransportError::new("sync.transport_failed", "offline"))
983 }
984
985 fn download_segment(
986 &mut self,
987 _request: &SegmentRequest,
988 ) -> Result<Vec<u8>, TransportError> {
989 Err(TransportError::new("sync.transport_failed", "offline"))
990 }
991
992 fn realtime_connect(&mut self) -> Result<(), TransportError> {
993 self.realtime_connects += 1;
994 Ok(())
995 }
996
997 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
998 Ok(())
999 }
1000
1001 fn realtime_close(&mut self) -> Result<(), TransportError> {
1002 self.realtime_closes += 1;
1003 Ok(())
1004 }
1005 }
1006
1007 fn schema() -> Value {
1008 json!({
1009 "version": 1,
1010 "tables": [{
1011 "name": "todos",
1012 "columns": [
1013 { "name": "id", "type": "string", "nullable": false },
1014 { "name": "list_id", "type": "string", "nullable": false }
1015 ],
1016 "primaryKey": "id",
1017 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1018 }]
1019 })
1020 }
1021
1022 #[test]
1023 fn native_command_realtime_connection_is_idempotent() {
1024 let mut transport = NoNetwork::default();
1025 let mut client: Option<SyncClient> = None;
1026 let mut effects = CreateEffects::default();
1027 dispatch(
1028 &mut transport,
1029 &mut client,
1030 &mut effects,
1031 "create",
1032 &json!({ "schema": schema() }),
1033 )
1034 .expect("create client");
1035
1036 for _ in 0..2 {
1037 dispatch(
1038 &mut transport,
1039 &mut client,
1040 &mut effects,
1041 "connectRealtime",
1042 &json!({}),
1043 )
1044 .expect("idempotent connect command");
1045 }
1046 assert_eq!(transport.realtime_connects, 1);
1047
1048 dispatch(
1049 &mut transport,
1050 &mut client,
1051 &mut effects,
1052 "disconnectRealtime",
1053 &json!({}),
1054 )
1055 .expect("disconnect command");
1056 dispatch(
1057 &mut transport,
1058 &mut client,
1059 &mut effects,
1060 "disconnectRealtime",
1061 &json!({}),
1062 )
1063 .expect("idempotent disconnect command");
1064 assert_eq!(transport.realtime_closes, 1);
1065 dispatch(
1066 &mut transport,
1067 &mut client,
1068 &mut effects,
1069 "connectRealtime",
1070 &json!({}),
1071 )
1072 .expect("deliberate reconnect command");
1073 assert_eq!(transport.realtime_connects, 2);
1074 }
1075
1076 #[test]
1077 fn parses_portable_encryption_keyring_and_key_id_columns() {
1078 let key_hex = "2a".repeat(32);
1079 let config = parse_encryption(&json!({
1080 "keys": { "practice-key-v1": { "$bytes": key_hex } },
1081 "keyIdColumns": { "patients": "encryption_key_id" }
1082 }))
1083 .expect("portable keyring parses");
1084 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1085 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1086 }
1087
1088 #[test]
1089 fn rejects_non_string_key_id_columns() {
1090 let error = parse_encryption(&json!({
1091 "keys": {},
1092 "keyIdColumns": { "patients": 7 }
1093 }))
1094 .expect_err("invalid selector must fail");
1095 assert!(error.contains("must be a string"), "{error}");
1096 }
1097
1098 #[test]
1099 fn native_command_hosts_reject_subscription_identity_rebinds() {
1100 let mut transport = NoNetwork::default();
1101 let mut client: Option<SyncClient> = None;
1102 let mut effects = CreateEffects::default();
1103 dispatch(
1104 &mut transport,
1105 &mut client,
1106 &mut effects,
1107 "create",
1108 &json!({ "schema": schema() }),
1109 )
1110 .expect("create client");
1111
1112 let original = json!({
1113 "id": "stable-subscription",
1114 "table": "todos",
1115 "scopes": { "list_id": ["list-2", "list-1"] },
1116 "params": "{\"view\":\"v1\"}"
1117 });
1118 dispatch(
1119 &mut transport,
1120 &mut client,
1121 &mut effects,
1122 "subscribe",
1123 &original,
1124 )
1125 .expect("register subscription");
1126 dispatch(
1127 &mut transport,
1128 &mut client,
1129 &mut effects,
1130 "subscribe",
1131 &json!({
1132 "id": "stable-subscription",
1133 "table": "todos",
1134 "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
1135 "params": "{\"view\":\"v1\"}"
1136 }),
1137 )
1138 .expect("canonical re-declaration is idempotent");
1139
1140 let error = dispatch(
1141 &mut transport,
1142 &mut client,
1143 &mut effects,
1144 "subscribe",
1145 &json!({
1146 "id": "stable-subscription",
1147 "table": "todos",
1148 "scopes": { "list_id": ["list-2"] },
1149 "params": "{\"view\":\"v1\"}"
1150 }),
1151 )
1152 .expect_err("changed native query identity must fail");
1153 assert_eq!(error.0, "client.subscription_intent_mismatch");
1154
1155 let state = dispatch(
1156 &mut transport,
1157 &mut client,
1158 &mut effects,
1159 "subscriptionState",
1160 &json!({ "id": "stable-subscription" }),
1161 )
1162 .expect("subscription state");
1163 assert_eq!(state["state"]["cursor"], -1);
1164 assert_eq!(state["state"]["table"], "todos");
1165 }
1166
1167 #[test]
1168 fn security_preflight_is_fail_closed_until_exact_activation() {
1169 let mut transport = NoNetwork::default();
1170 let mut client: Option<SyncClient> = None;
1171 let mut effects = CreateEffects::default();
1172 dispatch(
1173 &mut transport,
1174 &mut client,
1175 &mut effects,
1176 "create",
1177 &json!({ "schema": schema(), "securityPreflight": true }),
1178 )
1179 .expect("preflight create");
1180
1181 let lifecycle = dispatch(
1182 &mut transport,
1183 &mut client,
1184 &mut effects,
1185 "securityLifecycle",
1186 &json!({}),
1187 )
1188 .expect("lifecycle");
1189 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1190
1191 let query_error = dispatch(
1192 &mut transport,
1193 &mut client,
1194 &mut effects,
1195 "query",
1196 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1197 )
1198 .expect_err("protected query must fail");
1199 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1200 let diagnostics_error = dispatch(
1201 &mut transport,
1202 &mut client,
1203 &mut effects,
1204 "diagnosticsSnapshot",
1205 &json!({}),
1206 )
1207 .expect_err("diagnostics table/subscription evidence remains protected");
1208 assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1209
1210 dispatch(
1211 &mut transport,
1212 &mut client,
1213 &mut effects,
1214 "purgeLocalData",
1215 &json!({
1216 "input": {
1217 "purgeId": "directive-1",
1218 "targets": [{
1219 "table": "todos",
1220 "selectors": { "list_id": ["list-1"] }
1221 }]
1222 }
1223 }),
1224 )
1225 .expect("authorized local purge remains available");
1226
1227 let repair_error = dispatch(
1228 &mut transport,
1229 &mut client,
1230 &mut effects,
1231 "rebootstrapLocalData",
1232 &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1233 )
1234 .expect_err("projection repair must remain protected during preflight");
1235 assert_eq!(repair_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1236
1237 dispatch(
1238 &mut transport,
1239 &mut client,
1240 &mut effects,
1241 "activateSecurity",
1242 &json!({}),
1243 )
1244 .expect("activation");
1245 let rows = dispatch(
1246 &mut transport,
1247 &mut client,
1248 &mut effects,
1249 "query",
1250 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1251 )
1252 .expect("active query");
1253 assert_eq!(rows, json!({ "rows": [] }));
1254
1255 dispatch(
1256 &mut transport,
1257 &mut client,
1258 &mut effects,
1259 "beginSecurityPreflight",
1260 &json!({}),
1261 )
1262 .expect("re-enter preflight");
1263 let blocked = dispatch(
1264 &mut transport,
1265 &mut client,
1266 &mut effects,
1267 "mutate",
1268 &json!({ "mutations": [] }),
1269 )
1270 .expect_err("mutation must be gated");
1271 assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1272 }
1273
1274 #[test]
1275 fn preflight_refuses_a_replacement_create_without_the_flag() {
1276 let mut transport = NoNetwork::default();
1277 let mut client: Option<SyncClient> = None;
1278 let mut effects = CreateEffects::default();
1279 dispatch(
1280 &mut transport,
1281 &mut client,
1282 &mut effects,
1283 "create",
1284 &json!({ "schema": schema(), "securityPreflight": true }),
1285 )
1286 .expect("preflight create");
1287
1288 let escape = dispatch(
1290 &mut transport,
1291 &mut client,
1292 &mut effects,
1293 "create",
1294 &json!({ "schema": schema() }),
1295 )
1296 .expect_err("plain create must be refused during preflight");
1297 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1298 let query_error = dispatch(
1300 &mut transport,
1301 &mut client,
1302 &mut effects,
1303 "query",
1304 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1305 )
1306 .expect_err("protected query stays gated");
1307 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1308
1309 dispatch(
1311 &mut transport,
1312 &mut client,
1313 &mut effects,
1314 "create",
1315 &json!({ "schema": schema(), "securityPreflight": true }),
1316 )
1317 .expect("preflighted replacement create");
1318 let still_gated = dispatch(
1319 &mut transport,
1320 &mut client,
1321 &mut effects,
1322 "query",
1323 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1324 )
1325 .expect_err("replacement stays gated");
1326 assert_eq!(still_gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1327
1328 dispatch(
1330 &mut transport,
1331 &mut client,
1332 &mut effects,
1333 "activateSecurity",
1334 &json!({}),
1335 )
1336 .expect("activation");
1337 dispatch(
1338 &mut transport,
1339 &mut client,
1340 &mut effects,
1341 "create",
1342 &json!({ "schema": schema() }),
1343 )
1344 .expect("plain create after activation");
1345 let rows = dispatch(
1346 &mut transport,
1347 &mut client,
1348 &mut effects,
1349 "query",
1350 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1351 )
1352 .expect("active query");
1353 assert_eq!(rows, json!({ "rows": [] }));
1354 }
1355
1356 #[test]
1357 fn preflight_gate_survives_shutdown_before_replacement_creates() {
1358 let mut transport = NoNetwork::default();
1359 let mut client: Option<SyncClient> = None;
1360 let mut effects = CreateEffects::default();
1361 dispatch(
1362 &mut transport,
1363 &mut client,
1364 &mut effects,
1365 "create",
1366 &json!({ "schema": schema(), "securityPreflight": true }),
1367 )
1368 .expect("preflight create");
1369 dispatch(
1370 &mut transport,
1371 &mut client,
1372 &mut effects,
1373 "shutdown",
1374 &json!({}),
1375 )
1376 .expect("shutdown during preflight");
1377 assert!(client.is_none());
1378
1379 let escape = dispatch(
1381 &mut transport,
1382 &mut client,
1383 &mut effects,
1384 "create",
1385 &json!({ "schema": schema() }),
1386 )
1387 .expect_err("shutdown + plain create must stay refused");
1388 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1389
1390 dispatch(
1391 &mut transport,
1392 &mut client,
1393 &mut effects,
1394 "create",
1395 &json!({ "schema": schema(), "securityPreflight": true }),
1396 )
1397 .expect("preflighted re-create");
1398 dispatch(
1399 &mut transport,
1400 &mut client,
1401 &mut effects,
1402 "activateSecurity",
1403 &json!({}),
1404 )
1405 .expect("activation");
1406
1407 dispatch(
1409 &mut transport,
1410 &mut client,
1411 &mut effects,
1412 "shutdown",
1413 &json!({}),
1414 )
1415 .expect("shutdown after activation");
1416 dispatch(
1417 &mut transport,
1418 &mut client,
1419 &mut effects,
1420 "create",
1421 &json!({ "schema": schema() }),
1422 )
1423 .expect("plain create after an activated shutdown");
1424 }
1425
1426 #[test]
1427 fn activate_security_validates_optional_headers_atomically() {
1428 let mut transport = NoNetwork::default();
1429 let mut client: Option<SyncClient> = None;
1430 let mut effects = CreateEffects::default();
1431 dispatch(
1432 &mut transport,
1433 &mut client,
1434 &mut effects,
1435 "create",
1436 &json!({ "schema": schema(), "securityPreflight": true }),
1437 )
1438 .expect("preflight create");
1439
1440 let invalid = dispatch(
1442 &mut transport,
1443 &mut client,
1444 &mut effects,
1445 "activateSecurity",
1446 &json!({ "headers": { "authorization": 7 } }),
1447 )
1448 .expect_err("non-string header must fail");
1449 assert_eq!(invalid.0, "sync.invalid_request");
1450 let lifecycle = dispatch(
1451 &mut transport,
1452 &mut client,
1453 &mut effects,
1454 "securityLifecycle",
1455 &json!({}),
1456 )
1457 .expect("lifecycle");
1458 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1459
1460 dispatch(
1462 &mut transport,
1463 &mut client,
1464 &mut effects,
1465 "activateSecurity",
1466 &json!({ "headers": { "authorization": "Bearer fresh" } }),
1467 )
1468 .expect("activation with fresh headers");
1469 let lifecycle = dispatch(
1470 &mut transport,
1471 &mut client,
1472 &mut effects,
1473 "securityLifecycle",
1474 &json!({}),
1475 )
1476 .expect("lifecycle");
1477 assert_eq!(lifecycle, json!({ "state": "active" }));
1478 }
1479
1480 #[test]
1481 fn parse_headers_reads_the_full_replacement_set() {
1482 let parsed = parse_headers(&json!({
1483 "authorization": "Bearer fresh",
1484 "x-tenant": "t1"
1485 }))
1486 .expect("valid headers parse");
1487 assert_eq!(
1488 parsed,
1489 vec![
1490 ("authorization".to_owned(), "Bearer fresh".to_owned()),
1491 ("x-tenant".to_owned(), "t1".to_owned())
1492 ]
1493 );
1494 assert!(parse_headers(&json!(["authorization"])).is_err());
1495 assert!(parse_headers(&json!({ "authorization": null })).is_err());
1496 }
1497}