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::previous_version::{PreviousVersionContextConfig, PreviousVersionReadSpec};
23use syncular_client::{
24 ClientError, ClientLimits, CommandEffects, CommitOutcomeQuery, LocalDataPurgeInput,
25 LocalDataRebootstrapInput, Mutation, QueryOwner, QueryReadFailure, RealtimePolicy,
26 ResolveCommitOutcomeInput, SyncClient, Transport, TransportError, UnitScopes, WindowBase,
27 WindowCoverage, LOCAL_AUTHORITY_UNCONFIGURED_CODE,
28};
29
30pub fn bytes_to_hex(bytes: &[u8]) -> String {
33 syncular_client::values::bytes_to_hex(bytes)
34}
35
36pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
37 syncular_client::values::hex_to_bytes(hex)
38}
39
40pub fn bytes_value(bytes: &[u8]) -> Value {
41 Value::Object(serde_json::Map::from_iter([(
42 "$bytes".to_owned(),
43 Value::from(bytes_to_hex(bytes)),
44 )]))
45}
46
47pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
48 let hex = value
49 .and_then(|v| v.get("$bytes"))
50 .and_then(Value::as_str)
51 .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
52 hex_to_bytes(hex)
53}
54
55#[derive(Debug, Clone)]
59pub struct CommandError {
60 pub code: String,
61 pub message: String,
62 pub details: Option<Value>,
63 pub retryable: bool,
64}
65
66impl CommandError {
67 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
68 Self {
69 code: code.into(),
70 message: message.into(),
71 details: None,
72 retryable: false,
73 }
74 }
75
76 pub fn from_client(error: ClientError) -> Self {
78 Self {
79 code: error.code,
80 message: error.message,
81 details: error.details,
82 retryable: error.retryable,
83 }
84 }
85}
86
87impl From<(String, String)> for CommandError {
88 fn from((code, message): (String, String)) -> Self {
89 Self {
90 code,
91 message,
92 details: None,
93 retryable: false,
94 }
95 }
96}
97
98#[derive(Debug, Default, Clone)]
102pub struct CreateEffects {
103 pub signed_urls: bool,
106 pub security_preflight_pending: bool,
122}
123
124fn read_command_error(failure: QueryReadFailure) -> CommandError {
127 let details = failure.details();
128 let retryable = failure.retryable();
129 CommandError {
130 code: failure.code.unwrap_or("client.failed").to_owned(),
131 message: failure.message,
132 details,
133 retryable,
134 }
135}
136
137fn client_err(message: String) -> CommandError {
141 let code = ClientError::from(message.clone()).code;
142 let retryable = code == "client.storage_busy";
143 CommandError {
144 code,
145 message,
146 details: None,
147 retryable,
148 }
149}
150
151fn optional<'a, T>(
155 value: Option<&'a Value>,
156 field: &str,
157 read: impl FnOnce(&'a Value) -> Option<T>,
158) -> Result<Option<T>, String> {
159 match value {
160 None | Some(Value::Null) => Ok(None),
161 Some(value) => read(value)
162 .map(Some)
163 .ok_or_else(|| format!("sync.invalid_request: {field} has the wrong type")),
164 }
165}
166
167fn transport_command_error(error: TransportError) -> CommandError {
170 CommandError {
171 code: error.code,
172 message: error.message,
173 details: error.details,
174 retryable: false,
175 }
176}
177
178fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
179 client
180 .as_mut()
181 .ok_or_else(|| client_err("no client instance created".to_owned()))
182}
183
184pub fn parse_previous_version_context(
189 value: Option<&Value>,
190) -> Result<Option<PreviousVersionContextConfig>, CommandError> {
191 let Some(value) = value else {
192 return Ok(None);
193 };
194 let object = value.as_object().ok_or_else(|| {
195 client_err("sync.invalid_request: previousVersionContext must be an object".to_owned())
196 })?;
197 let enabled = match object.get("enabled") {
198 None => false,
199 Some(Value::Bool(enabled)) => *enabled,
200 Some(_) => {
201 return Err(client_err(
202 "sync.invalid_request: previousVersionContext.enabled must be a boolean".to_owned(),
203 ))
204 }
205 };
206 let mut config = PreviousVersionContextConfig {
207 enabled,
208 ..PreviousVersionContextConfig::default()
209 };
210 for (key, slot) in [
211 ("maxBytes", &mut config.max_bytes),
212 ("maxRows", &mut config.max_rows),
213 ("maxTables", &mut config.max_tables),
214 ("maxRowBytes", &mut config.max_row_bytes),
215 ("maxAgeMs", &mut config.max_age_ms),
216 ] {
217 if let Some(raw) = object.get(key) {
218 *slot = raw.as_i64().ok_or_else(|| {
219 client_err(format!(
220 "sync.invalid_request: previousVersionContext.{key} must be a positive safe integer"
221 ))
222 })?;
223 }
224 }
225 config.validate().map_err(client_err)?;
226 Ok(Some(config))
227}
228
229fn parse_previous_version_snapshot_spec(
231 params: &Value,
232) -> Result<PreviousVersionReadSpec, CommandError> {
233 let table = params.get("table").and_then(Value::as_str).ok_or_else(|| {
234 client_err("sync.invalid_request: previousVersionSnapshot missing table".to_owned())
235 })?;
236 let row_ids = match params.get("rowIds") {
237 None | Some(Value::Null) => Vec::new(),
238 Some(Value::Array(ids)) => ids
239 .iter()
240 .map(|id| match id {
241 Value::String(id) => Ok(id.clone()),
242 Value::Number(number) => Ok(number.to_string()),
243 Value::Bool(boolean) => Ok(boolean.to_string()),
244 _ => Err(client_err(
245 "sync.invalid_request: previousVersionSnapshot rowIds must be strings"
246 .to_owned(),
247 )),
248 })
249 .collect::<Result<Vec<_>, _>>()?,
250 Some(_) => {
251 return Err(client_err(
252 "sync.invalid_request: previousVersionSnapshot rowIds must be an array".to_owned(),
253 ))
254 }
255 };
256 let limit = match params.get("limit") {
257 None | Some(Value::Null) => None,
258 Some(value) => Some(value.as_i64().ok_or_else(|| {
259 client_err(
260 "sync.invalid_request: previousVersionSnapshot limit must be a positive number"
261 .to_owned(),
262 )
263 })?),
264 };
265 Ok(PreviousVersionReadSpec {
266 table: table.to_owned(),
267 row_ids,
268 limit,
269 })
270}
271
272pub fn parse_limits(value: Option<&Value>) -> Result<ClientLimits, CommandError> {
273 let mut limits = ClientLimits::default();
274 let Some(object) = optional(value, "limits", Value::as_object).map_err(client_err)? else {
275 return Ok(limits);
276 };
277 let i32_field = |key: &str| {
278 optional(object.get(key), key, |v| {
279 v.as_i64().and_then(|n| i32::try_from(n).ok())
280 })
281 .map_err(client_err)
282 };
283 limits.limit_commits = i32_field("limitCommits")?;
284 limits.limit_snapshot_rows = i32_field("limitSnapshotRows")?;
285 limits.max_snapshot_pages = i32_field("maxSnapshotPages")?;
286 limits.accept = optional(object.get("accept"), "limits.accept", |v| {
287 v.as_u64().and_then(|n| u8::try_from(n).ok())
288 })
289 .map_err(client_err)?;
290 limits.blob_cache_max_bytes = optional(
291 object.get("blobCacheMaxBytes"),
292 "limits.blobCacheMaxBytes",
293 Value::as_i64,
294 )
295 .map_err(client_err)?;
296 limits.outcome_retention_max_entries = optional(
297 object.get("outcomeRetentionMaxEntries"),
298 "limits.outcomeRetentionMaxEntries",
299 |v| v.as_u64().and_then(|n| usize::try_from(n).ok()),
300 )
301 .map_err(client_err)?;
302 limits.max_push_commits_per_request = parse_push_limit(object, "maxPushCommitsPerRequest")?;
303 limits.max_push_operations_per_request =
304 parse_push_limit(object, "maxPushOperationsPerRequest")?;
305 limits.max_push_request_bytes = parse_push_limit(object, "maxPushRequestBytes")?;
306 Ok(limits)
307}
308
309fn parse_push_limit(
312 object: &serde_json::Map<String, Value>,
313 key: &str,
314) -> Result<Option<usize>, CommandError> {
315 match object.get(key) {
316 None | Some(Value::Null) => Ok(None),
317 Some(value) => value
318 .as_u64()
319 .and_then(|n| usize::try_from(n).ok())
320 .filter(|n| *n >= 1 && *n <= u32::MAX as usize)
321 .map(Some)
322 .ok_or_else(|| CommandError {
323 code: "sync.invalid_request".to_owned(),
324 message: "push limit must be an integer in 1..=4294967295".to_owned(),
325 details: Some(serde_json::json!({ "limit": key })),
326 retryable: false,
327 }),
328 }
329}
330
331fn parse_realtime_policy(value: Option<&Value>) -> Result<RealtimePolicy, CommandError> {
333 match value {
334 None => Ok(RealtimePolicy::Optional),
335 Some(Value::String(text)) => match text.as_str() {
336 "required" => Ok(RealtimePolicy::Required),
337 "optional" => Ok(RealtimePolicy::Optional),
338 "off" => Ok(RealtimePolicy::Off),
339 other => Err(client_err(format!(
340 "sync.invalid_request: realtimePolicy must be 'required', 'optional', or 'off', got {other:?}"
341 ))),
342 },
343 Some(_) => Err(client_err(
344 "sync.invalid_request: realtimePolicy must be a string".to_owned(),
345 )),
346 }
347}
348
349pub fn parse_encryption(
353 value: &Value,
354) -> Result<syncular_client::values::EncryptionConfig, String> {
355 let mut config = syncular_client::values::EncryptionConfig::default();
356 let Some(keys) = optional(value.get("keys"), "encryption.keys", Value::as_object)? else {
357 return Ok(config);
358 };
359 for (key_id, key_val) in keys {
360 let bytes =
361 value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
362 if bytes.len() != 32 {
363 return Err(format!(
364 "encryption key {key_id:?} must be 32 bytes, got {}",
365 bytes.len()
366 ));
367 }
368 config.keys.insert(key_id.clone(), bytes);
369 }
370 if let Some(columns) = value.get("keyIdColumns") {
371 let columns = columns
372 .as_object()
373 .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
374 for (table, column) in columns {
375 let column = column.as_str().ok_or_else(|| {
376 format!("encryption keyIdColumns entry for {table:?} must be a string")
377 })?;
378 if column.is_empty() {
379 return Err(format!(
380 "encryption keyIdColumns entry for {table:?} must not be empty"
381 ));
382 }
383 config
384 .key_id_columns
385 .insert(table.clone(), column.to_owned());
386 }
387 }
388 Ok(config)
389}
390
391pub fn parse_headers(value: &Value) -> Result<Vec<(String, String)>, String> {
396 let object = value.as_object().ok_or_else(|| {
397 "sync.invalid_request: headers must be an object of string values".to_owned()
398 })?;
399 let mut headers = Vec::with_capacity(object.len());
400 for (name, value) in object {
401 let value = value
402 .as_str()
403 .ok_or_else(|| format!("sync.invalid_request: header {name:?} must be a string"))?;
404 headers.push((name.clone(), value.to_owned()));
405 }
406 Ok(headers)
407}
408
409pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
410 let list = value
411 .and_then(Value::as_array)
412 .ok_or_else(|| "mutations must be a list".to_owned())?;
413 let mut out = Vec::with_capacity(list.len());
414 for entry in list {
415 let op = entry
416 .get("op")
417 .and_then(Value::as_str)
418 .ok_or_else(|| "mutation missing op".to_owned())?;
419 let table = entry
420 .get("table")
421 .and_then(Value::as_str)
422 .ok_or_else(|| "mutation missing table".to_owned())?
423 .to_owned();
424 let base_version = optional(entry.get("baseVersion"), "baseVersion", Value::as_i64)?;
425 match op {
426 "upsert" | "patch" => {
427 let mut values = entry
428 .get("values")
429 .and_then(Value::as_object)
430 .cloned()
431 .ok_or_else(|| "upsert missing values".to_owned())?;
432 decode_bigint_members(&mut values)?;
433 out.push(if op == "patch" {
434 Mutation::Patch {
435 table,
436 values,
437 base_version,
438 }
439 } else {
440 Mutation::Upsert {
441 table,
442 values,
443 base_version,
444 }
445 });
446 }
447 "delete" => {
448 let row_id = entry
449 .get("rowId")
450 .and_then(Value::as_str)
451 .ok_or_else(|| "delete missing rowId".to_owned())?
452 .to_owned();
453 out.push(Mutation::Delete {
454 table,
455 row_id,
456 base_version,
457 });
458 }
459 other => return Err(format!("unknown mutation op {other:?}")),
460 }
461 }
462 Ok(out)
463}
464
465fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
466 for value in values.values_mut() {
467 let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
468 continue;
469 };
470 let integer = decimal
471 .parse::<i64>()
472 .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
473 *value = Value::from(integer);
474 }
475 Ok(())
476}
477
478fn unit_scopes(value: Option<&Value>) -> Result<UnitScopes, String> {
480 serde_json::from_value(value.cloned().unwrap_or(Value::Null)).map_err(|_| {
481 "sync.invalid_request: a unit scope map is an object of string values".to_owned()
482 })
483}
484
485fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
488 let object = value
489 .and_then(Value::as_object)
490 .ok_or_else(|| "sync.invalid_request: window base must be an object".to_owned())?;
491 let variable = object
492 .get("variable")
493 .and_then(Value::as_str)
494 .ok_or_else(|| "sync.invalid_request: window base missing variable".to_owned())?
495 .to_owned();
496 Ok(WindowBase {
497 variable,
498 fixed_scopes: match object.get("fixedScopes") {
499 None | Some(Value::Null) => UnitScopes::new(),
500 scopes => unit_scopes(scopes)?,
501 },
502 })
503}
504
505pub fn parse_window_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
510 let entries = match value {
511 None | Some(Value::Null) => return Ok(Vec::new()),
512 Some(Value::Array(entries)) => entries,
513 Some(_) => return Err("sync.invalid_request: coverage must be a list".to_owned()),
514 };
515 let mut coverage = Vec::with_capacity(entries.len());
516 for entry in entries {
517 let units = match entry.get("units") {
518 None | Some(Value::Null) => Vec::new(),
519 Some(Value::Array(values)) => values
520 .iter()
521 .map(|value| value.as_str().map(str::to_owned))
522 .collect::<Option<Vec<_>>>()
523 .ok_or_else(|| {
524 "sync.invalid_request: coverage units must be a list of strings".to_owned()
525 })?,
526 Some(_) => {
527 return Err(
528 "sync.invalid_request: coverage units must be a list of strings".to_owned(),
529 )
530 }
531 };
532 coverage.push(WindowCoverage {
533 base: window_base_from_params(entry.get("base"))?,
534 units,
535 });
536 }
537 Ok(coverage)
538}
539
540fn parse_query_owner<'a>(
543 params: &'a Value,
544 method: &str,
545) -> Result<Option<(&'a str, Vec<&'a str>)>, CommandError> {
546 match params.get("owner") {
547 None | Some(Value::Null) => Ok(None),
548 Some(value) => {
549 let id = value
550 .get("id")
551 .and_then(Value::as_str)
552 .filter(|id| !id.is_empty());
553 let tables = value
554 .get("tables")
555 .and_then(Value::as_array)
556 .and_then(|list| {
557 list.iter()
558 .map(Value::as_str)
559 .collect::<Option<Vec<&str>>>()
560 });
561 match (id, tables) {
562 (Some(id), Some(tables)) => Ok(Some((id, tables))),
563 _ => Err(client_err(format!(
564 "sync.invalid_request: {method} owner must be {{id: non-empty string, tables: string[]}}"
565 ))),
566 }
567 }
568 }
569}
570
571fn snapshot_string_list(value: Option<&Value>) -> Result<Vec<String>, CommandError> {
574 match value {
575 None | Some(Value::Null) => Ok(Vec::new()),
576 Some(Value::Array(items)) => items
577 .iter()
578 .map(|item| item.as_str().map(str::to_owned))
579 .collect::<Option<Vec<String>>>()
580 .ok_or_else(|| {
581 client_err(
582 "sync.invalid_request: snapshotRead list must contain strings".to_owned(),
583 )
584 }),
585 Some(_) => Err(client_err(
586 "sync.invalid_request: snapshotRead list must be a list of strings".to_owned(),
587 )),
588 }
589}
590
591#[cfg(feature = "crdt-yjs")]
595fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
596 let table = params
597 .get("table")
598 .and_then(Value::as_str)
599 .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
600 .to_owned();
601 let row_id = params
602 .get("rowId")
603 .and_then(Value::as_str)
604 .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
605 .to_owned();
606 let column = params
607 .get("column")
608 .and_then(Value::as_str)
609 .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
610 .to_owned();
611 let name = optional(params.get("name"), "crdt name", Value::as_str)
612 .map_err(client_err)?
613 .unwrap_or("text")
614 .to_owned();
615 Ok((table, row_id, column, name))
616}
617
618pub fn dispatch<T: Transport>(
628 transport: &mut T,
629 client: &mut Option<SyncClient>,
630 effects: &mut CreateEffects,
631 method: &str,
632 params: &Value,
633) -> Result<Value, CommandError> {
634 if method == "create" {
635 let requests_preflight = optional(
642 params.get("securityPreflight"),
643 "securityPreflight",
644 Value::as_bool,
645 )
646 .map_err(client_err)?
647 .unwrap_or(false);
648 let preflight_engaged = client.as_ref().map_or(
649 effects.security_preflight_pending,
650 SyncClient::security_preflight,
651 );
652 if preflight_engaged && !requests_preflight {
653 return Err(CommandError::new(
654 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
655 "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity",
656 ));
657 }
658 } else {
659 let allowed_during_preflight = matches!(
660 method,
661 "authoritySnapshot"
662 | "securityLifecycle"
663 | "beginSecurityPreflight"
664 | "activateSecurity"
665 | "setTransportEnabled"
666 | "purgeLocalData"
667 | "previousVersionDiscard"
668 | "localRevision"
669 | "statusSnapshot"
670 | "localAuthorityState"
671 | "drainRealtime"
672 | "shutdown"
673 );
674 if client
675 .as_ref()
676 .is_some_and(|running| running.security_preflight())
677 && !allowed_during_preflight
678 {
679 return Err(CommandError::new(
680 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
681 "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data",
682 ));
683 }
684 }
685 let mut result = match method {
686 "create" => {
687 let client_id = optional(params.get("clientId"), "clientId", Value::as_str)
688 .map_err(client_err)?
689 .map(str::to_owned);
690 let db_path =
691 optional(params.get("dbPath"), "dbPath", Value::as_str).map_err(client_err)?;
692 let schema = params
693 .get("schema")
694 .ok_or_else(|| client_err("create missing schema".to_owned()))?;
695 let limits = parse_limits(params.get("limits"))?;
696 let previous_version_context =
700 parse_previous_version_context(params.get("previousVersionContext"))?;
701 let mut limits = limits;
702 limits.previous_version_context = previous_version_context;
703 if let Some(reads) = params.get("authorityReads") {
704 limits.authority_reads = serde_json::from_value(reads.clone()).map_err(|_| {
705 client_err(
706 "client.authority_read_forbidden: invalid authority declaration".into(),
707 )
708 })?;
709 }
710 transport
713 .open_local_authority(schema, db_path)
714 .map_err(transport_command_error)?;
715 let mut instance = match db_path {
719 Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
720 .map_err(client_err)?,
721 None => {
722 SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
723 }
724 };
725 instance
726 .set_retain_failed_commits(
727 optional(
728 params.get("retainFailedCommits"),
729 "retainFailedCommits",
730 Value::as_bool,
731 )
732 .map_err(client_err)?
733 .unwrap_or(false),
734 )
735 .map_err(client_err)?;
736 let realtime_policy = parse_realtime_policy(params.get("realtimePolicy"))?;
739 instance
740 .set_realtime_policy(realtime_policy)
741 .map_err(client_err)?;
742 if let Some(now_ms) =
744 optional(params.get("nowMs"), "nowMs", Value::as_i64).map_err(client_err)?
745 {
746 instance.set_now_ms(now_ms);
747 }
748 effects.signed_urls = optional(params.get("signedUrls"), "signedUrls", Value::as_bool)
751 .map_err(client_err)?
752 .unwrap_or(false);
753 let security_preflight = optional(
757 params.get("securityPreflight"),
758 "securityPreflight",
759 Value::as_bool,
760 )
761 .map_err(client_err)?
762 .unwrap_or(false);
763 if instance.security_preflight() && !security_preflight {
771 return Err(CommandError::new(
772 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
773 "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity",
774 ));
775 }
776 if security_preflight && params.get("encryption").is_some() {
777 return Err(client_err(
778 "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
779 .to_owned(),
780 ));
781 }
782 if let Some(enc) = params.get("encryption") {
783 let config = parse_encryption(enc).map_err(client_err)?;
784 instance.set_encryption(config);
785 }
786 if security_preflight {
787 instance.begin_security_preflight().map_err(client_err)?;
788 }
789 if let Some(enabled) = params.get("transportEnabled") {
790 let enabled = enabled.as_bool().ok_or_else(|| {
791 client_err("sync.invalid_request: transportEnabled must be a boolean".into())
792 })?;
793 instance.set_transport_enabled(transport, enabled);
794 }
795 effects.security_preflight_pending = security_preflight;
796 *client = Some(instance);
797 Ok(json!({}))
798 }
799 "securityLifecycle" => Ok(json!({
800 "state": need_client(client)?.security_lifecycle()
801 })),
802 "beginSecurityPreflight" => {
803 let running = need_client(client)?;
804 running.disconnect_realtime(transport);
805 running.begin_security_preflight().map_err(client_err)?;
806 effects.security_preflight_pending = true;
807 Ok(json!({}))
808 }
809 "authoritySnapshot" => {
810 if !params.as_object().is_some_and(|p| p.is_empty()) {
811 return Err(client_err(
812 "client.authority_read_forbidden: authoritySnapshot accepts no arguments"
813 .into(),
814 ));
815 }
816 client
817 .as_ref()
818 .ok_or_else(|| client_err("client.closed: no authority client is open".into()))?
819 .authority_snapshot()
820 .map_err(client_err)
821 }
822 "activateSecurity" => {
823 let encryption = match params.get("encryption") {
824 Some(value) => parse_encryption(value).map_err(client_err)?,
825 None => syncular_client::values::EncryptionConfig::default(),
826 };
827 if let Some(headers) = params.get("headers") {
833 parse_headers(headers).map_err(client_err)?;
834 }
835 need_client(client)?
836 .activate_security(encryption)
837 .map_err(client_err)?;
838 effects.security_preflight_pending = false;
839 Ok(json!({}))
840 }
841 "setTransportEnabled" => {
842 let enabled = params
843 .get("enabled")
844 .and_then(Value::as_bool)
845 .ok_or_else(|| {
846 client_err(
847 "sync.invalid_request: setTransportEnabled.enabled must be a boolean"
848 .into(),
849 )
850 })?;
851 need_client(client)?.set_transport_enabled(transport, enabled);
852 Ok(json!({}))
853 }
854 "setHeaders" => {
855 let headers = params
856 .get("headers")
857 .ok_or_else(|| client_err("setHeaders missing headers".to_owned()))?;
858 parse_headers(headers).map_err(client_err)?;
859 Ok(json!({}))
860 }
861 "shutdown" => {
862 if let Some(running) = client.as_mut() {
863 effects.security_preflight_pending = running.security_preflight();
868 running.disconnect_realtime(transport);
869 running.seal_security_on_teardown();
873 }
874 *client = None;
875 Ok(json!({}))
876 }
877 "subscribe" => {
878 let scopes = unit_scopes(params.get("scopes")).map_err(client_err)?;
879 let id = need_client(client)?.subscribe(scopes).map_err(client_err)?;
880 Ok(json!({ "id": id }))
881 }
882 "subscribeMany" => {
883 let units = params
884 .get("units")
885 .and_then(Value::as_array)
886 .ok_or_else(|| {
887 client_err(
888 "sync.invalid_request: subscribeMany units must be a list".to_owned(),
889 )
890 })?
891 .iter()
892 .map(|scopes| unit_scopes(Some(scopes)))
893 .collect::<Result<Vec<_>, _>>()
894 .map_err(client_err)?;
895 let ids = need_client(client)?
896 .subscribe_many(units)
897 .map_err(client_err)?;
898 Ok(json!({ "ids": ids }))
899 }
900 "unsubscribe" => {
901 let id = params
902 .get("id")
903 .and_then(Value::as_str)
904 .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
905 need_client(client)?.unsubscribe(id).map_err(client_err)?;
906 Ok(json!({}))
907 }
908 "setWindow" => {
909 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
910 let units: Vec<String> = optional(params.get("units"), "setWindow units", |v| {
911 v.as_array()?
912 .iter()
913 .map(|unit| unit.as_str().map(str::to_owned))
914 .collect()
915 })
916 .map_err(client_err)?
917 .unwrap_or_default();
918 let command_effects = need_client(client)?
919 .set_window(&base, &units)
920 .map_err(client_err)?;
921 Ok(json!({ "effects": command_effects }))
922 }
923 "windowState" => {
924 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
925 let state = need_client(client)?
926 .window_state(&base)
927 .map_err(client_err)?;
928 Ok(json!({ "units": state.units, "pending": state.pending }))
929 }
930 "timeWindowSugar" => {
931 let created_at_ms = params
932 .get("createdAtMs")
933 .and_then(Value::as_i64)
934 .ok_or_else(|| client_err("timeWindowSugar missing createdAtMs".to_owned()))?;
935 let count = params
936 .get("count")
937 .and_then(Value::as_u64)
938 .and_then(|value| usize::try_from(value).ok())
939 .ok_or_else(|| client_err("timeWindowSugar missing count".to_owned()))?;
940 let now_ms = params
941 .get("nowMs")
942 .and_then(Value::as_i64)
943 .ok_or_else(|| client_err("timeWindowSugar missing nowMs".to_owned()))?;
944 if params.get("unit").and_then(Value::as_str) != Some("month") {
945 return Err(client_err(
946 "sync.invalid_request: timeWindowSugar requires the month unit".to_owned(),
947 ));
948 }
949 let unit = syncular_client::TimeBucketUnit::Month;
950 Ok(json!({
951 "bucket": syncular_client::creation_time_bucket(created_at_ms, unit)
952 .map_err(client_err)?,
953 "units": syncular_client::last(count, unit, now_ms).map_err(client_err)?,
954 }))
955 }
956 "mutate" => {
957 let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
958 let id = need_client(client)?
959 .mutate(mutations)
960 .map_err(CommandError::from_client)?;
961 Ok(json!({
962 "clientCommitId": id,
963 "effects": CommandEffects::interactive()
964 }))
965 }
966 "patch" => {
967 let table = params
968 .get("table")
969 .and_then(Value::as_str)
970 .ok_or_else(|| client_err("patch missing table".to_owned()))?;
971 let row_id = params
972 .get("rowId")
973 .and_then(Value::as_str)
974 .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
975 let mut partial = params
976 .get("partial")
977 .and_then(Value::as_object)
978 .cloned()
979 .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
980 decode_bigint_members(&mut partial).map_err(client_err)?;
981 let base_version = optional(params.get("baseVersion"), "baseVersion", Value::as_i64)
982 .map_err(client_err)?;
983 let id = need_client(client)?
984 .patch(table, row_id, partial, base_version)
985 .map_err(CommandError::from_client)?;
986 Ok(json!({
987 "clientCommitId": id,
988 "effects": CommandEffects::interactive()
989 }))
990 }
991 "purgeLocalData" => {
992 let input: LocalDataPurgeInput =
993 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
994 client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
995 })?)
996 .map_err(|error| {
997 client_err(format!(
998 "sync.invalid_request: invalid purgeLocalData input: {error}"
999 ))
1000 })?;
1001 let result = need_client(client)?
1002 .purge_local_data(&input)
1003 .map_err(client_err)?;
1004 serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
1005 }
1006 "rebootstrapLocalData" => {
1007 let input: LocalDataRebootstrapInput =
1008 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
1009 client_err(
1010 "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
1011 )
1012 })?)
1013 .map_err(|error| {
1014 client_err(format!(
1015 "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
1016 ))
1017 })?;
1018 let result = need_client(client)?
1019 .rebootstrap_local_data(&input)
1020 .map_err(client_err)?;
1021 Ok(json!({
1022 "alreadyApplied": result.already_applied,
1023 "retainedCommits": result.retained_commits,
1024 "resetSubscriptions": result.reset_subscriptions,
1025 "effects": if result.already_applied {
1026 CommandEffects::none()
1027 } else {
1028 CommandEffects::interactive()
1029 }
1030 }))
1031 }
1032 "sync" => {
1033 let outcome = need_client(client)?.sync(transport);
1034 Ok(outcome.to_json())
1035 }
1036 "resetClientId" | "reset_client_id" => {
1037 let client_id = need_client(client)?
1038 .reset_client_id(transport)
1039 .map_err(client_err)?;
1040 Ok(json!({ "clientId": client_id }))
1041 }
1042 "syncUntilIdle" => {
1043 let max_rounds = match params.get("maxRounds") {
1044 None | Some(Value::Null) => None,
1045 Some(value) => Some(
1046 value
1047 .as_u64()
1048 .and_then(|n| u32::try_from(n).ok())
1049 .filter(|n| *n >= 1)
1050 .ok_or_else(|| {
1051 client_err(
1052 "sync.invalid_request: maxRounds must be a positive integer"
1053 .to_owned(),
1054 )
1055 })?,
1056 ),
1057 };
1058 let timeout_ms = match params.get("timeoutMs") {
1059 None | Some(Value::Null) => None,
1060 Some(value) => Some(value.as_u64().filter(|ms| *ms >= 1).ok_or_else(|| {
1061 client_err(
1062 "sync.invalid_request: timeoutMs must be a positive integer".to_owned(),
1063 )
1064 })?),
1065 };
1066 let client = need_client(client)?;
1067 let outcome = match timeout_ms {
1068 Some(timeout_ms) => {
1069 client.sync_until_idle_with_deadline(transport, max_rounds, Some(timeout_ms))
1070 }
1071 None => client.sync_until_idle(transport, max_rounds),
1072 };
1073 Ok(outcome.to_json())
1074 }
1075 "proveCurrentBase" => {
1076 let outcome = params
1077 .get("outcome")
1078 .and_then(Value::as_object)
1079 .ok_or_else(|| client_err("proveCurrentBase missing outcome".to_owned()))?;
1080 let id = outcome
1081 .get("clientCommitId")
1082 .and_then(Value::as_str)
1083 .ok_or_else(|| client_err("proveCurrentBase missing clientCommitId".to_owned()))?;
1084 let sequence = outcome
1085 .get("sequence")
1086 .and_then(Value::as_i64)
1087 .ok_or_else(|| client_err("proveCurrentBase missing sequence".to_owned()))?;
1088 let max_rounds = match params.get("maxRounds") {
1089 None | Some(Value::Null) => None,
1090 Some(value) => Some(
1091 value
1092 .as_u64()
1093 .and_then(|n| u32::try_from(n).ok())
1094 .filter(|n| *n >= 1)
1095 .ok_or_else(|| {
1096 client_err(
1097 "sync.invalid_request: maxRounds must be a positive integer"
1098 .to_owned(),
1099 )
1100 })?,
1101 ),
1102 };
1103 Ok(json!(
1104 need_client(client)?.prove_current_base(transport, id, sequence, max_rounds)
1105 ))
1106 }
1107 "readRows" => {
1108 let table = params
1109 .get("table")
1110 .and_then(Value::as_str)
1111 .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
1112 let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
1113 Ok(json!({ "rows": rows }))
1114 }
1115 "query" => {
1116 let sql = params
1120 .get("sql")
1121 .and_then(Value::as_str)
1122 .ok_or_else(|| client_err("query missing sql".to_owned()))?;
1123 let bind = match params.get("params") {
1124 Some(Value::Array(list)) => list.as_slice(),
1125 None | Some(Value::Null) => &[],
1126 Some(_) => return Err(client_err("query params must be a list".to_owned())),
1127 };
1128 let rows = need_client(client)?.query(sql, bind).map_err(client_err)?;
1129 Ok(Value::Object(serde_json::Map::from_iter([(
1130 "rows".to_owned(),
1131 Value::Array(rows.into_iter().map(Value::Object).collect()),
1132 )])))
1133 }
1134 "querySnapshot" => {
1135 let sql = params
1136 .get("sql")
1137 .and_then(Value::as_str)
1138 .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
1139 let bind = match params.get("params") {
1140 Some(Value::Array(list)) => list.as_slice(),
1141 None | Some(Value::Null) => &[],
1142 Some(_) => {
1143 return Err(client_err("querySnapshot params must be a list".to_owned()))
1144 }
1145 };
1146 let coverage = parse_window_coverage(params.get("coverage")).map_err(client_err)?;
1147 let owner = parse_query_owner(params, "querySnapshot")?;
1148 let mut snapshot = need_client(client)?
1149 .query_snapshot(
1150 sql,
1151 bind,
1152 &coverage,
1153 owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
1154 )
1155 .map_err(client_err)?;
1156 let rows = std::mem::take(&mut snapshot.rows);
1157 let mut result = serde_json::to_value(snapshot).expect("snapshot serializes");
1158 result["rows"] = Value::Array(rows.into_iter().map(Value::Object).collect());
1159 Ok(result)
1160 }
1161 "localRevision" => {
1162 let read = need_client(client)?
1166 .snapshot_read(&syncular_client::SnapshotReadRequest::default(), None)
1167 .map_err(read_command_error)?;
1168 Ok(json!({ "revision": read.revision }))
1169 }
1170 "snapshotRead" => {
1171 let mut statement_sql: Vec<String> = Vec::new();
1172 let mut statement_params: Vec<Vec<Value>> = Vec::new();
1173 match params.get("statements") {
1174 None | Some(Value::Null) => {}
1175 Some(Value::Array(items)) => {
1176 for item in items {
1177 let Some(sql) = item.get("sql").and_then(Value::as_str) else {
1178 return Err(client_err(
1179 "sync.invalid_request: snapshotRead statements need a sql string"
1180 .to_owned(),
1181 ));
1182 };
1183 let bind = match item.get("params") {
1184 None | Some(Value::Null) => Vec::new(),
1185 Some(Value::Array(values)) => values.clone(),
1186 Some(_) => {
1187 return Err(client_err(
1188 "sync.invalid_request: snapshotRead statement params must be a list"
1189 .to_owned(),
1190 ))
1191 }
1192 };
1193 statement_sql.push(sql.to_owned());
1194 statement_params.push(bind);
1195 }
1196 }
1197 Some(_) => {
1198 return Err(client_err(
1199 "sync.invalid_request: snapshotRead statements must be a list".to_owned(),
1200 ))
1201 }
1202 }
1203 let units = snapshot_string_list(params.get("units"))?;
1204 let commit_ids = snapshot_string_list(params.get("commitIds"))?;
1205 let coverage = parse_window_coverage(params.get("coverage")).map_err(client_err)?;
1206 let owner = parse_query_owner(params, "snapshotRead")?;
1207 let statements: Vec<syncular_client::SnapshotStatement<'_>> = statement_sql
1208 .iter()
1209 .zip(&statement_params)
1210 .map(|(sql, params)| syncular_client::SnapshotStatement { sql, params })
1211 .collect();
1212 let read = need_client(client)?
1213 .snapshot_read(
1214 &syncular_client::SnapshotReadRequest {
1215 statements,
1216 coverage: &coverage,
1217 units,
1218 commit_ids,
1219 },
1220 owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
1221 )
1222 .map_err(read_command_error)?;
1223 serde_json::to_value(read).map_err(|error| client_err(error.to_string()))
1224 }
1225 "previousVersionSnapshot" | "previous_version_snapshot" => {
1226 let spec = parse_previous_version_snapshot_spec(params)?;
1227 let snapshot = need_client(client)?
1228 .previous_version_snapshot(&spec)
1229 .map_err(client_err)?;
1230 serde_json::to_value(snapshot).map_err(|error| client_err(error.to_string()))
1231 }
1232 "previousVersionAudit" | "previous_version_audit" => Ok(
1233 match need_client(client)?
1234 .previous_version_audit()
1235 .map_err(client_err)?
1236 {
1237 Some(audit) => {
1238 serde_json::to_value(audit).map_err(|error| client_err(error.to_string()))?
1239 }
1240 None => Value::Null,
1241 },
1242 ),
1243 "previousVersionDiscard" | "previous_version_discard" => {
1244 let outcome = need_client(client)?
1245 .previous_version_discard()
1246 .map_err(client_err)?;
1247 Ok(json!({
1248 "present": outcome.present,
1249 "discarded": outcome.discarded,
1250 }))
1251 }
1252 "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
1253 .expect("status serializes")),
1254 "diagnosticsSnapshot" => {
1255 let snapshot = need_client(client)?
1256 .diagnostics_snapshot()
1257 .map_err(client_err)?;
1258 Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
1259 }
1260 "progressSnapshot" => Ok(
1265 serde_json::to_value(need_client(client)?.progress().snapshot())
1266 .expect("progress JSON"),
1267 ),
1268 "drainChangeBatches" => Ok(json!({
1269 "batches": need_client(client)?.drain_change_batches()
1270 })),
1271 "drainSyncIntents" => Ok(json!({
1272 "intents": need_client(client)?.drain_sync_intents()
1273 })),
1274 #[cfg(feature = "crdt-yjs")]
1280 "crdtText" => {
1281 let (table, row_id, column, name) = crdt_target(params)?;
1282 let text = need_client(client)?
1283 .crdt_text(&table, &row_id, &column, &name)
1284 .map_err(client_err)?;
1285 Ok(json!({ "text": text }))
1286 }
1287 #[cfg(feature = "crdt-yjs")]
1288 "crdtInsertText" => {
1289 let (table, row_id, column, name) = crdt_target(params)?;
1290 let index = params
1291 .get("index")
1292 .and_then(Value::as_u64)
1293 .and_then(|n| u32::try_from(n).ok())
1294 .ok_or_else(|| {
1295 client_err(
1296 "sync.invalid_request: crdtInsertText index must be a u32".to_owned(),
1297 )
1298 })?;
1299 let value = params
1300 .get("value")
1301 .and_then(Value::as_str)
1302 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
1303 let id = need_client(client)?
1304 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
1305 .map_err(CommandError::from_client)?;
1306 Ok(json!({ "clientCommitId": id }))
1307 }
1308 #[cfg(feature = "crdt-yjs")]
1309 "crdtDeleteText" => {
1310 let (table, row_id, column, name) = crdt_target(params)?;
1311 let index = params
1312 .get("index")
1313 .and_then(Value::as_u64)
1314 .and_then(|n| u32::try_from(n).ok())
1315 .ok_or_else(|| {
1316 client_err(
1317 "sync.invalid_request: crdtDeleteText index must be a u32".to_owned(),
1318 )
1319 })?;
1320 let len = params
1321 .get("len")
1322 .and_then(Value::as_u64)
1323 .and_then(|n| u32::try_from(n).ok())
1324 .ok_or_else(|| {
1325 client_err("sync.invalid_request: crdtDeleteText len must be a u32".to_owned())
1326 })?;
1327 let id = need_client(client)?
1328 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
1329 .map_err(CommandError::from_client)?;
1330 Ok(json!({ "clientCommitId": id }))
1331 }
1332 #[cfg(feature = "crdt-yjs")]
1333 "crdtApplyUpdate" => {
1334 let (table, row_id, column, _name) = crdt_target(params)?;
1335 let update = value_bytes(params.get("update")).map_err(client_err)?;
1336 let id = need_client(client)?
1337 .crdt_apply_update(&table, &row_id, &column, &update)
1338 .map_err(CommandError::from_client)?;
1339 Ok(json!({ "clientCommitId": id }))
1340 }
1341 #[cfg(not(feature = "crdt-yjs"))]
1342 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => {
1343 Err(CommandError::new(
1344 "client.crdt_unavailable",
1345 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)",
1346 ))
1347 }
1348
1349 "uploadBlob" => {
1350 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1351 let media_type = optional(params.get("mediaType"), "mediaType", Value::as_str)
1352 .map_err(client_err)?
1353 .map(str::to_owned);
1354 let name = optional(params.get("name"), "blob name", Value::as_str)
1355 .map_err(client_err)?
1356 .map(str::to_owned);
1357 let reference = need_client(client)?
1358 .upload_blob(&bytes, media_type, name)
1359 .map_err(client_err)?;
1360 Ok(json!({ "ref": reference }))
1361 }
1362 "fetchBlob" => {
1363 let blob = params
1364 .get("blob")
1365 .and_then(Value::as_str)
1366 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
1367 .to_owned();
1368 let blob = need_client(client)?.fetch_blob_bytes(transport, &blob)?;
1372 let mut value = serde_json::Map::new();
1373 value.insert("blobId".to_owned(), Value::from(blob.blob_id));
1374 value.insert("byteLength".to_owned(), Value::from(blob.byte_length));
1375 value.insert("bytes".to_owned(), bytes_value(&blob.bytes));
1376 if let Some(media_type) = blob.media_type {
1377 value.insert("mediaType".to_owned(), Value::from(media_type));
1378 }
1379 Ok(Value::Object(serde_json::Map::from_iter([(
1380 "blob".to_owned(),
1381 Value::Object(value),
1382 )])))
1383 }
1384 "conflicts" => {
1385 let conflicts = need_client(client)?.conflicts().to_vec();
1386 Ok(json!({ "conflicts": conflicts }))
1387 }
1388 "rejections" => {
1389 let rejections = need_client(client)?.rejections().to_vec();
1390 Ok(json!({ "rejections": rejections }))
1391 }
1392 "commitOutcome" => {
1393 let client_commit_id = params
1394 .get("clientCommitId")
1395 .and_then(Value::as_str)
1396 .ok_or_else(|| {
1397 client_err(
1398 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
1399 )
1400 })?;
1401 let outcome = need_client(client)?
1402 .commit_outcome(client_commit_id)
1403 .map_err(client_err)?;
1404 Ok(json!({ "outcome": outcome }))
1405 }
1406 "commitOutcomes" => {
1407 let query = serde_json::from_value::<CommitOutcomeQuery>(
1408 params.get("query").cloned().unwrap_or_else(|| json!({})),
1409 )
1410 .map_err(|error| {
1411 client_err(format!(
1412 "sync.invalid_request: invalid commit outcome query: {error}"
1413 ))
1414 })?;
1415 let outcomes = need_client(client)?
1416 .commit_outcomes(query)
1417 .map_err(client_err)?;
1418 Ok(json!({ "outcomes": outcomes }))
1419 }
1420 "resolveCommitOutcome" => {
1421 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
1422 params.get("input").cloned().ok_or_else(|| {
1423 client_err(
1424 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
1425 )
1426 })?,
1427 )
1428 .map_err(|error| {
1429 client_err(format!(
1430 "sync.invalid_request: invalid outcome resolution: {error}"
1431 ))
1432 })?;
1433 let outcome = need_client(client)?
1434 .resolve_commit_outcome(input)
1435 .map_err(client_err)?;
1436 Ok(json!({ "outcome": outcome }))
1437 }
1438 "pendingCommitIds" => {
1439 let ids = need_client(client)?.pending_commit_ids();
1440 Ok(json!({ "ids": ids }))
1441 }
1442 "pendingPayloads" => {
1443 let payloads = need_client(client)?
1444 .pending_payloads()
1445 .map_err(client_err)?;
1446 Ok(json!({
1447 "payloads": payloads.iter().map(|p| bytes_value(p)).collect::<Vec<_>>()
1448 }))
1449 }
1450 "units" => Ok(json!({ "units": need_client(client)?.units().map_err(client_err)? })),
1451 "unit" => {
1452 let id = params
1453 .get("id")
1454 .and_then(Value::as_str)
1455 .ok_or_else(|| client_err("unit missing id".to_owned()))?;
1456 Ok(json!({ "unit": need_client(client)?.unit(id).map_err(client_err)? }))
1457 }
1458 "recreateWithSchema" => {
1459 let schema = params
1463 .get("schema")
1464 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
1465 need_client(client)?
1466 .recreate_with_schema(schema)
1467 .map_err(client_err)?;
1468 Ok(json!({}))
1469 }
1470 "localAuthorityState" => Ok(json!({ "state": transport.local_authority_state() })),
1471 "drainRealtime" => Ok(json!({})),
1475 "attachLocalAuthority" | "abandonLocalAuthorityAttach" => {
1476 let running = need_client(client)?;
1477 if transport.local_authority_state().is_none() {
1478 return Err(CommandError::new(
1479 LOCAL_AUTHORITY_UNCONFIGURED_CODE,
1480 "the host configured no localAuthorityPath",
1481 ));
1482 }
1483 let enabled = running.transport_enabled();
1486 running.set_transport_enabled(transport, false);
1487 let outcome = if method == "attachLocalAuthority" {
1488 transport.attach_local_authority()
1489 } else {
1490 transport.abandon_local_authority_attach()
1491 };
1492 running.set_transport_enabled(transport, enabled);
1493 outcome.map_err(transport_command_error)?;
1494 Ok(json!({ "effects": CommandEffects::interactive() }))
1495 }
1496 "connectRealtime" => {
1497 need_client(client)?
1498 .connect_realtime(transport)
1499 .map_err(client_err)?;
1500 Ok(json!({}))
1501 }
1502 "disconnectRealtime" => {
1503 need_client(client)?.disconnect_realtime(transport);
1504 Ok(json!({}))
1505 }
1506 "setPresence" => {
1507 let scope_key = params
1508 .get("scopeKey")
1509 .and_then(Value::as_str)
1510 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
1511 .to_owned();
1512 let doc = params.get("doc");
1514 let doc_ref = match doc {
1515 None | Some(Value::Null) => None,
1516 Some(v) => Some(v),
1517 };
1518 need_client(client)?
1519 .set_presence(transport, &scope_key, doc_ref)
1520 .map_err(client_err)?;
1521 Ok(json!({}))
1522 }
1523 "presence" => {
1524 let scope_key = params
1525 .get("scopeKey")
1526 .and_then(Value::as_str)
1527 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
1528 let peers = need_client(client)?.presence(scope_key);
1529 Ok(json!({ "peers": peers }))
1530 }
1531
1532 "messageRoundtrip" => {
1534 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1535 match decode_message(&bytes) {
1536 Ok(message) => Ok(json!({
1537 "ok": true,
1538 "bytes": bytes_value(&encode_message(&message)),
1539 "renderedJson": render_message(&message).to_string(),
1540 })),
1541 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1542 }
1543 }
1544 "segmentRoundtrip" => {
1545 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1546 match decode_rows_segment(&bytes) {
1547 Ok(segment) => Ok(json!({
1548 "ok": true,
1549 "bytes": bytes_value(&encode_rows_segment(&segment)),
1550 "renderedJson": render_rows_segment(&segment).to_string(),
1551 })),
1552 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1553 }
1554 }
1555 "realtimeKnown" => {
1556 let text = params
1557 .get("text")
1558 .and_then(Value::as_str)
1559 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
1560 let known = matches!(
1561 parse_control(text),
1562 Ok(ControlMessage::Hello { .. })
1563 | Ok(ControlMessage::Wake { .. })
1564 | Ok(ControlMessage::Heartbeat { .. })
1565 | Ok(ControlMessage::Presence { .. })
1566 );
1567 Ok(json!({ "value": known }))
1568 }
1569
1570 other => Err(client_err(format!("unknown method {other:?}"))),
1571 };
1572 if client
1573 .as_ref()
1574 .is_some_and(|running| !running.transport_enabled())
1575 {
1576 if let Ok(value) = &mut result {
1577 if let Some(effects) = value.get_mut("effects").and_then(Value::as_object_mut) {
1578 effects.remove("sync");
1579 }
1580 }
1581 }
1582 result
1583}
1584
1585#[cfg(test)]
1586mod tests {
1587 use serde_json::{json, Value};
1588 use syncular_client::{
1589 Mutation, SegmentRequest, SyncClient, Transport, TransportError,
1590 SECURITY_PREFLIGHT_REQUIRED_CODE,
1591 };
1592
1593 use super::{
1594 client_err, dispatch, parse_encryption, parse_headers, parse_previous_version_context,
1595 parse_window_coverage, CreateEffects, UnitScopes,
1596 };
1597
1598 #[derive(Default)]
1599 struct NoNetwork {
1600 realtime_connects: usize,
1601 realtime_closes: usize,
1602 }
1603
1604 impl Transport for NoNetwork {
1605 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1606 Err(TransportError::new("sync.transport_failed", "offline"))
1607 }
1608
1609 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1610 Err(TransportError::new("sync.transport_failed", "offline"))
1611 }
1612
1613 fn download_segment(
1614 &mut self,
1615 _request: &SegmentRequest,
1616 _on_progress: &mut dyn FnMut(u64),
1617 ) -> Result<Vec<u8>, TransportError> {
1618 Err(TransportError::new("sync.transport_failed", "offline"))
1619 }
1620
1621 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1622 self.realtime_connects += 1;
1623 Ok(())
1624 }
1625
1626 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1627 Ok(())
1628 }
1629
1630 fn realtime_close(&mut self) -> Result<(), TransportError> {
1631 self.realtime_closes += 1;
1632 Ok(())
1633 }
1634 }
1635
1636 fn schema() -> Value {
1637 json!({
1638 "version": 1,
1639 "tables": [{
1640 "name": "todos",
1641 "columns": [
1642 { "name": "id", "type": "string", "nullable": false },
1643 { "name": "list_id", "type": "string", "nullable": false }
1644 ],
1645 "primaryKey": "id",
1646 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1647 }]
1648 })
1649 }
1650
1651 #[test]
1652 fn native_command_realtime_connection_is_idempotent() {
1653 let mut transport = NoNetwork::default();
1654 let mut client: Option<SyncClient> = None;
1655 let mut effects = CreateEffects::default();
1656 dispatch(
1657 &mut transport,
1658 &mut client,
1659 &mut effects,
1660 "create",
1661 &json!({ "schema": schema() }),
1662 )
1663 .expect("create client");
1664
1665 for _ in 0..2 {
1666 dispatch(
1667 &mut transport,
1668 &mut client,
1669 &mut effects,
1670 "connectRealtime",
1671 &json!({}),
1672 )
1673 .expect("idempotent connect command");
1674 }
1675 assert_eq!(transport.realtime_connects, 1);
1676
1677 dispatch(
1678 &mut transport,
1679 &mut client,
1680 &mut effects,
1681 "disconnectRealtime",
1682 &json!({}),
1683 )
1684 .expect("disconnect command");
1685 dispatch(
1686 &mut transport,
1687 &mut client,
1688 &mut effects,
1689 "disconnectRealtime",
1690 &json!({}),
1691 )
1692 .expect("idempotent disconnect command");
1693 assert_eq!(transport.realtime_closes, 1);
1694 dispatch(
1695 &mut transport,
1696 &mut client,
1697 &mut effects,
1698 "connectRealtime",
1699 &json!({}),
1700 )
1701 .expect("deliberate reconnect command");
1702 assert_eq!(transport.realtime_connects, 2);
1703 }
1704
1705 #[test]
1706 fn local_authority_commands_without_one_report_the_unconfigured_code() {
1707 let mut transport = NoNetwork::default();
1708 let mut client: Option<SyncClient> = None;
1709 let mut effects = CreateEffects::default();
1710 let state = dispatch(
1711 &mut transport,
1712 &mut client,
1713 &mut effects,
1714 "localAuthorityState",
1715 &json!({}),
1716 )
1717 .expect("state without a client");
1718 assert_eq!(state, json!({ "state": null }));
1719 dispatch(
1720 &mut transport,
1721 &mut client,
1722 &mut effects,
1723 "create",
1724 &json!({ "schema": schema() }),
1725 )
1726 .expect("create client");
1727 for method in ["attachLocalAuthority", "abandonLocalAuthorityAttach"] {
1728 let error = dispatch(
1729 &mut transport,
1730 &mut client,
1731 &mut effects,
1732 method,
1733 &json!({}),
1734 )
1735 .expect_err("no local authority");
1736 assert_eq!(error.code, "client.local_authority_unconfigured");
1737 }
1738 let running = client.as_mut().expect("client");
1739 assert!(running.transport_enabled());
1740 assert!(
1741 running.drain_sync_intents().is_empty(),
1742 "the gate never toggled"
1743 );
1744 }
1745
1746 struct ScriptedAuthority {
1748 opened: Vec<(Value, Option<String>)>,
1749 open_error: Option<TransportError>,
1750 attach_error: Option<TransportError>,
1751 abandons: usize,
1752 }
1753
1754 impl Transport for ScriptedAuthority {
1755 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1756 Err(TransportError::new("sync.transport_failed", "offline"))
1757 }
1758 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1759 Err(TransportError::new("sync.transport_failed", "offline"))
1760 }
1761 fn download_segment(
1762 &mut self,
1763 _request: &SegmentRequest,
1764 _on_progress: &mut dyn FnMut(u64),
1765 ) -> Result<Vec<u8>, TransportError> {
1766 Err(TransportError::new("sync.transport_failed", "offline"))
1767 }
1768 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1769 Ok(())
1770 }
1771 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1772 Ok(())
1773 }
1774 fn realtime_close(&mut self) -> Result<(), TransportError> {
1775 Ok(())
1776 }
1777 fn open_local_authority(
1778 &mut self,
1779 schema: &Value,
1780 replica_path: Option<&str>,
1781 ) -> Result<(), TransportError> {
1782 self.opened
1783 .push((schema.clone(), replica_path.map(str::to_owned)));
1784 self.open_error.take().map_or(Ok(()), Err)
1785 }
1786 fn local_authority_state(&self) -> Option<&'static str> {
1787 (!self.opened.is_empty()).then_some("local")
1788 }
1789 fn attach_local_authority(&mut self) -> Result<(), TransportError> {
1790 self.attach_error.take().map_or(Ok(()), Err)
1791 }
1792 fn abandon_local_authority_attach(&mut self) -> Result<(), TransportError> {
1793 self.abandons += 1;
1794 Ok(())
1795 }
1796 }
1797
1798 #[test]
1799 fn local_authority_opens_at_create_and_attach_errors_keep_code_and_details() {
1800 let mut transport = ScriptedAuthority {
1801 opened: Vec::new(),
1802 open_error: Some(TransportError::new("sync.internal_error", "open failed")),
1803 attach_error: Some(TransportError {
1804 code: "sync.attach_rejected".to_owned(),
1805 message: "the remote server refused an attach commit".to_owned(),
1806 details: Some(json!({ "reason": "commit_rejected", "opIndex": 0 })),
1807 }),
1808 abandons: 0,
1809 };
1810 let mut client: Option<SyncClient> = None;
1811 let mut effects = CreateEffects::default();
1812 let error = dispatch(
1813 &mut transport,
1814 &mut client,
1815 &mut effects,
1816 "create",
1817 &json!({ "schema": schema() }),
1818 )
1819 .expect_err("an authority that fails to open fails create");
1820 assert_eq!(error.code, "sync.internal_error");
1821 assert!(client.is_none());
1822
1823 dispatch(
1824 &mut transport,
1825 &mut client,
1826 &mut effects,
1827 "create",
1828 &json!({ "schema": schema() }),
1829 )
1830 .expect("create client");
1831 assert_eq!(transport.opened.len(), 2);
1832 assert_eq!(transport.opened[1], (schema(), None));
1833 let state = dispatch(
1834 &mut transport,
1835 &mut client,
1836 &mut effects,
1837 "localAuthorityState",
1838 &json!({}),
1839 )
1840 .expect("state");
1841 assert_eq!(state, json!({ "state": "local" }));
1842
1843 let error = dispatch(
1844 &mut transport,
1845 &mut client,
1846 &mut effects,
1847 "attachLocalAuthority",
1848 &json!({}),
1849 )
1850 .expect_err("scripted rejection");
1851 assert_eq!(error.code, "sync.attach_rejected");
1852 assert_eq!(
1853 error.details,
1854 Some(json!({ "reason": "commit_rejected", "opIndex": 0 }))
1855 );
1856 assert!(client.as_ref().expect("client").transport_enabled());
1857
1858 let abandoned = dispatch(
1859 &mut transport,
1860 &mut client,
1861 &mut effects,
1862 "abandonLocalAuthorityAttach",
1863 &json!({}),
1864 )
1865 .expect("abandon");
1866 assert_eq!(abandoned["effects"]["sync"]["kind"], "interactive");
1867 assert_eq!(transport.abandons, 1);
1868
1869 dispatch(
1871 &mut transport,
1872 &mut client,
1873 &mut effects,
1874 "setTransportEnabled",
1875 &json!({ "enabled": false }),
1876 )
1877 .expect("go offline");
1878 let attached = dispatch(
1879 &mut transport,
1880 &mut client,
1881 &mut effects,
1882 "attachLocalAuthority",
1883 &json!({}),
1884 )
1885 .expect("attach");
1886 assert!(attached["effects"].get("sync").is_none());
1887 assert!(!client.as_ref().expect("client").transport_enabled());
1888 }
1889
1890 #[test]
1891 fn schema_downgrade_has_structured_code_at_create_and_recreate_boundaries() {
1892 let path = temp_db_path("schema-downgrade");
1893 let mut newer = schema();
1894 newer["version"] = json!(3);
1895 let mut older = schema();
1896 older["version"] = json!(2);
1897 let mut transport = NoNetwork::default();
1898 let mut client = None;
1899 let mut effects = CreateEffects::default();
1900 dispatch(
1901 &mut transport,
1902 &mut client,
1903 &mut effects,
1904 "create",
1905 &json!({ "schema": newer, "dbPath": path }),
1906 )
1907 .expect("v3 create");
1908 let error = dispatch(
1909 &mut transport,
1910 &mut client,
1911 &mut effects,
1912 "recreateWithSchema",
1913 &json!({ "schema": older }),
1914 )
1915 .expect_err("refuse recreation");
1916 assert_eq!(error.code, "client.schema_downgrade");
1917 assert_eq!(
1918 error.message,
1919 "client.schema_downgrade: persisted local schema is newer than the requested schema"
1920 );
1921 dispatch(
1922 &mut transport,
1923 &mut client,
1924 &mut effects,
1925 "shutdown",
1926 &json!({}),
1927 )
1928 .expect("shutdown");
1929 let error = dispatch(
1930 &mut transport,
1931 &mut client,
1932 &mut effects,
1933 "create",
1934 &json!({ "schema": older, "dbPath": path }),
1935 )
1936 .expect_err("refuse open");
1937 assert_eq!(error.code, "client.schema_downgrade");
1938 assert!(client.is_none());
1939 dispatch(
1940 &mut transport,
1941 &mut client,
1942 &mut effects,
1943 "create",
1944 &json!({ "schema": newer, "dbPath": path }),
1945 )
1946 .expect("compatible reopen");
1947 dispatch(
1948 &mut transport,
1949 &mut client,
1950 &mut effects,
1951 "shutdown",
1952 &json!({}),
1953 )
1954 .expect("shutdown");
1955 let _ = std::fs::remove_file(path);
1956 }
1957
1958 #[test]
1959 fn native_command_realtime_policy_rides_create_and_refuses_http() {
1960 let mut transport = NoNetwork::default();
1961 let mut client: Option<SyncClient> = None;
1962 let mut effects = CreateEffects::default();
1963 dispatch(
1964 &mut transport,
1965 &mut client,
1966 &mut effects,
1967 "create",
1968 &json!({ "schema": schema(), "realtimePolicy": "required" }),
1969 )
1970 .expect("create client with a required realtime policy");
1971 let result = dispatch(
1972 &mut transport,
1973 &mut client,
1974 &mut effects,
1975 "sync",
1976 &json!({}),
1977 )
1978 .expect("sync command");
1979 assert_eq!(result["ok"], json!(false));
1980 assert_eq!(result["errorCode"], json!("sync.realtime_unavailable"));
1981 assert_eq!(result["realtimeState"], json!("disconnected"));
1982 assert_eq!(result["retryDelayMs"], json!(250));
1983 let snapshot = dispatch(
1984 &mut transport,
1985 &mut client,
1986 &mut effects,
1987 "diagnosticsSnapshot",
1988 &json!({}),
1989 )
1990 .expect("diagnostics command");
1991 assert_eq!(snapshot["host"]["realtime"], json!("disconnected"));
1992 assert_eq!(snapshot["host"]["realtimePolicy"], json!("required"));
1993 assert_eq!(snapshot["host"]["realtimeRetryDelayMs"], json!(250));
1994
1995 let mut second: Option<SyncClient> = None;
1996 let error = dispatch(
1997 &mut transport,
1998 &mut second,
1999 &mut effects,
2000 "create",
2001 &json!({ "schema": schema(), "realtimePolicy": "always" }),
2002 )
2003 .expect_err("an unknown policy is refused");
2004 assert_eq!(error.code, "sync.invalid_request");
2005 }
2006
2007 #[test]
2008 fn parses_portable_encryption_keyring_and_key_id_columns() {
2009 let key_hex = "2a".repeat(32);
2010 let config = parse_encryption(&json!({
2011 "keys": { "practice-key-v1": { "$bytes": key_hex } },
2012 "keyIdColumns": { "patients": "encryption_key_id" }
2013 }))
2014 .expect("portable keyring parses");
2015 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
2016 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
2017 }
2018
2019 #[test]
2020 fn rejects_non_string_key_id_columns() {
2021 let error = parse_encryption(&json!({
2022 "keys": {},
2023 "keyIdColumns": { "patients": 7 }
2024 }))
2025 .expect_err("invalid selector must fail");
2026 assert!(error.contains("must be a string"), "{error}");
2027 }
2028
2029 #[test]
2030 fn subscribe_many_registers_every_unit() {
2031 let mut transport = NoNetwork::default();
2032 let mut client: Option<SyncClient> = None;
2033 let mut effects = CreateEffects::default();
2034 let mut run = |command: &str, params: Value| {
2035 dispatch(&mut transport, &mut client, &mut effects, command, ¶ms)
2036 };
2037 run("create", json!({ "schema": schema() })).expect("create client");
2038 let ids = run(
2039 "subscribeMany",
2040 json!({ "units": [{ "list_id": "list-1" }, { "list_id": "list-2" }] }),
2041 )
2042 .expect("register units")["ids"]
2043 .clone();
2044 let single = run("subscribe", json!({ "scopes": { "list_id": "list-1" } }))
2045 .expect("re-register a unit");
2046 assert_eq!(single["id"], ids[0], "a unit id derives from its scope map");
2047 let units = run("units", json!({})).expect("units");
2048 assert_eq!(units["units"].as_array().map(Vec::len), Some(2));
2049 for (index, list) in ["list-1", "list-2"].into_iter().enumerate() {
2050 let unit = run("unit", json!({ "id": ids[index] })).expect("unit");
2051 assert_eq!(unit["unit"]["scopes"], json!({ "list_id": list }));
2052 assert_eq!(unit["unit"]["cursor"], -1);
2053 }
2054 assert_eq!(
2055 run("unit", json!({ "id": "absent" })).expect("unit")["unit"],
2056 Value::Null
2057 );
2058 for scopes in [json!({ "list_id": "*" }), json!({ "other": "x" })] {
2059 let error = run("subscribe", json!({ "scopes": scopes }))
2060 .expect_err("a unit names a non-wildcard value per covered variable");
2061 assert_eq!(error.code, "sync.invalid_subscription", "{scopes}");
2062 }
2063 }
2064
2065 #[test]
2066 fn window_coverage_parser_reads_the_generated_descriptor_and_rejects_malformed_units() {
2067 let parsed = parse_window_coverage(Some(&json!([{
2068 "base": { "variable": "project_id", "fixedScopes": { "tenant_id": "one" } },
2069 "units": ["a", "b"]
2070 }])))
2071 .expect("parse coverage");
2072 assert_eq!(parsed[0].base.variable, "project_id");
2073 assert_eq!(
2074 parsed[0].base.fixed_scopes,
2075 UnitScopes::from([("tenant_id".to_owned(), "one".to_owned())])
2076 );
2077 assert_eq!(parsed[0].units, ["a", "b"]);
2078 for coverage in [
2079 json!({}),
2080 json!([{ "base": { "variable": "project_id" }, "units": ["a", 1] }]),
2081 json!([{ "base": { "fixedScopes": {} }, "units": [] }]),
2082 ] {
2083 let error = parse_window_coverage(Some(&coverage)).expect_err("malformed coverage");
2084 assert!(error.starts_with("sync.invalid_request: "), "{error}");
2085 }
2086 }
2087
2088 #[test]
2089 fn subscribe_and_unsubscribe_queue_an_interactive_sync_intent() {
2090 let mut transport = NoNetwork::default();
2091 let mut client: Option<SyncClient> = None;
2092 let mut effects = CreateEffects::default();
2093 let mut run = |command: &str, params: Value| {
2094 dispatch(&mut transport, &mut client, &mut effects, command, ¶ms)
2095 .expect("command succeeds")
2096 };
2097 run("create", json!({ "schema": schema() }));
2098 run("drainSyncIntents", json!({}));
2099 let id = run("subscribe", json!({ "scopes": { "list_id": "list-1" } }))["id"].clone();
2100 let intents = run("drainSyncIntents", json!({}));
2101 assert_eq!(intents["intents"], json!([{ "kind": "interactive" }]));
2102 run("unsubscribe", json!({ "id": id }));
2103 let intents = run("drainSyncIntents", json!({}));
2104 assert_eq!(intents["intents"], json!([{ "kind": "interactive" }]));
2105 }
2106
2107 #[test]
2108 fn security_preflight_is_fail_closed_until_exact_activation() {
2109 let mut transport = NoNetwork::default();
2110 let mut client: Option<SyncClient> = None;
2111 let mut effects = CreateEffects::default();
2112 dispatch(
2113 &mut transport,
2114 &mut client,
2115 &mut effects,
2116 "create",
2117 &json!({ "schema": schema(), "securityPreflight": true }),
2118 )
2119 .expect("preflight create");
2120
2121 let lifecycle = dispatch(
2122 &mut transport,
2123 &mut client,
2124 &mut effects,
2125 "securityLifecycle",
2126 &json!({}),
2127 )
2128 .expect("lifecycle");
2129 assert_eq!(lifecycle, json!({ "state": "preflight" }));
2130
2131 let query_error = dispatch(
2132 &mut transport,
2133 &mut client,
2134 &mut effects,
2135 "query",
2136 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2137 )
2138 .expect_err("protected query must fail");
2139 assert_eq!(query_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2140 let diagnostics_error = dispatch(
2141 &mut transport,
2142 &mut client,
2143 &mut effects,
2144 "diagnosticsSnapshot",
2145 &json!({}),
2146 )
2147 .expect_err("diagnostics unit evidence remains protected");
2148 assert_eq!(diagnostics_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2149
2150 dispatch(
2151 &mut transport,
2152 &mut client,
2153 &mut effects,
2154 "purgeLocalData",
2155 &json!({
2156 "input": {
2157 "purgeId": "directive-1",
2158 "targets": [{
2159 "table": "todos",
2160 "selectors": { "list_id": ["list-1"] }
2161 }]
2162 }
2163 }),
2164 )
2165 .expect("authorized local purge remains available");
2166
2167 let repair_error = dispatch(
2168 &mut transport,
2169 &mut client,
2170 &mut effects,
2171 "rebootstrapLocalData",
2172 &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
2173 )
2174 .expect_err("projection repair must remain protected during preflight");
2175 assert_eq!(repair_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2176
2177 dispatch(
2178 &mut transport,
2179 &mut client,
2180 &mut effects,
2181 "activateSecurity",
2182 &json!({}),
2183 )
2184 .expect("activation");
2185 let rows = dispatch(
2186 &mut transport,
2187 &mut client,
2188 &mut effects,
2189 "query",
2190 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2191 )
2192 .expect("active query");
2193 assert_eq!(rows, json!({ "rows": [] }));
2194
2195 dispatch(
2196 &mut transport,
2197 &mut client,
2198 &mut effects,
2199 "beginSecurityPreflight",
2200 &json!({}),
2201 )
2202 .expect("re-enter preflight");
2203 let blocked = dispatch(
2204 &mut transport,
2205 &mut client,
2206 &mut effects,
2207 "mutate",
2208 &json!({ "mutations": [] }),
2209 )
2210 .expect_err("mutation must be gated");
2211 assert_eq!(blocked.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2212 }
2213
2214 #[test]
2215 fn mutate_and_patch_forward_typed_storage_failures() {
2216 let path = temp_db_path("busy-authoring");
2217 let schema = json!({
2218 "version": 1,
2219 "tables": [{
2220 "name": "todos",
2221 "columns": [
2222 { "name": "id", "type": "string", "nullable": false },
2223 { "name": "list_id", "type": "string", "nullable": false },
2224 { "name": "title", "type": "string", "nullable": false }
2225 ],
2226 "primaryKey": "id",
2227 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2228 }]
2229 });
2230 let mut transport = NoNetwork::default();
2231 let mut client: Option<SyncClient> = None;
2232 let mut effects = CreateEffects::default();
2233 dispatch(
2234 &mut transport,
2235 &mut client,
2236 &mut effects,
2237 "create",
2238 &json!({ "schema": schema, "dbPath": path }),
2239 )
2240 .expect("create file-backed client");
2241 let upsert = |id: &str| {
2242 json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2243 "id": id, "list_id": "l1", "title": "first" } }] })
2244 };
2245 dispatch(
2246 &mut transport,
2247 &mut client,
2248 &mut effects,
2249 "mutate",
2250 &upsert("t1"),
2251 )
2252 .expect("seed row");
2253
2254 let locker = rusqlite::Connection::open(&path).expect("second connection");
2255 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
2256 let busy = dispatch(
2257 &mut transport,
2258 &mut client,
2259 &mut effects,
2260 "mutate",
2261 &upsert("t2"),
2262 )
2263 .expect_err("busy mutate");
2264 assert_eq!(busy.code, "client.storage_busy");
2265 assert!(busy.retryable);
2266 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
2267
2268 let busy_patch = dispatch(
2269 &mut transport,
2270 &mut client,
2271 &mut effects,
2272 "patch",
2273 &json!({ "table": "todos", "rowId": "t1", "partial": { "title": "second" } }),
2274 )
2275 .expect_err("busy patch");
2276 assert_eq!(busy_patch.code, "client.storage_busy");
2277 assert!(busy_patch.retryable);
2278 assert_eq!(busy_patch.details.as_ref().unwrap()["sqliteCode"], 5);
2279
2280 locker.execute_batch("ROLLBACK").unwrap();
2281 let retried = dispatch(
2282 &mut transport,
2283 &mut client,
2284 &mut effects,
2285 "mutate",
2286 &upsert("t2"),
2287 )
2288 .expect("retry after busy");
2289 assert!(retried.get("clientCommitId").is_some());
2290 let _ = std::fs::remove_file(&path);
2291 }
2292
2293 #[test]
2294 fn read_command_error_preserves_code_details_and_retryability() {
2295 let corrupt = super::read_command_error(syncular_client::QueryReadFailure {
2296 code: Some("sync.local_corrupt"),
2297 sqlite_code: None,
2298 sqlite_message: None,
2299 rollback_failure: None,
2300 message: "persisted local revision is invalid".to_owned(),
2301 });
2302 assert_eq!(corrupt.code, "sync.local_corrupt");
2303 assert!(!corrupt.retryable);
2304 assert!(corrupt.details.is_none());
2305
2306 let busy = super::read_command_error(syncular_client::QueryReadFailure {
2307 code: Some("client.storage_busy"),
2308 sqlite_code: Some(5),
2309 sqlite_message: Some("database is locked".to_owned()),
2310 rollback_failure: None,
2311 message: "local SQLite storage is busy".to_owned(),
2312 });
2313 assert_eq!(busy.code, "client.storage_busy");
2314 assert!(busy.retryable);
2315 assert_eq!(
2316 busy.details.expect("busy details")["sqliteCode"],
2317 serde_json::json!(5)
2318 );
2319 }
2320
2321 #[test]
2322 fn mutate_and_patch_report_caller_value_failures_as_invalid_request() {
2323 let path = temp_db_path("caller-values");
2324 let schema = json!({
2325 "version": 1,
2326 "tables": [{
2327 "name": "todos",
2328 "columns": [
2329 { "name": "id", "type": "string", "nullable": false },
2330 { "name": "list_id", "type": "string", "nullable": false },
2331 { "name": "title", "type": "string", "nullable": false },
2332 { "name": "priority", "type": "integer", "nullable": false }
2333 ],
2334 "primaryKey": "id",
2335 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2336 }]
2337 });
2338 let mut transport = NoNetwork::default();
2339 let mut client: Option<SyncClient> = None;
2340 let mut effects = CreateEffects::default();
2341 dispatch(
2342 &mut transport,
2343 &mut client,
2344 &mut effects,
2345 "create",
2346 &json!({ "schema": schema, "dbPath": path }),
2347 )
2348 .expect("create file-backed client");
2349 let seed = dispatch(
2350 &mut transport,
2351 &mut client,
2352 &mut effects,
2353 "mutate",
2354 &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2355 "id": "t1", "list_id": "l1", "title": "first", "priority": 1 } }] }),
2356 )
2357 .expect("seed row");
2358 let seed_id = seed["clientCommitId"].clone();
2359
2360 let unknown = dispatch(
2363 &mut transport,
2364 &mut client,
2365 &mut effects,
2366 "mutate",
2367 &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2368 "id": "t2", "list_id": "l1", "title": "second", "priority": 1,
2369 "missing_column": 1 } }] }),
2370 )
2371 .expect_err("unknown column");
2372 assert_eq!(unknown.code, "sync.invalid_request");
2373 assert_eq!(unknown.message, "the authoring request is invalid");
2374 assert!(!unknown.retryable);
2375 assert!(unknown.details.as_ref().unwrap()["legacyCause"]
2376 .as_str()
2377 .unwrap()
2378 .contains("unknown column"));
2379
2380 let bad_patch = dispatch(
2381 &mut transport,
2382 &mut client,
2383 &mut effects,
2384 "patch",
2385 &json!({ "table": "todos", "rowId": "t1", "partial": { "priority": "high" } }),
2386 )
2387 .expect_err("bad patch value");
2388 assert_eq!(bad_patch.code, "sync.invalid_request");
2389 assert_eq!(bad_patch.message, "the authoring request is invalid");
2390 assert!(bad_patch.details.as_ref().unwrap()["legacyCause"]
2391 .as_str()
2392 .unwrap()
2393 .contains("priority"));
2394
2395 let pending = dispatch(
2397 &mut transport,
2398 &mut client,
2399 &mut effects,
2400 "pendingCommitIds",
2401 &json!({}),
2402 )
2403 .expect("pending commit ids");
2404 assert_eq!(pending["ids"], json!([seed_id]));
2405 let rows = dispatch(
2406 &mut transport,
2407 &mut client,
2408 &mut effects,
2409 "query",
2410 &json!({ "sql": "SELECT id, priority FROM todos ORDER BY id" }),
2411 )
2412 .expect("visible rows");
2413 assert_eq!(rows["rows"], json!([{ "id": "t1", "priority": 1 }]));
2414
2415 let accepted = dispatch(
2417 &mut transport,
2418 &mut client,
2419 &mut effects,
2420 "mutate",
2421 &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2422 "id": "t2", "list_id": "l1", "title": "second", "priority": 2 } }] }),
2423 )
2424 .expect("retry after rejection");
2425 assert!(accepted.get("clientCommitId").is_some());
2426 let _ = std::fs::remove_file(&path);
2427 }
2428
2429 #[test]
2430 fn client_err_preserves_retryable_for_storage_busy() {
2431 let busy = client_err("client.storage_busy: local SQLite storage is busy".to_owned());
2432 assert_eq!(busy.code, "client.storage_busy");
2433 assert!(busy.retryable);
2434 assert_eq!(
2436 busy.message,
2437 "client.storage_busy: local SQLite storage is busy"
2438 );
2439 let full = client_err("client.storage_full: local SQLite storage is full".to_owned());
2440 assert_eq!(full.code, "client.storage_full");
2441 assert!(!full.retryable);
2442 }
2443
2444 #[test]
2445 fn client_err_keeps_a_colon_less_code_identity() {
2446 let error = client_err("sync.unknown_table".to_owned());
2447 assert_eq!(error.code, "sync.unknown_table");
2448 let error = client_err("no client instance created".to_owned());
2449 assert_eq!(error.code, "client.failed");
2450 }
2451
2452 #[cfg(feature = "crdt-yjs")]
2453 #[test]
2454 fn crdt_offsets_outside_u32_are_invalid_requests() {
2455 let mut transport = NoNetwork::default();
2456 let mut client: Option<SyncClient> = None;
2457 let mut effects = CreateEffects::default();
2458 dispatch(
2459 &mut transport,
2460 &mut client,
2461 &mut effects,
2462 "create",
2463 &json!({ "schema": schema() }),
2464 )
2465 .expect("create");
2466 let target = json!({ "table": "todos", "rowId": "t1", "column": "body" });
2467 for (method, extra) in [
2468 (
2469 "crdtInsertText",
2470 json!({ "index": 4_294_967_296u64, "value": "x" }),
2471 ),
2472 ("crdtInsertText", json!({ "index": "0", "value": "x" })),
2473 (
2474 "crdtDeleteText",
2475 json!({ "index": 0, "len": 4_294_967_296u64 }),
2476 ),
2477 ] {
2478 let mut params = target.clone();
2479 params
2480 .as_object_mut()
2481 .unwrap()
2482 .extend(extra.as_object().unwrap().clone());
2483 let error = dispatch(&mut transport, &mut client, &mut effects, method, ¶ms)
2484 .expect_err("out-of-range offset");
2485 assert_eq!(error.code, "sync.invalid_request", "{method} {params}");
2486 }
2487 }
2488
2489 #[test]
2490 fn wrong_typed_optional_fields_are_invalid_requests() {
2491 let created = || {
2492 let mut transport = NoNetwork::default();
2493 let mut client: Option<SyncClient> = None;
2494 let mut effects = CreateEffects::default();
2495 dispatch(
2496 &mut transport,
2497 &mut client,
2498 &mut effects,
2499 "create",
2500 &json!({ "schema": schema() }),
2501 )
2502 .expect("create");
2503 (transport, client, effects)
2504 };
2505 for params in [
2506 json!({ "schema": schema(), "limits": "x" }),
2507 json!({ "schema": schema(), "limits": { "accept": 256 } }),
2508 json!({ "schema": schema(), "limits": { "accept": "15" } }),
2509 json!({ "schema": schema(), "limits": { "limitCommits": 4_294_967_296i64 } }),
2510 json!({ "schema": schema(), "limits": { "outcomeRetentionMaxEntries": -1 } }),
2511 json!({ "schema": schema(), "clientId": 7 }),
2512 json!({ "schema": schema(), "nowMs": "1" }),
2513 json!({ "schema": schema(), "signedUrls": "true" }),
2514 json!({ "schema": schema(), "securityPreflight": 1 }),
2515 json!({ "schema": schema(), "encryption": { "keys": "k" } }),
2516 ] {
2517 let mut transport = NoNetwork::default();
2518 let mut client: Option<SyncClient> = None;
2519 let mut effects = CreateEffects::default();
2520 let error = dispatch(&mut transport, &mut client, &mut effects, "create", ¶ms)
2521 .expect_err("wrong-typed create field");
2522 assert_eq!(error.code, "sync.invalid_request", "{params}");
2523 }
2524 let base = json!({ "variable": "list_id" });
2525 for (method, params) in [
2526 ("setWindow", json!({ "base": base, "units": [1] })),
2527 ("setWindow", json!({ "base": base, "units": "l1" })),
2528 (
2529 "setWindow",
2530 json!({ "base": { "variable": "list_id", "fixedScopes": ["x"] }, "units": [] }),
2531 ),
2532 ("windowState", json!({ "base": {} })),
2533 (
2534 "mutate",
2535 json!({ "mutations": [{ "op": "delete", "table": "todos", "rowId": "t1", "baseVersion": "7" }] }),
2536 ),
2537 (
2538 "patch",
2539 json!({ "table": "todos", "rowId": "t1", "partial": { "title": "x" }, "baseVersion": "7" }),
2540 ),
2541 ("subscribe", json!({ "scopes": { "list_id": ["l1"] } })),
2542 (
2543 "subscribeMany",
2544 json!({ "units": [{ "list_id": "l1" }, 7] }),
2545 ),
2546 (
2547 "uploadBlob",
2548 json!({ "bytes": { "$bytes": "00" }, "mediaType": 1 }),
2549 ),
2550 ] {
2551 let (mut transport, mut client, mut effects) = created();
2552 let error = dispatch(&mut transport, &mut client, &mut effects, method, ¶ms)
2553 .expect_err("wrong-typed field");
2554 assert_eq!(error.code, "sync.invalid_request", "{method} {params}");
2555 }
2556 let (mut transport, mut client, mut effects) = created();
2558 dispatch(
2559 &mut transport,
2560 &mut client,
2561 &mut effects,
2562 "mutate",
2563 &json!({ "mutations": [{ "op": "delete", "table": "todos", "rowId": "t1", "baseVersion": null }] }),
2564 )
2565 .expect("null baseVersion is absent");
2566 }
2567
2568 #[test]
2569 fn sync_until_idle_rejects_an_invalid_max_rounds_budget() {
2570 for value in [json!(0), json!(-1), json!(1.5), json!(4_294_967_296u64)] {
2571 let mut transport = NoNetwork::default();
2572 let mut client: Option<SyncClient> = None;
2573 let mut effects = CreateEffects::default();
2574 dispatch(
2575 &mut transport,
2576 &mut client,
2577 &mut effects,
2578 "create",
2579 &json!({ "schema": schema() }),
2580 )
2581 .expect("create");
2582 let error = dispatch(
2583 &mut transport,
2584 &mut client,
2585 &mut effects,
2586 "syncUntilIdle",
2587 &json!({ "maxRounds": value }),
2588 )
2589 .expect_err("invalid budget");
2590 assert_eq!(error.code, "sync.invalid_request", "{value}");
2591 }
2592 }
2593
2594 #[test]
2595 fn sync_until_idle_rejects_an_invalid_timeout() {
2596 for value in [json!(0), json!(-1), json!(1.5)] {
2597 let mut transport = NoNetwork::default();
2598 let mut client: Option<SyncClient> = None;
2599 let mut effects = CreateEffects::default();
2600 dispatch(
2601 &mut transport,
2602 &mut client,
2603 &mut effects,
2604 "create",
2605 &json!({ "schema": schema() }),
2606 )
2607 .expect("create");
2608 let error = dispatch(
2609 &mut transport,
2610 &mut client,
2611 &mut effects,
2612 "syncUntilIdle",
2613 &json!({ "timeoutMs": value }),
2614 )
2615 .expect_err("invalid timeout");
2616 assert_eq!(error.code, "sync.invalid_request", "{value}");
2617 }
2618 }
2619
2620 #[test]
2621 fn create_rejects_invalid_push_limits() {
2622 for value in [json!(0), json!(-1), json!(1.5), json!(4_294_967_296u64)] {
2623 let mut transport = NoNetwork::default();
2624 let mut client: Option<SyncClient> = None;
2625 let mut effects = CreateEffects::default();
2626 let error = dispatch(
2627 &mut transport,
2628 &mut client,
2629 &mut effects,
2630 "create",
2631 &json!({
2632 "schema": schema(),
2633 "limits": { "maxPushRequestBytes": value }
2634 }),
2635 )
2636 .expect_err("invalid push limit");
2637 assert_eq!(error.code, "sync.invalid_request", "{value}");
2638 assert_eq!(
2639 error.details.as_ref().unwrap()["limit"],
2640 json!("maxPushRequestBytes")
2641 );
2642 }
2643 }
2644
2645 #[test]
2646 fn preflight_allows_only_the_authorized_previous_version_discard() {
2647 let mut transport = NoNetwork::default();
2648 let mut client: Option<SyncClient> = None;
2649 let mut effects = CreateEffects::default();
2650 dispatch(
2651 &mut transport,
2652 &mut client,
2653 &mut effects,
2654 "create",
2655 &json!({ "schema": schema(), "securityPreflight": true }),
2656 )
2657 .expect("preflight create");
2658
2659 for method in ["previousVersionSnapshot", "previousVersionAudit"] {
2661 let error = dispatch(
2662 &mut transport,
2663 &mut client,
2664 &mut effects,
2665 method,
2666 &json!({}),
2667 )
2668 .expect_err("previous-version reads stay gated during preflight");
2669 assert_eq!(error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2670 }
2671 let discarded = dispatch(
2674 &mut transport,
2675 &mut client,
2676 &mut effects,
2677 "previousVersionDiscard",
2678 &json!({}),
2679 )
2680 .expect("authorized cleanup is allowed during preflight");
2681 assert_eq!(discarded, json!({ "present": false, "discarded": false }));
2682 assert!(client.as_ref().is_some_and(SyncClient::security_preflight));
2683 }
2684
2685 #[test]
2686 fn preflight_refuses_a_replacement_create_without_the_flag() {
2687 let mut transport = NoNetwork::default();
2688 let mut client: Option<SyncClient> = None;
2689 let mut effects = CreateEffects::default();
2690 dispatch(
2691 &mut transport,
2692 &mut client,
2693 &mut effects,
2694 "create",
2695 &json!({ "schema": schema(), "securityPreflight": true }),
2696 )
2697 .expect("preflight create");
2698
2699 let escape = dispatch(
2701 &mut transport,
2702 &mut client,
2703 &mut effects,
2704 "create",
2705 &json!({ "schema": schema() }),
2706 )
2707 .expect_err("plain create must be refused during preflight");
2708 assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2709 let query_error = dispatch(
2711 &mut transport,
2712 &mut client,
2713 &mut effects,
2714 "query",
2715 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2716 )
2717 .expect_err("protected query stays gated");
2718 assert_eq!(query_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2719
2720 dispatch(
2722 &mut transport,
2723 &mut client,
2724 &mut effects,
2725 "create",
2726 &json!({ "schema": schema(), "securityPreflight": true }),
2727 )
2728 .expect("preflighted replacement create");
2729 let still_gated = dispatch(
2730 &mut transport,
2731 &mut client,
2732 &mut effects,
2733 "query",
2734 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2735 )
2736 .expect_err("replacement stays gated");
2737 assert_eq!(still_gated.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2738
2739 dispatch(
2741 &mut transport,
2742 &mut client,
2743 &mut effects,
2744 "activateSecurity",
2745 &json!({}),
2746 )
2747 .expect("activation");
2748 dispatch(
2749 &mut transport,
2750 &mut client,
2751 &mut effects,
2752 "create",
2753 &json!({ "schema": schema() }),
2754 )
2755 .expect("plain create after activation");
2756 let rows = dispatch(
2757 &mut transport,
2758 &mut client,
2759 &mut effects,
2760 "query",
2761 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2762 )
2763 .expect("active query");
2764 assert_eq!(rows, json!({ "rows": [] }));
2765 }
2766
2767 fn temp_db_path(tag: &str) -> String {
2770 std::env::temp_dir()
2771 .join(format!(
2772 "syncular-command-{tag}-{}-{:?}.db",
2773 std::process::id(),
2774 std::time::SystemTime::now()
2775 .duration_since(std::time::UNIX_EPOCH)
2776 .expect("clock")
2777 .as_nanos()
2778 ))
2779 .to_string_lossy()
2780 .into_owned()
2781 }
2782
2783 #[test]
2784 fn an_activated_replica_reopens_plainly_after_shutdown() {
2785 let path = temp_db_path("activated-reopen");
2791 let mut transport = NoNetwork::default();
2792 let mut client: Option<SyncClient> = None;
2793 let mut effects = CreateEffects::default();
2794
2795 dispatch(
2796 &mut transport,
2797 &mut client,
2798 &mut effects,
2799 "create",
2800 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
2801 )
2802 .expect("preflight create");
2803 dispatch(
2804 &mut transport,
2805 &mut client,
2806 &mut effects,
2807 "activateSecurity",
2808 &json!({}),
2809 )
2810 .expect("activation clears the gate");
2811 dispatch(
2812 &mut transport,
2813 &mut client,
2814 &mut effects,
2815 "shutdown",
2816 &json!({}),
2817 )
2818 .expect("shutdown after activation");
2819
2820 dispatch(
2822 &mut transport,
2823 &mut client,
2824 &mut effects,
2825 "create",
2826 &json!({ "schema": schema(), "dbPath": path }),
2827 )
2828 .expect("an activated replica must reopen plainly after shutdown");
2829 let state = dispatch(
2830 &mut transport,
2831 &mut client,
2832 &mut effects,
2833 "securityLifecycle",
2834 &json!({}),
2835 )
2836 .expect("lifecycle");
2837 assert_eq!(state, json!({ "state": "active" }));
2838
2839 let _ = std::fs::remove_file(&path);
2840 }
2841
2842 #[test]
2843 fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
2844 let path = temp_db_path("quarantined-reopen");
2849 let mut transport = NoNetwork::default();
2850 let mut client: Option<SyncClient> = None;
2851 let mut effects = CreateEffects::default();
2852
2853 dispatch(
2854 &mut transport,
2855 &mut client,
2856 &mut effects,
2857 "create",
2858 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
2859 )
2860 .expect("preflight create");
2861 dispatch(
2862 &mut transport,
2863 &mut client,
2864 &mut effects,
2865 "shutdown",
2866 &json!({}),
2867 )
2868 .expect("shutdown during preflight");
2869
2870 let mut rebuilt = CreateEffects::default();
2873 let escape = dispatch(
2874 &mut transport,
2875 &mut client,
2876 &mut rebuilt,
2877 "create",
2878 &json!({ "schema": schema(), "dbPath": path }),
2879 )
2880 .expect_err("a quarantined replica must refuse a plain re-create");
2881 assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2882
2883 let _ = std::fs::remove_file(&path);
2884 }
2885
2886 #[test]
2887 fn preflight_gate_survives_shutdown_before_replacement_creates() {
2888 let mut transport = NoNetwork::default();
2889 let mut client: Option<SyncClient> = None;
2890 let mut effects = CreateEffects::default();
2891 dispatch(
2892 &mut transport,
2893 &mut client,
2894 &mut effects,
2895 "create",
2896 &json!({ "schema": schema(), "securityPreflight": true }),
2897 )
2898 .expect("preflight create");
2899 dispatch(
2900 &mut transport,
2901 &mut client,
2902 &mut effects,
2903 "shutdown",
2904 &json!({}),
2905 )
2906 .expect("shutdown during preflight");
2907 assert!(client.is_none());
2908
2909 let escape = dispatch(
2911 &mut transport,
2912 &mut client,
2913 &mut effects,
2914 "create",
2915 &json!({ "schema": schema() }),
2916 )
2917 .expect_err("shutdown + plain create must stay refused");
2918 assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2919
2920 dispatch(
2921 &mut transport,
2922 &mut client,
2923 &mut effects,
2924 "create",
2925 &json!({ "schema": schema(), "securityPreflight": true }),
2926 )
2927 .expect("preflighted re-create");
2928 dispatch(
2929 &mut transport,
2930 &mut client,
2931 &mut effects,
2932 "activateSecurity",
2933 &json!({}),
2934 )
2935 .expect("activation");
2936
2937 dispatch(
2939 &mut transport,
2940 &mut client,
2941 &mut effects,
2942 "shutdown",
2943 &json!({}),
2944 )
2945 .expect("shutdown after activation");
2946 dispatch(
2947 &mut transport,
2948 &mut client,
2949 &mut effects,
2950 "create",
2951 &json!({ "schema": schema() }),
2952 )
2953 .expect("plain create after an activated shutdown");
2954 }
2955
2956 #[test]
2957 fn activate_security_validates_optional_headers_atomically() {
2958 let mut transport = NoNetwork::default();
2959 let mut client: Option<SyncClient> = None;
2960 let mut effects = CreateEffects::default();
2961 dispatch(
2962 &mut transport,
2963 &mut client,
2964 &mut effects,
2965 "create",
2966 &json!({ "schema": schema(), "securityPreflight": true }),
2967 )
2968 .expect("preflight create");
2969
2970 let invalid = dispatch(
2972 &mut transport,
2973 &mut client,
2974 &mut effects,
2975 "activateSecurity",
2976 &json!({ "headers": { "authorization": 7 } }),
2977 )
2978 .expect_err("non-string header must fail");
2979 assert_eq!(invalid.code, "sync.invalid_request");
2980 let lifecycle = dispatch(
2981 &mut transport,
2982 &mut client,
2983 &mut effects,
2984 "securityLifecycle",
2985 &json!({}),
2986 )
2987 .expect("lifecycle");
2988 assert_eq!(lifecycle, json!({ "state": "preflight" }));
2989
2990 dispatch(
2992 &mut transport,
2993 &mut client,
2994 &mut effects,
2995 "activateSecurity",
2996 &json!({ "headers": { "authorization": "Bearer fresh" } }),
2997 )
2998 .expect("activation with fresh headers");
2999 let lifecycle = dispatch(
3000 &mut transport,
3001 &mut client,
3002 &mut effects,
3003 "securityLifecycle",
3004 &json!({}),
3005 )
3006 .expect("lifecycle");
3007 assert_eq!(lifecycle, json!({ "state": "active" }));
3008 }
3009
3010 #[test]
3011 fn parse_headers_reads_the_full_replacement_set() {
3012 let parsed = parse_headers(&json!({
3013 "authorization": "Bearer fresh",
3014 "x-tenant": "t1"
3015 }))
3016 .expect("valid headers parse");
3017 assert_eq!(
3018 parsed,
3019 vec![
3020 ("authorization".to_owned(), "Bearer fresh".to_owned()),
3021 ("x-tenant".to_owned(), "t1".to_owned())
3022 ]
3023 );
3024 assert!(parse_headers(&json!(["authorization"])).is_err());
3025 assert!(parse_headers(&json!({ "authorization": null })).is_err());
3026 }
3027
3028 #[test]
3029 fn set_headers_validates_replacement_and_respects_preflight() {
3030 let mut transport = NoNetwork::default();
3031 let mut client: Option<SyncClient> = None;
3032 let mut effects = CreateEffects::default();
3033 dispatch(
3034 &mut transport,
3035 &mut client,
3036 &mut effects,
3037 "create",
3038 &json!({ "schema": schema() }),
3039 )
3040 .expect("create");
3041 dispatch(
3042 &mut transport,
3043 &mut client,
3044 &mut effects,
3045 "setHeaders",
3046 &json!({ "headers": { "authorization": "Bearer fresh" } }),
3047 )
3048 .expect("valid replacement");
3049 let invalid = dispatch(
3050 &mut transport,
3051 &mut client,
3052 &mut effects,
3053 "setHeaders",
3054 &json!({ "headers": { "authorization": 7 } }),
3055 )
3056 .expect_err("invalid replacement");
3057 assert_eq!(invalid.code, "sync.invalid_request");
3058
3059 dispatch(
3060 &mut transport,
3061 &mut client,
3062 &mut effects,
3063 "beginSecurityPreflight",
3064 &json!({}),
3065 )
3066 .expect("preflight");
3067 let gated = dispatch(
3068 &mut transport,
3069 &mut client,
3070 &mut effects,
3071 "setHeaders",
3072 &json!({ "headers": {} }),
3073 )
3074 .expect_err("direct rotation must respect preflight");
3075 assert_eq!(gated.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
3076 }
3077
3078 fn schema_v2() -> Value {
3079 json!({
3080 "version": 2,
3081 "tables": [{
3082 "name": "todos",
3083 "columns": [
3084 { "name": "id", "type": "string", "nullable": false },
3085 { "name": "list_id", "type": "string", "nullable": false },
3086 { "name": "note", "type": "string", "nullable": true }
3087 ],
3088 "primaryKey": "id",
3089 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
3090 }]
3091 })
3092 }
3093
3094 fn previous_version_enabled() -> Value {
3095 json!({ "enabled": true })
3096 }
3097
3098 fn container_path(db_path: &str) -> String {
3099 format!("{db_path}.prev-context")
3100 }
3101
3102 #[test]
3103 fn previous_version_config_parser_matches_ts_keys_and_defaults() {
3104 assert_eq!(parse_previous_version_context(None).expect("absent"), None);
3105 let defaults = parse_previous_version_context(Some(&json!({})))
3106 .expect("empty")
3107 .expect("present");
3108 assert!(!defaults.enabled);
3109 assert_eq!(defaults.max_bytes, 8 * 1024 * 1024);
3110 assert_eq!(defaults.max_rows, 20_000);
3111 assert_eq!(defaults.max_tables, 32);
3112 assert_eq!(defaults.max_row_bytes, 1024 * 1024);
3113 assert_eq!(defaults.max_age_ms, 24 * 60 * 60 * 1000);
3114
3115 let configured = parse_previous_version_context(Some(&json!({
3116 "enabled": true,
3117 "maxBytes": 1024,
3118 "maxRows": 3,
3119 "maxTables": 2,
3120 "maxRowBytes": 5,
3121 "maxAgeMs": 7
3122 })))
3123 .expect("configured")
3124 .expect("present");
3125 assert!(configured.enabled);
3126 assert_eq!(configured.max_bytes, 1024);
3127 assert_eq!(configured.max_rows, 3);
3128 assert_eq!(configured.max_tables, 2);
3129 assert_eq!(configured.max_row_bytes, 5);
3130 assert_eq!(configured.max_age_ms, 7);
3131
3132 for (value, expected) in [
3134 (json!([]), "must be an object"),
3135 (json!({ "enabled": "yes" }), "enabled must be a boolean"),
3136 (
3137 json!({ "maxBytes": 0 }),
3138 "maxBytes must be a positive safe integer",
3139 ),
3140 (
3141 json!({ "maxRows": -1 }),
3142 "maxRows must be a positive safe integer",
3143 ),
3144 (
3145 json!({ "maxTables": 1.5 }),
3146 "maxTables must be a positive safe integer",
3147 ),
3148 (
3149 json!({ "maxRowBytes": "1" }),
3150 "maxRowBytes must be a positive safe integer",
3151 ),
3152 (
3153 json!({ "maxAgeMs": 0 }),
3154 "maxAgeMs must be a positive safe integer",
3155 ),
3156 ] {
3157 let error = parse_previous_version_context(Some(&value)).expect_err("must fail");
3158 assert_eq!(error.code, "sync.invalid_request", "{value}");
3159 assert!(
3160 error.message.contains(expected),
3161 "{value}: {}",
3162 error.message
3163 );
3164 }
3165 }
3166
3167 #[test]
3168 fn invalid_previous_version_config_fails_create_loudly() {
3169 let mut transport = NoNetwork::default();
3170 let mut client: Option<SyncClient> = None;
3171 let mut effects = CreateEffects::default();
3172 let error = dispatch(
3173 &mut transport,
3174 &mut client,
3175 &mut effects,
3176 "create",
3177 &json!({ "schema": schema(), "previousVersionContext": { "enabled": true, "maxRows": 0 } }),
3178 )
3179 .expect_err("invalid config must fail the create");
3180 assert_eq!(error.code, "sync.invalid_request");
3181 assert!(error.message.contains("maxRows"), "{}", error.message);
3182 assert!(client.is_none(), "no client may be installed");
3183 }
3184
3185 #[test]
3186 fn previous_version_snapshot_reports_not_configured_by_default() {
3187 let mut transport = NoNetwork::default();
3188 let mut client: Option<SyncClient> = None;
3189 let mut effects = CreateEffects::default();
3190 dispatch(
3191 &mut transport,
3192 &mut client,
3193 &mut effects,
3194 "create",
3195 &json!({ "schema": schema() }),
3196 )
3197 .expect("create");
3198 let snapshot = dispatch(
3199 &mut transport,
3200 &mut client,
3201 &mut effects,
3202 "previousVersionSnapshot",
3203 &json!({ "table": "todos" }),
3204 )
3205 .expect("snapshot");
3206 assert_eq!(snapshot["state"], json!("previousVersion"));
3207 assert_eq!(snapshot["available"], json!(false));
3208 assert_eq!(snapshot["reason"], json!("not-configured"));
3209 assert_eq!(snapshot["rows"], json!([]));
3210 assert_eq!(snapshot["truncated"], json!(false));
3211 assert_eq!(snapshot["currentVersion"], json!(1));
3212 let discarded = dispatch(
3213 &mut transport,
3214 &mut client,
3215 &mut effects,
3216 "previousVersionDiscard",
3217 &json!({}),
3218 )
3219 .expect("discard");
3220 assert_eq!(discarded, json!({ "present": false, "discarded": false }));
3221 assert_eq!(
3222 dispatch(
3223 &mut transport,
3224 &mut client,
3225 &mut effects,
3226 "previousVersionAudit",
3227 &json!({}),
3228 )
3229 .expect("audit"),
3230 Value::Null
3231 );
3232 }
3233
3234 #[test]
3235 fn previous_version_commands_round_trip_through_the_dispatcher() {
3236 let path = temp_db_path("previous-version");
3237 let mut transport = NoNetwork::default();
3238 let mut client: Option<SyncClient> = None;
3239 let mut effects = CreateEffects::default();
3240
3241 dispatch(
3243 &mut transport,
3244 &mut client,
3245 &mut effects,
3246 "create",
3247 &json!({
3248 "schema": schema(),
3249 "dbPath": path,
3250 "previousVersionContext": previous_version_enabled()
3251 }),
3252 )
3253 .expect("create v1");
3254 client
3255 .as_mut()
3256 .expect("client")
3257 .mutate(vec![Mutation::Upsert {
3258 table: "todos".to_owned(),
3259 values: serde_json::Map::from_iter([
3260 ("id".to_owned(), json!("t1")),
3261 ("list_id".to_owned(), json!("l1")),
3262 ]),
3263 base_version: None,
3264 }])
3265 .expect("seed a local row");
3266 dispatch(
3267 &mut transport,
3268 &mut client,
3269 &mut effects,
3270 "shutdown",
3271 &json!({}),
3272 )
3273 .expect("shutdown");
3274
3275 dispatch(
3277 &mut transport,
3278 &mut client,
3279 &mut effects,
3280 "create",
3281 &json!({
3282 "schema": schema_v2(),
3283 "dbPath": path,
3284 "previousVersionContext": previous_version_enabled()
3285 }),
3286 )
3287 .expect("create v2");
3288 assert!(
3289 std::path::Path::new(&container_path(&path)).exists(),
3290 "the bump must leave a container file"
3291 );
3292 let status = dispatch(
3293 &mut transport,
3294 &mut client,
3295 &mut effects,
3296 "statusSnapshot",
3297 &json!({}),
3298 )
3299 .expect("status");
3300 assert_eq!(
3301 status["previousVersionContext"]["present"],
3302 json!(true),
3303 "{status}"
3304 );
3305 assert!(
3306 status["previousVersionContext"]["createdAtMs"].is_i64(),
3307 "{status}"
3308 );
3309
3310 let snapshot = dispatch(
3311 &mut transport,
3312 &mut client,
3313 &mut effects,
3314 "previousVersionSnapshot",
3315 &json!({ "table": "todos" }),
3316 )
3317 .expect("snapshot");
3318 assert_eq!(snapshot["state"], json!("previousVersion"));
3319 assert_eq!(snapshot["available"], json!(true));
3320 assert_eq!(snapshot["previousVersion"], json!(1));
3321 assert_eq!(snapshot["currentVersion"], json!(2));
3322 assert_eq!(snapshot["truncated"], json!(false));
3323 assert_eq!(snapshot["rows"][0]["id"], json!("t1"));
3324 assert_eq!(snapshot["rows"][0]["list_id"], json!("l1"));
3325
3326 let unknown = dispatch(
3328 &mut transport,
3329 &mut client,
3330 &mut effects,
3331 "previousVersionSnapshot",
3332 &json!({ "table": "absent" }),
3333 )
3334 .expect_err("unknown previous table");
3335 assert_eq!(unknown.code, "sync.invalid_request");
3336 let bad_limit = dispatch(
3337 &mut transport,
3338 &mut client,
3339 &mut effects,
3340 "previousVersionSnapshot",
3341 &json!({ "table": "todos", "limit": 0 }),
3342 )
3343 .expect_err("bad limit");
3344 assert_eq!(bad_limit.code, "sync.invalid_request");
3345
3346 let audit = dispatch(
3349 &mut transport,
3350 &mut client,
3351 &mut effects,
3352 "previousVersionAudit",
3353 &json!({}),
3354 )
3355 .expect("audit");
3356 assert_eq!(audit["v"], json!(1));
3357 assert_eq!(audit["fromVersion"], json!(1));
3358 assert_eq!(audit["toVersion"], json!(2));
3359 assert_eq!(audit["pending"], json!(1));
3360 assert_eq!(audit["encodable"], json!(1));
3361 assert_eq!(audit["incompatible"], json!([]));
3362 assert!(audit.get("operations").is_none());
3363
3364 let discarded = dispatch(
3366 &mut transport,
3367 &mut client,
3368 &mut effects,
3369 "previousVersionDiscard",
3370 &json!({}),
3371 )
3372 .expect("discard");
3373 assert_eq!(discarded, json!({ "present": true, "discarded": true }));
3374 assert!(!std::path::Path::new(&container_path(&path)).exists());
3375 let again = dispatch(
3376 &mut transport,
3377 &mut client,
3378 &mut effects,
3379 "previousVersionDiscard",
3380 &json!({}),
3381 )
3382 .expect("idempotent discard");
3383 assert_eq!(again, json!({ "present": false, "discarded": false }));
3384
3385 let inspect = rusqlite::Connection::open(&path).expect("inspect replica");
3387 let remaining: i64 = inspect
3388 .query_row(
3389 "SELECT COUNT(*) FROM _syncular_meta WHERE key IN (?1, ?2)",
3390 rusqlite::params!["previousVersionContext", "previousVersionAudit"],
3391 |row| row.get(0),
3392 )
3393 .expect("count meta");
3394 assert_eq!(remaining, 0);
3395
3396 let _ = std::fs::remove_file(&path);
3397 }
3398
3399 #[test]
3400 fn previous_version_ttl_expires_at_read_through_the_dispatcher() {
3401 let path = temp_db_path("previous-version-ttl");
3402 let mut transport = NoNetwork::default();
3403 let mut client: Option<SyncClient> = None;
3404 let mut effects = CreateEffects::default();
3405 dispatch(
3406 &mut transport,
3407 &mut client,
3408 &mut effects,
3409 "create",
3410 &json!({
3411 "schema": schema(),
3412 "dbPath": path,
3413 "previousVersionContext": previous_version_enabled()
3414 }),
3415 )
3416 .expect("create v1");
3417 dispatch(
3418 &mut transport,
3419 &mut client,
3420 &mut effects,
3421 "shutdown",
3422 &json!({}),
3423 )
3424 .expect("shutdown");
3425
3426 let now_ms = std::time::SystemTime::now()
3430 .duration_since(std::time::UNIX_EPOCH)
3431 .expect("clock")
3432 .as_millis() as i64
3433 + 2_000;
3434 dispatch(
3435 &mut transport,
3436 &mut client,
3437 &mut effects,
3438 "create",
3439 &json!({
3440 "schema": schema_v2(),
3441 "dbPath": path,
3442 "nowMs": now_ms,
3443 "previousVersionContext": { "enabled": true, "maxAgeMs": 500 }
3444 }),
3445 )
3446 .expect("create v2");
3447 assert!(std::path::Path::new(&container_path(&path)).exists());
3448 let snapshot = dispatch(
3449 &mut transport,
3450 &mut client,
3451 &mut effects,
3452 "previousVersionSnapshot",
3453 &json!({ "table": "todos" }),
3454 )
3455 .expect("snapshot");
3456 assert_eq!(snapshot["available"], json!(false));
3457 assert_eq!(snapshot["reason"], json!("expired"));
3458 assert_eq!(snapshot["rows"], json!([]));
3459 assert!(!std::path::Path::new(&container_path(&path)).exists());
3461
3462 let _ = std::fs::remove_file(&path);
3463 }
3464}