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