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