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