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 "readRows" => {
1064 let table = params
1065 .get("table")
1066 .and_then(Value::as_str)
1067 .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
1068 let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
1069 Ok(json!({ "rows": rows }))
1070 }
1071 "query" => {
1072 let sql = params
1076 .get("sql")
1077 .and_then(Value::as_str)
1078 .ok_or_else(|| client_err("query missing sql".to_owned()))?;
1079 let bind = match params.get("params") {
1080 Some(Value::Array(list)) => list.as_slice(),
1081 None | Some(Value::Null) => &[],
1082 Some(_) => return Err(client_err("query params must be a list".to_owned())),
1083 };
1084 let rows = need_client(client)?.query(sql, bind).map_err(client_err)?;
1085 Ok(Value::Object(serde_json::Map::from_iter([(
1086 "rows".to_owned(),
1087 Value::Array(rows.into_iter().map(Value::Object).collect()),
1088 )])))
1089 }
1090 "querySnapshot" => {
1091 let sql = params
1092 .get("sql")
1093 .and_then(Value::as_str)
1094 .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
1095 let bind = match params.get("params") {
1096 Some(Value::Array(list)) => list.as_slice(),
1097 None | Some(Value::Null) => &[],
1098 Some(_) => {
1099 return Err(client_err("querySnapshot params must be a list".to_owned()))
1100 }
1101 };
1102 let coverage = parse_window_coverage(params.get("coverage")).map_err(client_err)?;
1103 let owner = parse_query_owner(params, "querySnapshot")?;
1104 let mut snapshot = need_client(client)?
1105 .query_snapshot(
1106 sql,
1107 bind,
1108 &coverage,
1109 owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
1110 )
1111 .map_err(client_err)?;
1112 let rows = std::mem::take(&mut snapshot.rows);
1113 let mut result = serde_json::to_value(snapshot).expect("snapshot serializes");
1114 result["rows"] = Value::Array(rows.into_iter().map(Value::Object).collect());
1115 Ok(result)
1116 }
1117 "localRevision" => {
1118 let read = need_client(client)?
1122 .snapshot_read(&syncular_client::SnapshotReadRequest::default(), None)
1123 .map_err(read_command_error)?;
1124 Ok(json!({ "revision": read.revision }))
1125 }
1126 "snapshotRead" => {
1127 let mut statement_sql: Vec<String> = Vec::new();
1128 let mut statement_params: Vec<Vec<Value>> = Vec::new();
1129 match params.get("statements") {
1130 None | Some(Value::Null) => {}
1131 Some(Value::Array(items)) => {
1132 for item in items {
1133 let Some(sql) = item.get("sql").and_then(Value::as_str) else {
1134 return Err(client_err(
1135 "sync.invalid_request: snapshotRead statements need a sql string"
1136 .to_owned(),
1137 ));
1138 };
1139 let bind = match item.get("params") {
1140 None | Some(Value::Null) => Vec::new(),
1141 Some(Value::Array(values)) => values.clone(),
1142 Some(_) => {
1143 return Err(client_err(
1144 "sync.invalid_request: snapshotRead statement params must be a list"
1145 .to_owned(),
1146 ))
1147 }
1148 };
1149 statement_sql.push(sql.to_owned());
1150 statement_params.push(bind);
1151 }
1152 }
1153 Some(_) => {
1154 return Err(client_err(
1155 "sync.invalid_request: snapshotRead statements must be a list".to_owned(),
1156 ))
1157 }
1158 }
1159 let subscription_ids = snapshot_string_list(params.get("subscriptions"))?;
1160 let commit_ids = snapshot_string_list(params.get("commitIds"))?;
1161 let coverage = parse_window_coverage(params.get("coverage")).map_err(client_err)?;
1162 let owner = parse_query_owner(params, "snapshotRead")?;
1163 let statements: Vec<syncular_client::SnapshotStatement<'_>> = statement_sql
1164 .iter()
1165 .zip(&statement_params)
1166 .map(|(sql, params)| syncular_client::SnapshotStatement { sql, params })
1167 .collect();
1168 let read = need_client(client)?
1169 .snapshot_read(
1170 &syncular_client::SnapshotReadRequest {
1171 statements,
1172 coverage: &coverage,
1173 subscriptions: subscription_ids,
1174 commit_ids,
1175 },
1176 owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
1177 )
1178 .map_err(read_command_error)?;
1179 serde_json::to_value(read).map_err(|error| client_err(error.to_string()))
1180 }
1181 "previousVersionSnapshot" | "previous_version_snapshot" => {
1182 let spec = parse_previous_version_snapshot_spec(params)?;
1183 let snapshot = need_client(client)?
1184 .previous_version_snapshot(&spec)
1185 .map_err(client_err)?;
1186 serde_json::to_value(snapshot).map_err(|error| client_err(error.to_string()))
1187 }
1188 "previousVersionAudit" | "previous_version_audit" => {
1189 Ok(match need_client(client)?.previous_version_audit() {
1190 Some(audit) => {
1191 serde_json::to_value(audit).map_err(|error| client_err(error.to_string()))?
1192 }
1193 None => Value::Null,
1194 })
1195 }
1196 "previousVersionDiscard" | "previous_version_discard" => {
1197 let outcome = need_client(client)?.previous_version_discard();
1198 Ok(json!({
1199 "present": outcome.present,
1200 "discarded": outcome.discarded,
1201 }))
1202 }
1203 "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
1204 .expect("status serializes")),
1205 "diagnosticsSnapshot" => {
1206 let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
1207 .map_err(|error| {
1208 client_err(format!(
1209 "sync.invalid_request: invalid diagnostics request: {error}"
1210 ))
1211 })?;
1212 let snapshot = need_client(client)?
1213 .diagnostics_snapshot(&request)
1214 .map_err(client_err)?;
1215 Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
1216 }
1217 "progressSnapshot" => Ok(
1222 serde_json::to_value(need_client(client)?.progress().snapshot())
1223 .expect("progress JSON"),
1224 ),
1225 "drainChangeBatches" => Ok(json!({
1226 "batches": need_client(client)?.drain_change_batches()
1227 })),
1228 "drainSyncIntents" => Ok(json!({
1229 "intents": need_client(client)?.drain_sync_intents()
1230 })),
1231 #[cfg(feature = "crdt-yjs")]
1237 "crdtText" => {
1238 let (table, row_id, column, name) = crdt_target(params)?;
1239 let text = need_client(client)?
1240 .crdt_text(&table, &row_id, &column, &name)
1241 .map_err(client_err)?;
1242 Ok(json!({ "text": text }))
1243 }
1244 #[cfg(feature = "crdt-yjs")]
1245 "crdtInsertText" => {
1246 let (table, row_id, column, name) = crdt_target(params)?;
1247 let index = params
1248 .get("index")
1249 .and_then(Value::as_u64)
1250 .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
1251 as u32;
1252 let value = params
1253 .get("value")
1254 .and_then(Value::as_str)
1255 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
1256 let id = need_client(client)?
1257 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
1258 .map_err(CommandError::from_client)?;
1259 Ok(json!({ "clientCommitId": id }))
1260 }
1261 #[cfg(feature = "crdt-yjs")]
1262 "crdtDeleteText" => {
1263 let (table, row_id, column, name) = crdt_target(params)?;
1264 let index = params
1265 .get("index")
1266 .and_then(Value::as_u64)
1267 .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
1268 as u32;
1269 let len = params
1270 .get("len")
1271 .and_then(Value::as_u64)
1272 .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
1273 as u32;
1274 let id = need_client(client)?
1275 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
1276 .map_err(CommandError::from_client)?;
1277 Ok(json!({ "clientCommitId": id }))
1278 }
1279 #[cfg(feature = "crdt-yjs")]
1280 "crdtApplyUpdate" => {
1281 let (table, row_id, column, _name) = crdt_target(params)?;
1282 let update = value_bytes(params.get("update")).map_err(client_err)?;
1283 let id = need_client(client)?
1284 .crdt_apply_update(&table, &row_id, &column, &update)
1285 .map_err(CommandError::from_client)?;
1286 Ok(json!({ "clientCommitId": id }))
1287 }
1288 #[cfg(not(feature = "crdt-yjs"))]
1289 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => {
1290 Err(CommandError::new(
1291 "client.crdt_unavailable",
1292 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)",
1293 ))
1294 }
1295
1296 "uploadBlob" => {
1297 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1298 let media_type = params
1299 .get("mediaType")
1300 .and_then(Value::as_str)
1301 .map(str::to_owned);
1302 let name = params
1303 .get("name")
1304 .and_then(Value::as_str)
1305 .map(str::to_owned);
1306 let reference = need_client(client)?
1307 .upload_blob(&bytes, media_type, name)
1308 .map_err(client_err)?;
1309 Ok(json!({ "ref": reference }))
1310 }
1311 "fetchBlob" => {
1312 let blob = params
1313 .get("blob")
1314 .and_then(Value::as_str)
1315 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
1316 .to_owned();
1317 let blob = need_client(client)?.fetch_blob_bytes(transport, &blob)?;
1321 let mut value = serde_json::Map::new();
1322 value.insert("blobId".to_owned(), Value::from(blob.blob_id));
1323 value.insert("byteLength".to_owned(), Value::from(blob.byte_length));
1324 value.insert("bytes".to_owned(), bytes_value(&blob.bytes));
1325 if let Some(media_type) = blob.media_type {
1326 value.insert("mediaType".to_owned(), Value::from(media_type));
1327 }
1328 Ok(Value::Object(serde_json::Map::from_iter([(
1329 "blob".to_owned(),
1330 Value::Object(value),
1331 )])))
1332 }
1333 "conflicts" => {
1334 let conflicts = need_client(client)?.conflicts().to_vec();
1335 Ok(json!({ "conflicts": conflicts }))
1336 }
1337 "rejections" => {
1338 let rejections = need_client(client)?.rejections().to_vec();
1339 Ok(json!({ "rejections": rejections }))
1340 }
1341 "commitOutcome" => {
1342 let client_commit_id = params
1343 .get("clientCommitId")
1344 .and_then(Value::as_str)
1345 .ok_or_else(|| {
1346 client_err(
1347 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
1348 )
1349 })?;
1350 let outcome = need_client(client)?
1351 .commit_outcome(client_commit_id)
1352 .map_err(client_err)?;
1353 Ok(json!({ "outcome": outcome }))
1354 }
1355 "commitOutcomes" => {
1356 let query = serde_json::from_value::<CommitOutcomeQuery>(
1357 params.get("query").cloned().unwrap_or_else(|| json!({})),
1358 )
1359 .map_err(|error| {
1360 client_err(format!(
1361 "sync.invalid_request: invalid commit outcome query: {error}"
1362 ))
1363 })?;
1364 let outcomes = need_client(client)?
1365 .commit_outcomes(query)
1366 .map_err(client_err)?;
1367 Ok(json!({ "outcomes": outcomes }))
1368 }
1369 "resolveCommitOutcome" => {
1370 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
1371 params.get("input").cloned().ok_or_else(|| {
1372 client_err(
1373 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
1374 )
1375 })?,
1376 )
1377 .map_err(|error| {
1378 client_err(format!(
1379 "sync.invalid_request: invalid outcome resolution: {error}"
1380 ))
1381 })?;
1382 let outcome = need_client(client)?
1383 .resolve_commit_outcome(input)
1384 .map_err(client_err)?;
1385 Ok(json!({ "outcome": outcome }))
1386 }
1387 "pendingCommitIds" => {
1388 let ids = need_client(client)?.pending_commit_ids();
1389 Ok(json!({ "ids": ids }))
1390 }
1391 "pendingPayloads" => {
1392 let payloads = need_client(client)?.pending_payloads();
1393 Ok(json!({
1394 "payloads": payloads.iter().map(|p| bytes_value(p)).collect::<Vec<_>>()
1395 }))
1396 }
1397 "subscriptionState" => {
1398 let id = params
1399 .get("id")
1400 .and_then(Value::as_str)
1401 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
1402 let state = need_client(client)?.subscription_state(id);
1403 Ok(json!({ "state": state }))
1404 }
1405 "recreateWithSchema" => {
1406 let schema = params
1410 .get("schema")
1411 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
1412 need_client(client)?
1413 .recreate_with_schema(schema)
1414 .map_err(client_err)?;
1415 Ok(json!({}))
1416 }
1417 "localAuthorityState" => Ok(json!({ "state": transport.local_authority_state() })),
1418 "attachLocalAuthority" | "abandonLocalAuthorityAttach" => {
1419 let running = need_client(client)?;
1420 if transport.local_authority_state().is_none() {
1421 return Err(CommandError::new(
1422 LOCAL_AUTHORITY_UNCONFIGURED_CODE,
1423 "the host configured no localAuthorityPath",
1424 ));
1425 }
1426 let enabled = running.transport_enabled();
1429 running.set_transport_enabled(transport, false);
1430 let outcome = if method == "attachLocalAuthority" {
1431 transport.attach_local_authority()
1432 } else {
1433 transport.abandon_local_authority_attach()
1434 };
1435 running.set_transport_enabled(transport, enabled);
1436 outcome.map_err(transport_command_error)?;
1437 Ok(json!({ "effects": CommandEffects::interactive() }))
1438 }
1439 "connectRealtime" => {
1440 need_client(client)?
1441 .connect_realtime(transport)
1442 .map_err(client_err)?;
1443 Ok(json!({}))
1444 }
1445 "disconnectRealtime" => {
1446 need_client(client)?.disconnect_realtime(transport);
1447 Ok(json!({}))
1448 }
1449 "setPresence" => {
1450 let scope_key = params
1451 .get("scopeKey")
1452 .and_then(Value::as_str)
1453 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
1454 .to_owned();
1455 let doc = params.get("doc");
1457 let doc_ref = match doc {
1458 None | Some(Value::Null) => None,
1459 Some(v) => Some(v),
1460 };
1461 need_client(client)?
1462 .set_presence(transport, &scope_key, doc_ref)
1463 .map_err(client_err)?;
1464 Ok(json!({}))
1465 }
1466 "presence" => {
1467 let scope_key = params
1468 .get("scopeKey")
1469 .and_then(Value::as_str)
1470 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
1471 let peers = need_client(client)?.presence(scope_key);
1472 Ok(json!({ "peers": peers }))
1473 }
1474
1475 "messageRoundtrip" => {
1477 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1478 match decode_message(&bytes) {
1479 Ok(message) => Ok(json!({
1480 "ok": true,
1481 "bytes": bytes_value(&encode_message(&message)),
1482 "renderedJson": render_message(&message).to_string(),
1483 })),
1484 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1485 }
1486 }
1487 "segmentRoundtrip" => {
1488 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1489 match decode_rows_segment(&bytes) {
1490 Ok(segment) => Ok(json!({
1491 "ok": true,
1492 "bytes": bytes_value(&encode_rows_segment(&segment)),
1493 "renderedJson": render_rows_segment(&segment).to_string(),
1494 })),
1495 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1496 }
1497 }
1498 "realtimeKnown" => {
1499 let text = params
1500 .get("text")
1501 .and_then(Value::as_str)
1502 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
1503 let known = matches!(
1504 parse_control(text),
1505 Ok(ControlMessage::Hello { .. })
1506 | Ok(ControlMessage::Wake { .. })
1507 | Ok(ControlMessage::Heartbeat { .. })
1508 | Ok(ControlMessage::Presence { .. })
1509 );
1510 Ok(json!({ "value": known }))
1511 }
1512
1513 other => Err(client_err(format!("unknown method {other:?}"))),
1514 };
1515 if client
1516 .as_ref()
1517 .is_some_and(|running| !running.transport_enabled())
1518 {
1519 if let Ok(value) = &mut result {
1520 if let Some(effects) = value.get_mut("effects").and_then(Value::as_object_mut) {
1521 effects.remove("sync");
1522 }
1523 }
1524 }
1525 result
1526}
1527
1528#[cfg(test)]
1529mod tests {
1530 use serde_json::{json, Value};
1531 use syncular_client::{
1532 Mutation, SegmentRequest, SyncClient, Transport, TransportError,
1533 SECURITY_PREFLIGHT_REQUIRED_CODE,
1534 };
1535
1536 use super::{
1537 client_err, dispatch, parse_encryption, parse_headers, parse_previous_version_context,
1538 CreateEffects,
1539 };
1540
1541 #[derive(Default)]
1542 struct NoNetwork {
1543 realtime_connects: usize,
1544 realtime_closes: usize,
1545 }
1546
1547 impl Transport for NoNetwork {
1548 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1549 Err(TransportError::new("sync.transport_failed", "offline"))
1550 }
1551
1552 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1553 Err(TransportError::new("sync.transport_failed", "offline"))
1554 }
1555
1556 fn download_segment(
1557 &mut self,
1558 _request: &SegmentRequest,
1559 _on_progress: &mut dyn FnMut(u64),
1560 ) -> Result<Vec<u8>, TransportError> {
1561 Err(TransportError::new("sync.transport_failed", "offline"))
1562 }
1563
1564 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1565 self.realtime_connects += 1;
1566 Ok(())
1567 }
1568
1569 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1570 Ok(())
1571 }
1572
1573 fn realtime_close(&mut self) -> Result<(), TransportError> {
1574 self.realtime_closes += 1;
1575 Ok(())
1576 }
1577 }
1578
1579 fn schema() -> Value {
1580 json!({
1581 "version": 1,
1582 "tables": [{
1583 "name": "todos",
1584 "columns": [
1585 { "name": "id", "type": "string", "nullable": false },
1586 { "name": "list_id", "type": "string", "nullable": false }
1587 ],
1588 "primaryKey": "id",
1589 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1590 }]
1591 })
1592 }
1593
1594 #[test]
1595 fn native_command_realtime_connection_is_idempotent() {
1596 let mut transport = NoNetwork::default();
1597 let mut client: Option<SyncClient> = None;
1598 let mut effects = CreateEffects::default();
1599 dispatch(
1600 &mut transport,
1601 &mut client,
1602 &mut effects,
1603 "create",
1604 &json!({ "schema": schema() }),
1605 )
1606 .expect("create client");
1607
1608 for _ in 0..2 {
1609 dispatch(
1610 &mut transport,
1611 &mut client,
1612 &mut effects,
1613 "connectRealtime",
1614 &json!({}),
1615 )
1616 .expect("idempotent connect command");
1617 }
1618 assert_eq!(transport.realtime_connects, 1);
1619
1620 dispatch(
1621 &mut transport,
1622 &mut client,
1623 &mut effects,
1624 "disconnectRealtime",
1625 &json!({}),
1626 )
1627 .expect("disconnect command");
1628 dispatch(
1629 &mut transport,
1630 &mut client,
1631 &mut effects,
1632 "disconnectRealtime",
1633 &json!({}),
1634 )
1635 .expect("idempotent disconnect command");
1636 assert_eq!(transport.realtime_closes, 1);
1637 dispatch(
1638 &mut transport,
1639 &mut client,
1640 &mut effects,
1641 "connectRealtime",
1642 &json!({}),
1643 )
1644 .expect("deliberate reconnect command");
1645 assert_eq!(transport.realtime_connects, 2);
1646 }
1647
1648 #[test]
1649 fn local_authority_commands_without_one_report_the_unconfigured_code() {
1650 let mut transport = NoNetwork::default();
1651 let mut client: Option<SyncClient> = None;
1652 let mut effects = CreateEffects::default();
1653 let state = dispatch(
1654 &mut transport,
1655 &mut client,
1656 &mut effects,
1657 "localAuthorityState",
1658 &json!({}),
1659 )
1660 .expect("state without a client");
1661 assert_eq!(state, json!({ "state": null }));
1662 dispatch(
1663 &mut transport,
1664 &mut client,
1665 &mut effects,
1666 "create",
1667 &json!({ "schema": schema() }),
1668 )
1669 .expect("create client");
1670 for method in ["attachLocalAuthority", "abandonLocalAuthorityAttach"] {
1671 let error = dispatch(
1672 &mut transport,
1673 &mut client,
1674 &mut effects,
1675 method,
1676 &json!({}),
1677 )
1678 .expect_err("no local authority");
1679 assert_eq!(error.code, "client.local_authority_unconfigured");
1680 }
1681 let running = client.as_mut().expect("client");
1682 assert!(running.transport_enabled());
1683 assert!(
1684 running.drain_sync_intents().is_empty(),
1685 "the gate never toggled"
1686 );
1687 }
1688
1689 struct ScriptedAuthority {
1691 opened: Vec<(Value, Option<String>)>,
1692 open_error: Option<TransportError>,
1693 attach_error: Option<TransportError>,
1694 abandons: usize,
1695 }
1696
1697 impl Transport for ScriptedAuthority {
1698 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1699 Err(TransportError::new("sync.transport_failed", "offline"))
1700 }
1701 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1702 Err(TransportError::new("sync.transport_failed", "offline"))
1703 }
1704 fn download_segment(
1705 &mut self,
1706 _request: &SegmentRequest,
1707 _on_progress: &mut dyn FnMut(u64),
1708 ) -> Result<Vec<u8>, TransportError> {
1709 Err(TransportError::new("sync.transport_failed", "offline"))
1710 }
1711 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1712 Ok(())
1713 }
1714 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1715 Ok(())
1716 }
1717 fn realtime_close(&mut self) -> Result<(), TransportError> {
1718 Ok(())
1719 }
1720 fn open_local_authority(
1721 &mut self,
1722 schema: &Value,
1723 replica_path: Option<&str>,
1724 ) -> Result<(), TransportError> {
1725 self.opened
1726 .push((schema.clone(), replica_path.map(str::to_owned)));
1727 self.open_error.take().map_or(Ok(()), Err)
1728 }
1729 fn local_authority_state(&self) -> Option<&'static str> {
1730 (!self.opened.is_empty()).then_some("local")
1731 }
1732 fn attach_local_authority(&mut self) -> Result<(), TransportError> {
1733 self.attach_error.take().map_or(Ok(()), Err)
1734 }
1735 fn abandon_local_authority_attach(&mut self) -> Result<(), TransportError> {
1736 self.abandons += 1;
1737 Ok(())
1738 }
1739 }
1740
1741 #[test]
1742 fn local_authority_opens_at_create_and_attach_errors_keep_code_and_details() {
1743 let mut transport = ScriptedAuthority {
1744 opened: Vec::new(),
1745 open_error: Some(TransportError::new("sync.internal_error", "open failed")),
1746 attach_error: Some(TransportError {
1747 code: "sync.attach_rejected".to_owned(),
1748 message: "the remote server refused an attach commit".to_owned(),
1749 details: Some(json!({ "reason": "commit_rejected", "opIndex": 0 })),
1750 }),
1751 abandons: 0,
1752 };
1753 let mut client: Option<SyncClient> = None;
1754 let mut effects = CreateEffects::default();
1755 let error = dispatch(
1756 &mut transport,
1757 &mut client,
1758 &mut effects,
1759 "create",
1760 &json!({ "schema": schema() }),
1761 )
1762 .expect_err("an authority that fails to open fails create");
1763 assert_eq!(error.code, "sync.internal_error");
1764 assert!(client.is_none());
1765
1766 dispatch(
1767 &mut transport,
1768 &mut client,
1769 &mut effects,
1770 "create",
1771 &json!({ "schema": schema() }),
1772 )
1773 .expect("create client");
1774 assert_eq!(transport.opened.len(), 2);
1775 assert_eq!(transport.opened[1], (schema(), None));
1776 let state = dispatch(
1777 &mut transport,
1778 &mut client,
1779 &mut effects,
1780 "localAuthorityState",
1781 &json!({}),
1782 )
1783 .expect("state");
1784 assert_eq!(state, json!({ "state": "local" }));
1785
1786 let error = dispatch(
1787 &mut transport,
1788 &mut client,
1789 &mut effects,
1790 "attachLocalAuthority",
1791 &json!({}),
1792 )
1793 .expect_err("scripted rejection");
1794 assert_eq!(error.code, "sync.attach_rejected");
1795 assert_eq!(
1796 error.details,
1797 Some(json!({ "reason": "commit_rejected", "opIndex": 0 }))
1798 );
1799 assert!(client.as_ref().expect("client").transport_enabled());
1800
1801 let abandoned = dispatch(
1802 &mut transport,
1803 &mut client,
1804 &mut effects,
1805 "abandonLocalAuthorityAttach",
1806 &json!({}),
1807 )
1808 .expect("abandon");
1809 assert_eq!(abandoned["effects"]["sync"]["kind"], "interactive");
1810 assert_eq!(transport.abandons, 1);
1811
1812 dispatch(
1814 &mut transport,
1815 &mut client,
1816 &mut effects,
1817 "setTransportEnabled",
1818 &json!({ "enabled": false }),
1819 )
1820 .expect("go offline");
1821 let attached = dispatch(
1822 &mut transport,
1823 &mut client,
1824 &mut effects,
1825 "attachLocalAuthority",
1826 &json!({}),
1827 )
1828 .expect("attach");
1829 assert!(attached["effects"].get("sync").is_none());
1830 assert!(!client.as_ref().expect("client").transport_enabled());
1831 }
1832
1833 #[test]
1834 fn schema_downgrade_has_structured_code_at_create_and_recreate_boundaries() {
1835 let path = temp_db_path("schema-downgrade");
1836 let mut newer = schema();
1837 newer["version"] = json!(3);
1838 let mut older = schema();
1839 older["version"] = json!(2);
1840 let mut transport = NoNetwork::default();
1841 let mut client = None;
1842 let mut effects = CreateEffects::default();
1843 dispatch(
1844 &mut transport,
1845 &mut client,
1846 &mut effects,
1847 "create",
1848 &json!({ "schema": newer, "dbPath": path }),
1849 )
1850 .expect("v3 create");
1851 let error = dispatch(
1852 &mut transport,
1853 &mut client,
1854 &mut effects,
1855 "recreateWithSchema",
1856 &json!({ "schema": older }),
1857 )
1858 .expect_err("refuse recreation");
1859 assert_eq!(error.code, "client.schema_downgrade");
1860 assert_eq!(
1861 error.message,
1862 "client.schema_downgrade: persisted local schema is newer than the requested schema"
1863 );
1864 dispatch(
1865 &mut transport,
1866 &mut client,
1867 &mut effects,
1868 "shutdown",
1869 &json!({}),
1870 )
1871 .expect("shutdown");
1872 let error = dispatch(
1873 &mut transport,
1874 &mut client,
1875 &mut effects,
1876 "create",
1877 &json!({ "schema": older, "dbPath": path }),
1878 )
1879 .expect_err("refuse open");
1880 assert_eq!(error.code, "client.schema_downgrade");
1881 assert!(client.is_none());
1882 dispatch(
1883 &mut transport,
1884 &mut client,
1885 &mut effects,
1886 "create",
1887 &json!({ "schema": newer, "dbPath": path }),
1888 )
1889 .expect("compatible reopen");
1890 dispatch(
1891 &mut transport,
1892 &mut client,
1893 &mut effects,
1894 "shutdown",
1895 &json!({}),
1896 )
1897 .expect("shutdown");
1898 let _ = std::fs::remove_file(path);
1899 }
1900
1901 #[test]
1902 fn native_command_realtime_policy_rides_create_and_refuses_http() {
1903 let mut transport = NoNetwork::default();
1904 let mut client: Option<SyncClient> = None;
1905 let mut effects = CreateEffects::default();
1906 dispatch(
1907 &mut transport,
1908 &mut client,
1909 &mut effects,
1910 "create",
1911 &json!({ "schema": schema(), "realtimePolicy": "required" }),
1912 )
1913 .expect("create client with a required realtime policy");
1914 let result = dispatch(
1915 &mut transport,
1916 &mut client,
1917 &mut effects,
1918 "sync",
1919 &json!({}),
1920 )
1921 .expect("sync command");
1922 assert_eq!(result["ok"], json!(false));
1923 assert_eq!(result["errorCode"], json!("sync.realtime_unavailable"));
1924 assert_eq!(result["realtimeState"], json!("disconnected"));
1925 assert_eq!(result["retryDelayMs"], json!(250));
1926 let snapshot = dispatch(
1927 &mut transport,
1928 &mut client,
1929 &mut effects,
1930 "diagnosticsSnapshot",
1931 &json!({}),
1932 )
1933 .expect("diagnostics command");
1934 assert_eq!(snapshot["host"]["realtime"], json!("disconnected"));
1935 assert_eq!(snapshot["host"]["realtimePolicy"], json!("required"));
1936 assert_eq!(snapshot["host"]["realtimeRetryDelayMs"], json!(250));
1937
1938 let mut second: Option<SyncClient> = None;
1939 let error = dispatch(
1940 &mut transport,
1941 &mut second,
1942 &mut effects,
1943 "create",
1944 &json!({ "schema": schema(), "realtimePolicy": "always" }),
1945 )
1946 .expect_err("an unknown policy is refused");
1947 assert_eq!(error.code, "sync.invalid_request");
1948 }
1949
1950 #[test]
1951 fn parses_portable_encryption_keyring_and_key_id_columns() {
1952 let key_hex = "2a".repeat(32);
1953 let config = parse_encryption(&json!({
1954 "keys": { "practice-key-v1": { "$bytes": key_hex } },
1955 "keyIdColumns": { "patients": "encryption_key_id" }
1956 }))
1957 .expect("portable keyring parses");
1958 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1959 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1960 }
1961
1962 #[test]
1963 fn rejects_non_string_key_id_columns() {
1964 let error = parse_encryption(&json!({
1965 "keys": {},
1966 "keyIdColumns": { "patients": 7 }
1967 }))
1968 .expect_err("invalid selector must fail");
1969 assert!(error.contains("must be a string"), "{error}");
1970 }
1971
1972 #[test]
1973 fn subscribe_many_registers_every_subscription() {
1974 let mut transport = NoNetwork::default();
1975 let mut client: Option<SyncClient> = None;
1976 let mut effects = CreateEffects::default();
1977 dispatch(
1978 &mut transport,
1979 &mut client,
1980 &mut effects,
1981 "create",
1982 &json!({ "schema": schema() }),
1983 )
1984 .expect("create client");
1985 dispatch(
1986 &mut transport,
1987 &mut client,
1988 &mut effects,
1989 "subscribeMany",
1990 &json!({ "subscriptions": [
1991 { "id": "a", "table": "todos", "scopes": { "list_id": ["list-1"] } },
1992 { "id": "b", "table": "todos", "scopes": { "list_id": ["list-2"] } }
1993 ] }),
1994 )
1995 .expect("register subscriptions");
1996 for id in ["a", "b"] {
1997 let state = dispatch(
1998 &mut transport,
1999 &mut client,
2000 &mut effects,
2001 "subscriptionState",
2002 &json!({ "id": id }),
2003 )
2004 .expect("subscription state");
2005 assert_eq!(state["state"]["table"], "todos");
2006 }
2007 }
2008
2009 #[test]
2010 fn native_command_hosts_reject_subscription_identity_rebinds() {
2011 let mut transport = NoNetwork::default();
2012 let mut client: Option<SyncClient> = None;
2013 let mut effects = CreateEffects::default();
2014 dispatch(
2015 &mut transport,
2016 &mut client,
2017 &mut effects,
2018 "create",
2019 &json!({ "schema": schema() }),
2020 )
2021 .expect("create client");
2022
2023 let original = json!({
2024 "id": "stable-subscription",
2025 "table": "todos",
2026 "scopes": { "list_id": ["list-2", "list-1"] },
2027 "params": "{\"view\":\"v1\"}"
2028 });
2029 dispatch(
2030 &mut transport,
2031 &mut client,
2032 &mut effects,
2033 "subscribe",
2034 &original,
2035 )
2036 .expect("register subscription");
2037 dispatch(
2038 &mut transport,
2039 &mut client,
2040 &mut effects,
2041 "subscribe",
2042 &json!({
2043 "id": "stable-subscription",
2044 "table": "todos",
2045 "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
2046 "params": "{\"view\":\"v1\"}"
2047 }),
2048 )
2049 .expect("canonical re-declaration is idempotent");
2050
2051 let error = dispatch(
2052 &mut transport,
2053 &mut client,
2054 &mut effects,
2055 "subscribe",
2056 &json!({
2057 "id": "stable-subscription",
2058 "table": "todos",
2059 "scopes": { "list_id": ["list-2"] },
2060 "params": "{\"view\":\"v1\"}"
2061 }),
2062 )
2063 .expect_err("changed native query identity must fail");
2064 assert_eq!(error.code, "client.subscription_intent_mismatch");
2065
2066 let state = dispatch(
2067 &mut transport,
2068 &mut client,
2069 &mut effects,
2070 "subscriptionState",
2071 &json!({ "id": "stable-subscription" }),
2072 )
2073 .expect("subscription state");
2074 assert_eq!(state["state"]["cursor"], -1);
2075 assert_eq!(state["state"]["table"], "todos");
2076 }
2077
2078 #[test]
2079 fn security_preflight_is_fail_closed_until_exact_activation() {
2080 let mut transport = NoNetwork::default();
2081 let mut client: Option<SyncClient> = None;
2082 let mut effects = CreateEffects::default();
2083 dispatch(
2084 &mut transport,
2085 &mut client,
2086 &mut effects,
2087 "create",
2088 &json!({ "schema": schema(), "securityPreflight": true }),
2089 )
2090 .expect("preflight create");
2091
2092 let lifecycle = dispatch(
2093 &mut transport,
2094 &mut client,
2095 &mut effects,
2096 "securityLifecycle",
2097 &json!({}),
2098 )
2099 .expect("lifecycle");
2100 assert_eq!(lifecycle, json!({ "state": "preflight" }));
2101
2102 let query_error = dispatch(
2103 &mut transport,
2104 &mut client,
2105 &mut effects,
2106 "query",
2107 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2108 )
2109 .expect_err("protected query must fail");
2110 assert_eq!(query_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2111 let diagnostics_error = dispatch(
2112 &mut transport,
2113 &mut client,
2114 &mut effects,
2115 "diagnosticsSnapshot",
2116 &json!({}),
2117 )
2118 .expect_err("diagnostics table/subscription evidence remains protected");
2119 assert_eq!(diagnostics_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2120
2121 dispatch(
2122 &mut transport,
2123 &mut client,
2124 &mut effects,
2125 "purgeLocalData",
2126 &json!({
2127 "input": {
2128 "purgeId": "directive-1",
2129 "targets": [{
2130 "table": "todos",
2131 "selectors": { "list_id": ["list-1"] }
2132 }]
2133 }
2134 }),
2135 )
2136 .expect("authorized local purge remains available");
2137
2138 let repair_error = dispatch(
2139 &mut transport,
2140 &mut client,
2141 &mut effects,
2142 "rebootstrapLocalData",
2143 &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
2144 )
2145 .expect_err("projection repair must remain protected during preflight");
2146 assert_eq!(repair_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2147
2148 dispatch(
2149 &mut transport,
2150 &mut client,
2151 &mut effects,
2152 "activateSecurity",
2153 &json!({}),
2154 )
2155 .expect("activation");
2156 let rows = dispatch(
2157 &mut transport,
2158 &mut client,
2159 &mut effects,
2160 "query",
2161 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2162 )
2163 .expect("active query");
2164 assert_eq!(rows, json!({ "rows": [] }));
2165
2166 dispatch(
2167 &mut transport,
2168 &mut client,
2169 &mut effects,
2170 "beginSecurityPreflight",
2171 &json!({}),
2172 )
2173 .expect("re-enter preflight");
2174 let blocked = dispatch(
2175 &mut transport,
2176 &mut client,
2177 &mut effects,
2178 "mutate",
2179 &json!({ "mutations": [] }),
2180 )
2181 .expect_err("mutation must be gated");
2182 assert_eq!(blocked.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2183 }
2184
2185 #[test]
2186 fn mutate_and_patch_forward_typed_storage_failures() {
2187 let path = temp_db_path("busy-authoring");
2188 let schema = json!({
2189 "version": 1,
2190 "tables": [{
2191 "name": "todos",
2192 "columns": [
2193 { "name": "id", "type": "string", "nullable": false },
2194 { "name": "list_id", "type": "string", "nullable": false },
2195 { "name": "title", "type": "string", "nullable": false }
2196 ],
2197 "primaryKey": "id",
2198 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2199 }]
2200 });
2201 let mut transport = NoNetwork::default();
2202 let mut client: Option<SyncClient> = None;
2203 let mut effects = CreateEffects::default();
2204 dispatch(
2205 &mut transport,
2206 &mut client,
2207 &mut effects,
2208 "create",
2209 &json!({ "schema": schema, "dbPath": path }),
2210 )
2211 .expect("create file-backed client");
2212 let upsert = |id: &str| {
2213 json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2214 "id": id, "list_id": "l1", "title": "first" } }] })
2215 };
2216 dispatch(
2217 &mut transport,
2218 &mut client,
2219 &mut effects,
2220 "mutate",
2221 &upsert("t1"),
2222 )
2223 .expect("seed row");
2224
2225 let locker = rusqlite::Connection::open(&path).expect("second connection");
2226 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
2227 let busy = dispatch(
2228 &mut transport,
2229 &mut client,
2230 &mut effects,
2231 "mutate",
2232 &upsert("t2"),
2233 )
2234 .expect_err("busy mutate");
2235 assert_eq!(busy.code, "client.storage_busy");
2236 assert!(busy.retryable);
2237 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
2238
2239 let busy_patch = dispatch(
2240 &mut transport,
2241 &mut client,
2242 &mut effects,
2243 "patch",
2244 &json!({ "table": "todos", "rowId": "t1", "partial": { "title": "second" } }),
2245 )
2246 .expect_err("busy patch");
2247 assert_eq!(busy_patch.code, "client.storage_busy");
2248 assert!(busy_patch.retryable);
2249 assert_eq!(busy_patch.details.as_ref().unwrap()["sqliteCode"], 5);
2250
2251 locker.execute_batch("ROLLBACK").unwrap();
2252 let retried = dispatch(
2253 &mut transport,
2254 &mut client,
2255 &mut effects,
2256 "mutate",
2257 &upsert("t2"),
2258 )
2259 .expect("retry after busy");
2260 assert!(retried.get("clientCommitId").is_some());
2261 let _ = std::fs::remove_file(&path);
2262 }
2263
2264 #[test]
2265 fn read_command_error_preserves_code_details_and_retryability() {
2266 let corrupt = super::read_command_error(syncular_client::QueryReadFailure {
2267 code: Some("sync.local_corrupt"),
2268 sqlite_code: None,
2269 sqlite_message: None,
2270 rollback_failure: None,
2271 message: "persisted local revision is invalid".to_owned(),
2272 });
2273 assert_eq!(corrupt.code, "sync.local_corrupt");
2274 assert!(!corrupt.retryable);
2275 assert!(corrupt.details.is_none());
2276
2277 let busy = super::read_command_error(syncular_client::QueryReadFailure {
2278 code: Some("client.storage_busy"),
2279 sqlite_code: Some(5),
2280 sqlite_message: Some("database is locked".to_owned()),
2281 rollback_failure: None,
2282 message: "local SQLite storage is busy".to_owned(),
2283 });
2284 assert_eq!(busy.code, "client.storage_busy");
2285 assert!(busy.retryable);
2286 assert_eq!(
2287 busy.details.expect("busy details")["sqliteCode"],
2288 serde_json::json!(5)
2289 );
2290 }
2291
2292 #[test]
2293 fn mutate_and_patch_report_caller_value_failures_as_invalid_request() {
2294 let path = temp_db_path("caller-values");
2295 let schema = json!({
2296 "version": 1,
2297 "tables": [{
2298 "name": "todos",
2299 "columns": [
2300 { "name": "id", "type": "string", "nullable": false },
2301 { "name": "list_id", "type": "string", "nullable": false },
2302 { "name": "title", "type": "string", "nullable": false },
2303 { "name": "priority", "type": "integer", "nullable": false }
2304 ],
2305 "primaryKey": "id",
2306 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2307 }]
2308 });
2309 let mut transport = NoNetwork::default();
2310 let mut client: Option<SyncClient> = None;
2311 let mut effects = CreateEffects::default();
2312 dispatch(
2313 &mut transport,
2314 &mut client,
2315 &mut effects,
2316 "create",
2317 &json!({ "schema": schema, "dbPath": path }),
2318 )
2319 .expect("create file-backed client");
2320 let seed = dispatch(
2321 &mut transport,
2322 &mut client,
2323 &mut effects,
2324 "mutate",
2325 &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2326 "id": "t1", "list_id": "l1", "title": "first", "priority": 1 } }] }),
2327 )
2328 .expect("seed row");
2329 let seed_id = seed["clientCommitId"].clone();
2330
2331 let unknown = dispatch(
2334 &mut transport,
2335 &mut client,
2336 &mut effects,
2337 "mutate",
2338 &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2339 "id": "t2", "list_id": "l1", "title": "second", "priority": 1,
2340 "missing_column": 1 } }] }),
2341 )
2342 .expect_err("unknown column");
2343 assert_eq!(unknown.code, "sync.invalid_request");
2344 assert_eq!(unknown.message, "the authoring request is invalid");
2345 assert!(!unknown.retryable);
2346 assert!(unknown.details.as_ref().unwrap()["legacyCause"]
2347 .as_str()
2348 .unwrap()
2349 .contains("unknown column"));
2350
2351 let bad_patch = dispatch(
2352 &mut transport,
2353 &mut client,
2354 &mut effects,
2355 "patch",
2356 &json!({ "table": "todos", "rowId": "t1", "partial": { "priority": "high" } }),
2357 )
2358 .expect_err("bad patch value");
2359 assert_eq!(bad_patch.code, "sync.invalid_request");
2360 assert_eq!(bad_patch.message, "the authoring request is invalid");
2361 assert!(bad_patch.details.as_ref().unwrap()["legacyCause"]
2362 .as_str()
2363 .unwrap()
2364 .contains("priority"));
2365
2366 let pending = dispatch(
2368 &mut transport,
2369 &mut client,
2370 &mut effects,
2371 "pendingCommitIds",
2372 &json!({}),
2373 )
2374 .expect("pending commit ids");
2375 assert_eq!(pending["ids"], json!([seed_id]));
2376 let rows = dispatch(
2377 &mut transport,
2378 &mut client,
2379 &mut effects,
2380 "query",
2381 &json!({ "sql": "SELECT id, priority FROM todos ORDER BY id" }),
2382 )
2383 .expect("visible rows");
2384 assert_eq!(rows["rows"], json!([{ "id": "t1", "priority": 1 }]));
2385
2386 let accepted = dispatch(
2388 &mut transport,
2389 &mut client,
2390 &mut effects,
2391 "mutate",
2392 &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2393 "id": "t2", "list_id": "l1", "title": "second", "priority": 2 } }] }),
2394 )
2395 .expect("retry after rejection");
2396 assert!(accepted.get("clientCommitId").is_some());
2397 let _ = std::fs::remove_file(&path);
2398 }
2399
2400 #[test]
2401 fn client_err_preserves_retryable_for_storage_busy() {
2402 let busy = client_err("client.storage_busy: local SQLite storage is busy".to_owned());
2403 assert_eq!(busy.code, "client.storage_busy");
2404 assert!(busy.retryable);
2405 assert_eq!(
2407 busy.message,
2408 "client.storage_busy: local SQLite storage is busy"
2409 );
2410 let full = client_err("client.storage_full: local SQLite storage is full".to_owned());
2411 assert_eq!(full.code, "client.storage_full");
2412 assert!(!full.retryable);
2413 }
2414
2415 #[test]
2416 fn sync_until_idle_rejects_an_invalid_max_rounds_budget() {
2417 for value in [json!(0), json!(-1), json!(1.5), json!(4_294_967_296u64)] {
2418 let mut transport = NoNetwork::default();
2419 let mut client: Option<SyncClient> = None;
2420 let mut effects = CreateEffects::default();
2421 dispatch(
2422 &mut transport,
2423 &mut client,
2424 &mut effects,
2425 "create",
2426 &json!({ "schema": schema() }),
2427 )
2428 .expect("create");
2429 let error = dispatch(
2430 &mut transport,
2431 &mut client,
2432 &mut effects,
2433 "syncUntilIdle",
2434 &json!({ "maxRounds": value }),
2435 )
2436 .expect_err("invalid budget");
2437 assert_eq!(error.code, "sync.invalid_request", "{value}");
2438 }
2439 }
2440
2441 #[test]
2442 fn sync_until_idle_rejects_an_invalid_timeout() {
2443 for value in [json!(0), json!(-1), json!(1.5)] {
2444 let mut transport = NoNetwork::default();
2445 let mut client: Option<SyncClient> = None;
2446 let mut effects = CreateEffects::default();
2447 dispatch(
2448 &mut transport,
2449 &mut client,
2450 &mut effects,
2451 "create",
2452 &json!({ "schema": schema() }),
2453 )
2454 .expect("create");
2455 let error = dispatch(
2456 &mut transport,
2457 &mut client,
2458 &mut effects,
2459 "syncUntilIdle",
2460 &json!({ "timeoutMs": value }),
2461 )
2462 .expect_err("invalid timeout");
2463 assert_eq!(error.code, "sync.invalid_request", "{value}");
2464 }
2465 }
2466
2467 #[test]
2468 fn create_rejects_invalid_push_limits() {
2469 for value in [json!(0), json!(-1), json!(1.5), json!(4_294_967_296u64)] {
2470 let mut transport = NoNetwork::default();
2471 let mut client: Option<SyncClient> = None;
2472 let mut effects = CreateEffects::default();
2473 let error = dispatch(
2474 &mut transport,
2475 &mut client,
2476 &mut effects,
2477 "create",
2478 &json!({
2479 "schema": schema(),
2480 "limits": { "maxPushRequestBytes": value }
2481 }),
2482 )
2483 .expect_err("invalid push limit");
2484 assert_eq!(error.code, "sync.invalid_request", "{value}");
2485 assert_eq!(
2486 error.details.as_ref().unwrap()["limit"],
2487 json!("maxPushRequestBytes")
2488 );
2489 }
2490 }
2491
2492 #[test]
2493 fn preflight_allows_only_the_authorized_previous_version_discard() {
2494 let mut transport = NoNetwork::default();
2495 let mut client: Option<SyncClient> = None;
2496 let mut effects = CreateEffects::default();
2497 dispatch(
2498 &mut transport,
2499 &mut client,
2500 &mut effects,
2501 "create",
2502 &json!({ "schema": schema(), "securityPreflight": true }),
2503 )
2504 .expect("preflight create");
2505
2506 for method in ["previousVersionSnapshot", "previousVersionAudit"] {
2508 let error = dispatch(
2509 &mut transport,
2510 &mut client,
2511 &mut effects,
2512 method,
2513 &json!({}),
2514 )
2515 .expect_err("previous-version reads stay gated during preflight");
2516 assert_eq!(error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2517 }
2518 let discarded = dispatch(
2521 &mut transport,
2522 &mut client,
2523 &mut effects,
2524 "previousVersionDiscard",
2525 &json!({}),
2526 )
2527 .expect("authorized cleanup is allowed during preflight");
2528 assert_eq!(discarded, json!({ "present": false, "discarded": false }));
2529 assert!(client.as_ref().is_some_and(SyncClient::security_preflight));
2530 }
2531
2532 #[test]
2533 fn preflight_refuses_a_replacement_create_without_the_flag() {
2534 let mut transport = NoNetwork::default();
2535 let mut client: Option<SyncClient> = None;
2536 let mut effects = CreateEffects::default();
2537 dispatch(
2538 &mut transport,
2539 &mut client,
2540 &mut effects,
2541 "create",
2542 &json!({ "schema": schema(), "securityPreflight": true }),
2543 )
2544 .expect("preflight create");
2545
2546 let escape = dispatch(
2548 &mut transport,
2549 &mut client,
2550 &mut effects,
2551 "create",
2552 &json!({ "schema": schema() }),
2553 )
2554 .expect_err("plain create must be refused during preflight");
2555 assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2556 let query_error = dispatch(
2558 &mut transport,
2559 &mut client,
2560 &mut effects,
2561 "query",
2562 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2563 )
2564 .expect_err("protected query stays gated");
2565 assert_eq!(query_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2566
2567 dispatch(
2569 &mut transport,
2570 &mut client,
2571 &mut effects,
2572 "create",
2573 &json!({ "schema": schema(), "securityPreflight": true }),
2574 )
2575 .expect("preflighted replacement create");
2576 let still_gated = dispatch(
2577 &mut transport,
2578 &mut client,
2579 &mut effects,
2580 "query",
2581 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2582 )
2583 .expect_err("replacement stays gated");
2584 assert_eq!(still_gated.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2585
2586 dispatch(
2588 &mut transport,
2589 &mut client,
2590 &mut effects,
2591 "activateSecurity",
2592 &json!({}),
2593 )
2594 .expect("activation");
2595 dispatch(
2596 &mut transport,
2597 &mut client,
2598 &mut effects,
2599 "create",
2600 &json!({ "schema": schema() }),
2601 )
2602 .expect("plain create after activation");
2603 let rows = dispatch(
2604 &mut transport,
2605 &mut client,
2606 &mut effects,
2607 "query",
2608 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2609 )
2610 .expect("active query");
2611 assert_eq!(rows, json!({ "rows": [] }));
2612 }
2613
2614 fn temp_db_path(tag: &str) -> String {
2617 std::env::temp_dir()
2618 .join(format!(
2619 "syncular-command-{tag}-{}-{:?}.db",
2620 std::process::id(),
2621 std::time::SystemTime::now()
2622 .duration_since(std::time::UNIX_EPOCH)
2623 .expect("clock")
2624 .as_nanos()
2625 ))
2626 .to_string_lossy()
2627 .into_owned()
2628 }
2629
2630 #[test]
2631 fn an_activated_replica_reopens_plainly_after_shutdown() {
2632 let path = temp_db_path("activated-reopen");
2638 let mut transport = NoNetwork::default();
2639 let mut client: Option<SyncClient> = None;
2640 let mut effects = CreateEffects::default();
2641
2642 dispatch(
2643 &mut transport,
2644 &mut client,
2645 &mut effects,
2646 "create",
2647 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
2648 )
2649 .expect("preflight create");
2650 dispatch(
2651 &mut transport,
2652 &mut client,
2653 &mut effects,
2654 "activateSecurity",
2655 &json!({}),
2656 )
2657 .expect("activation clears the gate");
2658 dispatch(
2659 &mut transport,
2660 &mut client,
2661 &mut effects,
2662 "shutdown",
2663 &json!({}),
2664 )
2665 .expect("shutdown after activation");
2666
2667 dispatch(
2669 &mut transport,
2670 &mut client,
2671 &mut effects,
2672 "create",
2673 &json!({ "schema": schema(), "dbPath": path }),
2674 )
2675 .expect("an activated replica must reopen plainly after shutdown");
2676 let state = dispatch(
2677 &mut transport,
2678 &mut client,
2679 &mut effects,
2680 "securityLifecycle",
2681 &json!({}),
2682 )
2683 .expect("lifecycle");
2684 assert_eq!(state, json!({ "state": "active" }));
2685
2686 let _ = std::fs::remove_file(&path);
2687 }
2688
2689 #[test]
2690 fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
2691 let path = temp_db_path("quarantined-reopen");
2696 let mut transport = NoNetwork::default();
2697 let mut client: Option<SyncClient> = None;
2698 let mut effects = CreateEffects::default();
2699
2700 dispatch(
2701 &mut transport,
2702 &mut client,
2703 &mut effects,
2704 "create",
2705 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
2706 )
2707 .expect("preflight create");
2708 dispatch(
2709 &mut transport,
2710 &mut client,
2711 &mut effects,
2712 "shutdown",
2713 &json!({}),
2714 )
2715 .expect("shutdown during preflight");
2716
2717 let mut rebuilt = CreateEffects::default();
2720 let escape = dispatch(
2721 &mut transport,
2722 &mut client,
2723 &mut rebuilt,
2724 "create",
2725 &json!({ "schema": schema(), "dbPath": path }),
2726 )
2727 .expect_err("a quarantined replica must refuse a plain re-create");
2728 assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2729
2730 let _ = std::fs::remove_file(&path);
2731 }
2732
2733 #[test]
2734 fn preflight_gate_survives_shutdown_before_replacement_creates() {
2735 let mut transport = NoNetwork::default();
2736 let mut client: Option<SyncClient> = None;
2737 let mut effects = CreateEffects::default();
2738 dispatch(
2739 &mut transport,
2740 &mut client,
2741 &mut effects,
2742 "create",
2743 &json!({ "schema": schema(), "securityPreflight": true }),
2744 )
2745 .expect("preflight create");
2746 dispatch(
2747 &mut transport,
2748 &mut client,
2749 &mut effects,
2750 "shutdown",
2751 &json!({}),
2752 )
2753 .expect("shutdown during preflight");
2754 assert!(client.is_none());
2755
2756 let escape = dispatch(
2758 &mut transport,
2759 &mut client,
2760 &mut effects,
2761 "create",
2762 &json!({ "schema": schema() }),
2763 )
2764 .expect_err("shutdown + plain create must stay refused");
2765 assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2766
2767 dispatch(
2768 &mut transport,
2769 &mut client,
2770 &mut effects,
2771 "create",
2772 &json!({ "schema": schema(), "securityPreflight": true }),
2773 )
2774 .expect("preflighted re-create");
2775 dispatch(
2776 &mut transport,
2777 &mut client,
2778 &mut effects,
2779 "activateSecurity",
2780 &json!({}),
2781 )
2782 .expect("activation");
2783
2784 dispatch(
2786 &mut transport,
2787 &mut client,
2788 &mut effects,
2789 "shutdown",
2790 &json!({}),
2791 )
2792 .expect("shutdown after activation");
2793 dispatch(
2794 &mut transport,
2795 &mut client,
2796 &mut effects,
2797 "create",
2798 &json!({ "schema": schema() }),
2799 )
2800 .expect("plain create after an activated shutdown");
2801 }
2802
2803 #[test]
2804 fn activate_security_validates_optional_headers_atomically() {
2805 let mut transport = NoNetwork::default();
2806 let mut client: Option<SyncClient> = None;
2807 let mut effects = CreateEffects::default();
2808 dispatch(
2809 &mut transport,
2810 &mut client,
2811 &mut effects,
2812 "create",
2813 &json!({ "schema": schema(), "securityPreflight": true }),
2814 )
2815 .expect("preflight create");
2816
2817 let invalid = dispatch(
2819 &mut transport,
2820 &mut client,
2821 &mut effects,
2822 "activateSecurity",
2823 &json!({ "headers": { "authorization": 7 } }),
2824 )
2825 .expect_err("non-string header must fail");
2826 assert_eq!(invalid.code, "sync.invalid_request");
2827 let lifecycle = dispatch(
2828 &mut transport,
2829 &mut client,
2830 &mut effects,
2831 "securityLifecycle",
2832 &json!({}),
2833 )
2834 .expect("lifecycle");
2835 assert_eq!(lifecycle, json!({ "state": "preflight" }));
2836
2837 dispatch(
2839 &mut transport,
2840 &mut client,
2841 &mut effects,
2842 "activateSecurity",
2843 &json!({ "headers": { "authorization": "Bearer fresh" } }),
2844 )
2845 .expect("activation with fresh headers");
2846 let lifecycle = dispatch(
2847 &mut transport,
2848 &mut client,
2849 &mut effects,
2850 "securityLifecycle",
2851 &json!({}),
2852 )
2853 .expect("lifecycle");
2854 assert_eq!(lifecycle, json!({ "state": "active" }));
2855 }
2856
2857 #[test]
2858 fn parse_headers_reads_the_full_replacement_set() {
2859 let parsed = parse_headers(&json!({
2860 "authorization": "Bearer fresh",
2861 "x-tenant": "t1"
2862 }))
2863 .expect("valid headers parse");
2864 assert_eq!(
2865 parsed,
2866 vec![
2867 ("authorization".to_owned(), "Bearer fresh".to_owned()),
2868 ("x-tenant".to_owned(), "t1".to_owned())
2869 ]
2870 );
2871 assert!(parse_headers(&json!(["authorization"])).is_err());
2872 assert!(parse_headers(&json!({ "authorization": null })).is_err());
2873 }
2874
2875 #[test]
2876 fn set_headers_validates_replacement_and_respects_preflight() {
2877 let mut transport = NoNetwork::default();
2878 let mut client: Option<SyncClient> = None;
2879 let mut effects = CreateEffects::default();
2880 dispatch(
2881 &mut transport,
2882 &mut client,
2883 &mut effects,
2884 "create",
2885 &json!({ "schema": schema() }),
2886 )
2887 .expect("create");
2888 dispatch(
2889 &mut transport,
2890 &mut client,
2891 &mut effects,
2892 "setHeaders",
2893 &json!({ "headers": { "authorization": "Bearer fresh" } }),
2894 )
2895 .expect("valid replacement");
2896 let invalid = dispatch(
2897 &mut transport,
2898 &mut client,
2899 &mut effects,
2900 "setHeaders",
2901 &json!({ "headers": { "authorization": 7 } }),
2902 )
2903 .expect_err("invalid replacement");
2904 assert_eq!(invalid.code, "sync.invalid_request");
2905
2906 dispatch(
2907 &mut transport,
2908 &mut client,
2909 &mut effects,
2910 "beginSecurityPreflight",
2911 &json!({}),
2912 )
2913 .expect("preflight");
2914 let gated = dispatch(
2915 &mut transport,
2916 &mut client,
2917 &mut effects,
2918 "setHeaders",
2919 &json!({ "headers": {} }),
2920 )
2921 .expect_err("direct rotation must respect preflight");
2922 assert_eq!(gated.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2923 }
2924
2925 fn schema_v2() -> Value {
2926 json!({
2927 "version": 2,
2928 "tables": [{
2929 "name": "todos",
2930 "columns": [
2931 { "name": "id", "type": "string", "nullable": false },
2932 { "name": "list_id", "type": "string", "nullable": false },
2933 { "name": "note", "type": "string", "nullable": true }
2934 ],
2935 "primaryKey": "id",
2936 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2937 }]
2938 })
2939 }
2940
2941 fn previous_version_enabled() -> Value {
2942 json!({ "enabled": true })
2943 }
2944
2945 fn container_path(db_path: &str) -> String {
2946 format!("{db_path}.prev-context")
2947 }
2948
2949 #[test]
2950 fn previous_version_config_parser_matches_ts_keys_and_defaults() {
2951 assert_eq!(parse_previous_version_context(None).expect("absent"), None);
2952 let defaults = parse_previous_version_context(Some(&json!({})))
2953 .expect("empty")
2954 .expect("present");
2955 assert!(!defaults.enabled);
2956 assert_eq!(defaults.max_bytes, 8 * 1024 * 1024);
2957 assert_eq!(defaults.max_rows, 20_000);
2958 assert_eq!(defaults.max_tables, 32);
2959 assert_eq!(defaults.max_row_bytes, 1024 * 1024);
2960 assert_eq!(defaults.max_age_ms, 24 * 60 * 60 * 1000);
2961
2962 let configured = parse_previous_version_context(Some(&json!({
2963 "enabled": true,
2964 "maxBytes": 1024,
2965 "maxRows": 3,
2966 "maxTables": 2,
2967 "maxRowBytes": 5,
2968 "maxAgeMs": 7
2969 })))
2970 .expect("configured")
2971 .expect("present");
2972 assert!(configured.enabled);
2973 assert_eq!(configured.max_bytes, 1024);
2974 assert_eq!(configured.max_rows, 3);
2975 assert_eq!(configured.max_tables, 2);
2976 assert_eq!(configured.max_row_bytes, 5);
2977 assert_eq!(configured.max_age_ms, 7);
2978
2979 for (value, expected) in [
2981 (json!([]), "must be an object"),
2982 (json!({ "enabled": "yes" }), "enabled must be a boolean"),
2983 (
2984 json!({ "maxBytes": 0 }),
2985 "maxBytes must be a positive safe integer",
2986 ),
2987 (
2988 json!({ "maxRows": -1 }),
2989 "maxRows must be a positive safe integer",
2990 ),
2991 (
2992 json!({ "maxTables": 1.5 }),
2993 "maxTables must be a positive safe integer",
2994 ),
2995 (
2996 json!({ "maxRowBytes": "1" }),
2997 "maxRowBytes must be a positive safe integer",
2998 ),
2999 (
3000 json!({ "maxAgeMs": 0 }),
3001 "maxAgeMs must be a positive safe integer",
3002 ),
3003 ] {
3004 let error = parse_previous_version_context(Some(&value)).expect_err("must fail");
3005 assert_eq!(error.code, "sync.invalid_request", "{value}");
3006 assert!(
3007 error.message.contains(expected),
3008 "{value}: {}",
3009 error.message
3010 );
3011 }
3012 }
3013
3014 #[test]
3015 fn invalid_previous_version_config_fails_create_loudly() {
3016 let mut transport = NoNetwork::default();
3017 let mut client: Option<SyncClient> = None;
3018 let mut effects = CreateEffects::default();
3019 let error = dispatch(
3020 &mut transport,
3021 &mut client,
3022 &mut effects,
3023 "create",
3024 &json!({ "schema": schema(), "previousVersionContext": { "enabled": true, "maxRows": 0 } }),
3025 )
3026 .expect_err("invalid config must fail the create");
3027 assert_eq!(error.code, "sync.invalid_request");
3028 assert!(error.message.contains("maxRows"), "{}", error.message);
3029 assert!(client.is_none(), "no client may be installed");
3030 }
3031
3032 #[test]
3033 fn previous_version_snapshot_reports_not_configured_by_default() {
3034 let mut transport = NoNetwork::default();
3035 let mut client: Option<SyncClient> = None;
3036 let mut effects = CreateEffects::default();
3037 dispatch(
3038 &mut transport,
3039 &mut client,
3040 &mut effects,
3041 "create",
3042 &json!({ "schema": schema() }),
3043 )
3044 .expect("create");
3045 let snapshot = dispatch(
3046 &mut transport,
3047 &mut client,
3048 &mut effects,
3049 "previousVersionSnapshot",
3050 &json!({ "table": "todos" }),
3051 )
3052 .expect("snapshot");
3053 assert_eq!(snapshot["state"], json!("previousVersion"));
3054 assert_eq!(snapshot["available"], json!(false));
3055 assert_eq!(snapshot["reason"], json!("not-configured"));
3056 assert_eq!(snapshot["rows"], json!([]));
3057 assert_eq!(snapshot["truncated"], json!(false));
3058 assert_eq!(snapshot["currentVersion"], json!(1));
3059 let discarded = dispatch(
3060 &mut transport,
3061 &mut client,
3062 &mut effects,
3063 "previousVersionDiscard",
3064 &json!({}),
3065 )
3066 .expect("discard");
3067 assert_eq!(discarded, json!({ "present": false, "discarded": false }));
3068 assert_eq!(
3069 dispatch(
3070 &mut transport,
3071 &mut client,
3072 &mut effects,
3073 "previousVersionAudit",
3074 &json!({}),
3075 )
3076 .expect("audit"),
3077 Value::Null
3078 );
3079 }
3080
3081 #[test]
3082 fn previous_version_commands_round_trip_through_the_dispatcher() {
3083 let path = temp_db_path("previous-version");
3084 let mut transport = NoNetwork::default();
3085 let mut client: Option<SyncClient> = None;
3086 let mut effects = CreateEffects::default();
3087
3088 dispatch(
3090 &mut transport,
3091 &mut client,
3092 &mut effects,
3093 "create",
3094 &json!({
3095 "schema": schema(),
3096 "dbPath": path,
3097 "previousVersionContext": previous_version_enabled()
3098 }),
3099 )
3100 .expect("create v1");
3101 client
3102 .as_mut()
3103 .expect("client")
3104 .mutate(vec![Mutation::Upsert {
3105 table: "todos".to_owned(),
3106 values: serde_json::Map::from_iter([
3107 ("id".to_owned(), json!("t1")),
3108 ("list_id".to_owned(), json!("l1")),
3109 ]),
3110 base_version: None,
3111 }])
3112 .expect("seed a local row");
3113 dispatch(
3114 &mut transport,
3115 &mut client,
3116 &mut effects,
3117 "shutdown",
3118 &json!({}),
3119 )
3120 .expect("shutdown");
3121
3122 dispatch(
3124 &mut transport,
3125 &mut client,
3126 &mut effects,
3127 "create",
3128 &json!({
3129 "schema": schema_v2(),
3130 "dbPath": path,
3131 "previousVersionContext": previous_version_enabled()
3132 }),
3133 )
3134 .expect("create v2");
3135 assert!(
3136 std::path::Path::new(&container_path(&path)).exists(),
3137 "the bump must leave a container file"
3138 );
3139 let status = dispatch(
3140 &mut transport,
3141 &mut client,
3142 &mut effects,
3143 "statusSnapshot",
3144 &json!({}),
3145 )
3146 .expect("status");
3147 assert_eq!(
3148 status["previousVersionContext"]["present"],
3149 json!(true),
3150 "{status}"
3151 );
3152 assert!(
3153 status["previousVersionContext"]["createdAtMs"].is_i64(),
3154 "{status}"
3155 );
3156
3157 let snapshot = dispatch(
3158 &mut transport,
3159 &mut client,
3160 &mut effects,
3161 "previousVersionSnapshot",
3162 &json!({ "table": "todos" }),
3163 )
3164 .expect("snapshot");
3165 assert_eq!(snapshot["state"], json!("previousVersion"));
3166 assert_eq!(snapshot["available"], json!(true));
3167 assert_eq!(snapshot["previousVersion"], json!(1));
3168 assert_eq!(snapshot["currentVersion"], json!(2));
3169 assert_eq!(snapshot["truncated"], json!(false));
3170 assert_eq!(snapshot["rows"][0]["id"], json!("t1"));
3171 assert_eq!(snapshot["rows"][0]["list_id"], json!("l1"));
3172
3173 let unknown = dispatch(
3175 &mut transport,
3176 &mut client,
3177 &mut effects,
3178 "previousVersionSnapshot",
3179 &json!({ "table": "absent" }),
3180 )
3181 .expect_err("unknown previous table");
3182 assert_eq!(unknown.code, "sync.invalid_request");
3183 let bad_limit = dispatch(
3184 &mut transport,
3185 &mut client,
3186 &mut effects,
3187 "previousVersionSnapshot",
3188 &json!({ "table": "todos", "limit": 0 }),
3189 )
3190 .expect_err("bad limit");
3191 assert_eq!(bad_limit.code, "sync.invalid_request");
3192
3193 let audit = dispatch(
3196 &mut transport,
3197 &mut client,
3198 &mut effects,
3199 "previousVersionAudit",
3200 &json!({}),
3201 )
3202 .expect("audit");
3203 assert_eq!(audit["v"], json!(1));
3204 assert_eq!(audit["fromVersion"], json!(1));
3205 assert_eq!(audit["toVersion"], json!(2));
3206 assert_eq!(audit["pending"], json!(1));
3207 assert_eq!(audit["encodable"], json!(1));
3208 assert_eq!(audit["incompatible"], json!([]));
3209 assert!(audit.get("operations").is_none());
3210
3211 let discarded = dispatch(
3213 &mut transport,
3214 &mut client,
3215 &mut effects,
3216 "previousVersionDiscard",
3217 &json!({}),
3218 )
3219 .expect("discard");
3220 assert_eq!(discarded, json!({ "present": true, "discarded": true }));
3221 assert!(!std::path::Path::new(&container_path(&path)).exists());
3222 let again = dispatch(
3223 &mut transport,
3224 &mut client,
3225 &mut effects,
3226 "previousVersionDiscard",
3227 &json!({}),
3228 )
3229 .expect("idempotent discard");
3230 assert_eq!(again, json!({ "present": false, "discarded": false }));
3231
3232 let inspect = rusqlite::Connection::open(&path).expect("inspect replica");
3234 let remaining: i64 = inspect
3235 .query_row(
3236 "SELECT COUNT(*) FROM _syncular_meta WHERE key IN (?1, ?2)",
3237 rusqlite::params!["previousVersionContext", "previousVersionAudit"],
3238 |row| row.get(0),
3239 )
3240 .expect("count meta");
3241 assert_eq!(remaining, 0);
3242
3243 let _ = std::fs::remove_file(&path);
3244 }
3245
3246 #[test]
3247 fn previous_version_ttl_expires_at_read_through_the_dispatcher() {
3248 let path = temp_db_path("previous-version-ttl");
3249 let mut transport = NoNetwork::default();
3250 let mut client: Option<SyncClient> = None;
3251 let mut effects = CreateEffects::default();
3252 dispatch(
3253 &mut transport,
3254 &mut client,
3255 &mut effects,
3256 "create",
3257 &json!({
3258 "schema": schema(),
3259 "dbPath": path,
3260 "previousVersionContext": previous_version_enabled()
3261 }),
3262 )
3263 .expect("create v1");
3264 dispatch(
3265 &mut transport,
3266 &mut client,
3267 &mut effects,
3268 "shutdown",
3269 &json!({}),
3270 )
3271 .expect("shutdown");
3272
3273 let now_ms = std::time::SystemTime::now()
3277 .duration_since(std::time::UNIX_EPOCH)
3278 .expect("clock")
3279 .as_millis() as i64
3280 + 2_000;
3281 dispatch(
3282 &mut transport,
3283 &mut client,
3284 &mut effects,
3285 "create",
3286 &json!({
3287 "schema": schema_v2(),
3288 "dbPath": path,
3289 "nowMs": now_ms,
3290 "previousVersionContext": { "enabled": true, "maxAgeMs": 500 }
3291 }),
3292 )
3293 .expect("create v2");
3294 assert!(std::path::Path::new(&container_path(&path)).exists());
3295 let snapshot = dispatch(
3296 &mut transport,
3297 &mut client,
3298 &mut effects,
3299 "previousVersionSnapshot",
3300 &json!({ "table": "todos" }),
3301 )
3302 .expect("snapshot");
3303 assert_eq!(snapshot["available"], json!(false));
3304 assert_eq!(snapshot["reason"], json!("expired"));
3305 assert_eq!(snapshot["rows"], json!([]));
3306 assert!(!std::path::Path::new(&container_path(&path)).exists());
3308
3309 let _ = std::fs::remove_file(&path);
3310 }
3311}