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