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, ClientLimits, CommandEffects, CommitOutcomeQuery,
25 LocalDataPurgeInput, LocalDataRebootstrapInput, Mutation, QueryOwner, RealtimePolicy,
26 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
54pub type CommandError = (String, String);
56
57#[derive(Debug, Default, Clone)]
61pub struct CreateEffects {
62 pub signed_urls: bool,
65 pub security_preflight_pending: bool,
81}
82
83fn client_err(message: String) -> CommandError {
84 let code = message
85 .split_once(':')
86 .map(|(candidate, _)| candidate)
87 .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
88 .unwrap_or("client.failed");
89 (code.to_owned(), message)
90}
91
92fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
93 client
94 .as_mut()
95 .ok_or_else(|| client_err("no client instance created".to_owned()))
96}
97
98pub fn parse_previous_version_context(
103 value: Option<&Value>,
104) -> Result<Option<PreviousVersionContextConfig>, CommandError> {
105 let Some(value) = value else {
106 return Ok(None);
107 };
108 let object = value.as_object().ok_or_else(|| {
109 client_err("sync.invalid_request: previousVersionContext must be an object".to_owned())
110 })?;
111 let enabled = match object.get("enabled") {
112 None => false,
113 Some(Value::Bool(enabled)) => *enabled,
114 Some(_) => {
115 return Err(client_err(
116 "sync.invalid_request: previousVersionContext.enabled must be a boolean".to_owned(),
117 ))
118 }
119 };
120 let mut config = PreviousVersionContextConfig {
121 enabled,
122 ..PreviousVersionContextConfig::default()
123 };
124 for (key, slot) in [
125 ("maxBytes", &mut config.max_bytes),
126 ("maxRows", &mut config.max_rows),
127 ("maxTables", &mut config.max_tables),
128 ("maxRowBytes", &mut config.max_row_bytes),
129 ("maxAgeMs", &mut config.max_age_ms),
130 ] {
131 if let Some(raw) = object.get(key) {
132 *slot = raw.as_i64().ok_or_else(|| {
133 client_err(format!(
134 "sync.invalid_request: previousVersionContext.{key} must be a positive safe integer"
135 ))
136 })?;
137 }
138 }
139 config.validate().map_err(client_err)?;
140 Ok(Some(config))
141}
142
143fn parse_previous_version_snapshot_spec(
145 params: &Value,
146) -> Result<PreviousVersionReadSpec, CommandError> {
147 let table = params.get("table").and_then(Value::as_str).ok_or_else(|| {
148 client_err("sync.invalid_request: previousVersionSnapshot missing table".to_owned())
149 })?;
150 let row_ids = match params.get("rowIds") {
151 None | Some(Value::Null) => Vec::new(),
152 Some(Value::Array(ids)) => ids
153 .iter()
154 .map(|id| match id {
155 Value::String(id) => Ok(id.clone()),
156 Value::Number(number) => Ok(number.to_string()),
157 Value::Bool(boolean) => Ok(boolean.to_string()),
158 _ => Err(client_err(
159 "sync.invalid_request: previousVersionSnapshot rowIds must be strings"
160 .to_owned(),
161 )),
162 })
163 .collect::<Result<Vec<_>, _>>()?,
164 Some(_) => {
165 return Err(client_err(
166 "sync.invalid_request: previousVersionSnapshot rowIds must be an array".to_owned(),
167 ))
168 }
169 };
170 let limit = match params.get("limit") {
171 None | Some(Value::Null) => None,
172 Some(value) => Some(value.as_i64().ok_or_else(|| {
173 client_err(
174 "sync.invalid_request: previousVersionSnapshot limit must be a positive number"
175 .to_owned(),
176 )
177 })?),
178 };
179 Ok(PreviousVersionReadSpec {
180 table: table.to_owned(),
181 row_ids,
182 limit,
183 })
184}
185
186pub fn parse_limits(value: Option<&Value>) -> ClientLimits {
187 let mut limits = ClientLimits::default();
188 let Some(object) = value.and_then(Value::as_object) else {
189 return limits;
190 };
191 limits.limit_commits = object
192 .get("limitCommits")
193 .and_then(Value::as_i64)
194 .map(|v| v as i32);
195 limits.limit_snapshot_rows = object
196 .get("limitSnapshotRows")
197 .and_then(Value::as_i64)
198 .map(|v| v as i32);
199 limits.max_snapshot_pages = object
200 .get("maxSnapshotPages")
201 .and_then(Value::as_i64)
202 .map(|v| v as i32);
203 limits.accept = object
204 .get("accept")
205 .and_then(Value::as_u64)
206 .map(|v| v as u8);
207 limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
208 limits.outcome_retention_max_entries = object
209 .get("outcomeRetentionMaxEntries")
210 .and_then(Value::as_u64)
211 .map(|value| value as usize);
212 limits
213}
214
215fn parse_realtime_policy(value: Option<&Value>) -> Result<RealtimePolicy, CommandError> {
217 match value {
218 None => Ok(RealtimePolicy::Optional),
219 Some(Value::String(text)) => match text.as_str() {
220 "required" => Ok(RealtimePolicy::Required),
221 "optional" => Ok(RealtimePolicy::Optional),
222 "off" => Ok(RealtimePolicy::Off),
223 other => Err(client_err(format!(
224 "sync.invalid_request: realtimePolicy must be 'required', 'optional', or 'off', got {other:?}"
225 ))),
226 },
227 Some(_) => Err(client_err(
228 "sync.invalid_request: realtimePolicy must be a string".to_owned(),
229 )),
230 }
231}
232
233pub fn parse_encryption(
237 value: &Value,
238) -> Result<syncular_client::values::EncryptionConfig, String> {
239 let mut config = syncular_client::values::EncryptionConfig::default();
240 let Some(keys) = value.get("keys").and_then(Value::as_object) else {
241 return Ok(config);
242 };
243 for (key_id, key_val) in keys {
244 let bytes =
245 value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
246 if bytes.len() != 32 {
247 return Err(format!(
248 "encryption key {key_id:?} must be 32 bytes, got {}",
249 bytes.len()
250 ));
251 }
252 config.keys.insert(key_id.clone(), bytes);
253 }
254 if let Some(columns) = value.get("keyIdColumns") {
255 let columns = columns
256 .as_object()
257 .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
258 for (table, column) in columns {
259 let column = column.as_str().ok_or_else(|| {
260 format!("encryption keyIdColumns entry for {table:?} must be a string")
261 })?;
262 if column.is_empty() {
263 return Err(format!(
264 "encryption keyIdColumns entry for {table:?} must not be empty"
265 ));
266 }
267 config
268 .key_id_columns
269 .insert(table.clone(), column.to_owned());
270 }
271 }
272 Ok(config)
273}
274
275pub fn parse_headers(value: &Value) -> Result<Vec<(String, String)>, String> {
280 let object = value.as_object().ok_or_else(|| {
281 "sync.invalid_request: headers must be an object of string values".to_owned()
282 })?;
283 let mut headers = Vec::with_capacity(object.len());
284 for (name, value) in object {
285 let value = value
286 .as_str()
287 .ok_or_else(|| format!("sync.invalid_request: header {name:?} must be a string"))?;
288 headers.push((name.clone(), value.to_owned()));
289 }
290 Ok(headers)
291}
292
293pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
294 let list = value
295 .and_then(Value::as_array)
296 .ok_or_else(|| "mutations must be a list".to_owned())?;
297 let mut out = Vec::with_capacity(list.len());
298 for entry in list {
299 let op = entry
300 .get("op")
301 .and_then(Value::as_str)
302 .ok_or_else(|| "mutation missing op".to_owned())?;
303 let table = entry
304 .get("table")
305 .and_then(Value::as_str)
306 .ok_or_else(|| "mutation missing table".to_owned())?
307 .to_owned();
308 let base_version = entry.get("baseVersion").and_then(Value::as_i64);
309 match op {
310 "upsert" | "patch" => {
311 let mut values = entry
312 .get("values")
313 .and_then(Value::as_object)
314 .cloned()
315 .ok_or_else(|| "upsert missing values".to_owned())?;
316 decode_bigint_members(&mut values)?;
317 out.push(if op == "patch" {
318 Mutation::Patch {
319 table,
320 values,
321 base_version,
322 }
323 } else {
324 Mutation::Upsert {
325 table,
326 values,
327 base_version,
328 }
329 });
330 }
331 "delete" => {
332 let row_id = entry
333 .get("rowId")
334 .and_then(Value::as_str)
335 .ok_or_else(|| "delete missing rowId".to_owned())?
336 .to_owned();
337 out.push(Mutation::Delete {
338 table,
339 row_id,
340 base_version,
341 });
342 }
343 other => return Err(format!("unknown mutation op {other:?}")),
344 }
345 }
346 Ok(out)
347}
348
349fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
350 for value in values.values_mut() {
351 let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
352 continue;
353 };
354 let integer = decimal
355 .parse::<i64>()
356 .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
357 *value = Value::from(integer);
358 }
359 Ok(())
360}
361
362fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
363 match value {
364 Some(v) => syncular_client::values::json_to_scope_map(v),
365 None => Ok(Vec::new()),
366 }
367}
368
369fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
372 let object = value
373 .and_then(Value::as_object)
374 .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
375 let table = object
376 .get("table")
377 .and_then(Value::as_str)
378 .ok_or_else(|| "window base missing table".to_owned())?
379 .to_owned();
380 let variable = object
381 .get("variable")
382 .and_then(Value::as_str)
383 .ok_or_else(|| "window base missing variable".to_owned())?
384 .to_owned();
385 let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
386 let params = object
387 .get("params")
388 .and_then(Value::as_str)
389 .map(str::to_owned);
390 Ok(WindowBase {
391 table,
392 variable,
393 fixed_scopes,
394 params,
395 })
396}
397
398#[cfg(feature = "crdt-yjs")]
402fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
403 let table = params
404 .get("table")
405 .and_then(Value::as_str)
406 .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
407 .to_owned();
408 let row_id = params
409 .get("rowId")
410 .and_then(Value::as_str)
411 .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
412 .to_owned();
413 let column = params
414 .get("column")
415 .and_then(Value::as_str)
416 .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
417 .to_owned();
418 let name = params
419 .get("name")
420 .and_then(Value::as_str)
421 .unwrap_or("text")
422 .to_owned();
423 Ok((table, row_id, column, name))
424}
425
426pub fn dispatch<T: Transport>(
436 transport: &mut T,
437 client: &mut Option<SyncClient>,
438 effects: &mut CreateEffects,
439 method: &str,
440 params: &Value,
441) -> Result<Value, CommandError> {
442 if method == "create" {
443 let requests_preflight = params
450 .get("securityPreflight")
451 .and_then(Value::as_bool)
452 .unwrap_or(false);
453 let preflight_engaged = client.as_ref().map_or(
454 effects.security_preflight_pending,
455 SyncClient::security_preflight,
456 );
457 if preflight_engaged && !requests_preflight {
458 return Err((
459 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
460 "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
461 ));
462 }
463 } else {
464 let allowed_during_preflight = matches!(
465 method,
466 "authoritySnapshot"
467 | "securityLifecycle"
468 | "beginSecurityPreflight"
469 | "activateSecurity"
470 | "setTransportEnabled"
471 | "purgeLocalData"
472 | "previousVersionDiscard"
473 | "localRevision"
474 | "statusSnapshot"
475 | "shutdown"
476 );
477 if client
478 .as_ref()
479 .is_some_and(|running| running.security_preflight())
480 && !allowed_during_preflight
481 {
482 return Err((
483 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
484 "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data".to_owned(),
485 ));
486 }
487 }
488 let mut result = match method {
489 "create" => {
490 let client_id = params
491 .get("clientId")
492 .and_then(Value::as_str)
493 .map(str::to_owned);
494 let schema = params
495 .get("schema")
496 .ok_or_else(|| client_err("create missing schema".to_owned()))?;
497 let limits = parse_limits(params.get("limits"));
498 let previous_version_context =
502 parse_previous_version_context(params.get("previousVersionContext"))?;
503 let mut limits = limits;
504 limits.previous_version_context = previous_version_context;
505 if let Some(reads) = params.get("authorityReads") {
506 limits.authority_reads = serde_json::from_value(reads.clone()).map_err(|_| {
507 client_err(
508 "client.authority_read_forbidden: invalid authority declaration".into(),
509 )
510 })?;
511 }
512 let mut instance = match params.get("dbPath").and_then(Value::as_str) {
516 Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
517 .map_err(client_err)?,
518 None => {
519 SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
520 }
521 };
522 instance
523 .set_retain_failed_commits(
524 params
525 .get("retainFailedCommits")
526 .and_then(Value::as_bool)
527 .unwrap_or(false),
528 )
529 .map_err(client_err)?;
530 let realtime_policy = parse_realtime_policy(params.get("realtimePolicy"))?;
533 instance
534 .set_realtime_policy(realtime_policy)
535 .map_err(client_err)?;
536 if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
538 instance.set_now_ms(now_ms);
539 }
540 effects.signed_urls = params
543 .get("signedUrls")
544 .and_then(Value::as_bool)
545 .unwrap_or(false);
546 let security_preflight = params
550 .get("securityPreflight")
551 .and_then(Value::as_bool)
552 .unwrap_or(false);
553 if instance.security_preflight() && !security_preflight {
561 return Err((
562 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
563 "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
564 ));
565 }
566 if security_preflight && params.get("encryption").is_some() {
567 return Err(client_err(
568 "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
569 .to_owned(),
570 ));
571 }
572 if let Some(enc) = params.get("encryption") {
573 let config = parse_encryption(enc).map_err(client_err)?;
574 instance.set_encryption(config);
575 }
576 if security_preflight {
577 instance.begin_security_preflight();
578 }
579 if let Some(enabled) = params.get("transportEnabled") {
580 let enabled = enabled.as_bool().ok_or_else(|| {
581 client_err("sync.invalid_request: transportEnabled must be a boolean".into())
582 })?;
583 instance.set_transport_enabled(transport, enabled);
584 }
585 effects.security_preflight_pending = security_preflight;
586 *client = Some(instance);
587 Ok(json!({}))
588 }
589 "securityLifecycle" => Ok(json!({
590 "state": need_client(client)?.security_lifecycle()
591 })),
592 "beginSecurityPreflight" => {
593 let running = need_client(client)?;
594 running.disconnect_realtime(transport);
595 running.begin_security_preflight();
596 effects.security_preflight_pending = true;
597 Ok(json!({}))
598 }
599 "authoritySnapshot" => {
600 if !params.as_object().is_some_and(|p| p.is_empty()) {
601 return Err(client_err(
602 "client.authority_read_forbidden: authoritySnapshot accepts no arguments"
603 .into(),
604 ));
605 }
606 client
607 .as_ref()
608 .ok_or_else(|| client_err("client.closed: no authority client is open".into()))?
609 .authority_snapshot()
610 .map_err(client_err)
611 }
612 "activateSecurity" => {
613 let encryption = match params.get("encryption") {
614 Some(value) => parse_encryption(value).map_err(client_err)?,
615 None => syncular_client::values::EncryptionConfig::default(),
616 };
617 if let Some(headers) = params.get("headers") {
623 parse_headers(headers).map_err(client_err)?;
624 }
625 need_client(client)?
626 .activate_security(encryption)
627 .map_err(client_err)?;
628 effects.security_preflight_pending = false;
629 Ok(json!({}))
630 }
631 "setTransportEnabled" => {
632 let enabled = params
633 .get("enabled")
634 .and_then(Value::as_bool)
635 .ok_or_else(|| {
636 client_err(
637 "sync.invalid_request: setTransportEnabled.enabled must be a boolean"
638 .into(),
639 )
640 })?;
641 need_client(client)?.set_transport_enabled(transport, enabled);
642 Ok(json!({}))
643 }
644 "setHeaders" => {
645 let headers = params
646 .get("headers")
647 .ok_or_else(|| client_err("setHeaders missing headers".to_owned()))?;
648 parse_headers(headers).map_err(client_err)?;
649 Ok(json!({}))
650 }
651 "shutdown" => {
652 if let Some(running) = client.as_mut() {
653 effects.security_preflight_pending = running.security_preflight();
658 running.disconnect_realtime(transport);
659 running.seal_security_on_teardown();
663 }
664 *client = None;
665 Ok(json!({}))
666 }
667 "subscribe" => {
668 let id = params
669 .get("id")
670 .and_then(Value::as_str)
671 .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
672 .to_owned();
673 let table = params
674 .get("table")
675 .and_then(Value::as_str)
676 .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
677 .to_owned();
678 let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
679 let sub_params = params
680 .get("params")
681 .and_then(Value::as_str)
682 .map(str::to_owned);
683 need_client(client)?
684 .subscribe(id, table, scopes, sub_params)
685 .map_err(client_err)?;
686 Ok(json!({}))
687 }
688 "unsubscribe" => {
689 let id = params
690 .get("id")
691 .and_then(Value::as_str)
692 .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
693 need_client(client)?.unsubscribe(id);
694 Ok(json!({}))
695 }
696 "setWindow" => {
697 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
698 let units: Vec<String> = params
699 .get("units")
700 .and_then(Value::as_array)
701 .map(|arr| {
702 arr.iter()
703 .filter_map(|v| v.as_str().map(str::to_owned))
704 .collect()
705 })
706 .unwrap_or_default();
707 let command_effects = need_client(client)?
708 .set_window(&base, &units)
709 .map_err(client_err)?;
710 Ok(json!({ "effects": command_effects }))
711 }
712 "windowState" => {
713 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
714 let state = need_client(client)?.window_state(&base);
715 Ok(json!({ "units": state.units, "pending": state.pending }))
716 }
717 "timeWindowSugar" => {
718 let created_at_ms = params
719 .get("createdAtMs")
720 .and_then(Value::as_i64)
721 .ok_or_else(|| client_err("timeWindowSugar missing createdAtMs".to_owned()))?;
722 let count = params
723 .get("count")
724 .and_then(Value::as_u64)
725 .and_then(|value| usize::try_from(value).ok())
726 .ok_or_else(|| client_err("timeWindowSugar missing count".to_owned()))?;
727 let now_ms = params
728 .get("nowMs")
729 .and_then(Value::as_i64)
730 .ok_or_else(|| client_err("timeWindowSugar missing nowMs".to_owned()))?;
731 if params.get("unit").and_then(Value::as_str) != Some("month") {
732 return Err(client_err(
733 "sync.invalid_request: timeWindowSugar requires the month unit".to_owned(),
734 ));
735 }
736 let unit = syncular_client::TimeBucketUnit::Month;
737 Ok(json!({
738 "bucket": syncular_client::creation_time_bucket(created_at_ms, unit)
739 .map_err(client_err)?,
740 "units": syncular_client::last(count, unit, now_ms).map_err(client_err)?,
741 }))
742 }
743 "mutate" => {
744 let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
745 let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
746 Ok(json!({
747 "clientCommitId": id,
748 "effects": CommandEffects::interactive()
749 }))
750 }
751 "patch" => {
752 let table = params
753 .get("table")
754 .and_then(Value::as_str)
755 .ok_or_else(|| client_err("patch missing table".to_owned()))?;
756 let row_id = params
757 .get("rowId")
758 .and_then(Value::as_str)
759 .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
760 let mut partial = params
761 .get("partial")
762 .and_then(Value::as_object)
763 .cloned()
764 .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
765 decode_bigint_members(&mut partial).map_err(client_err)?;
766 let base_version = params.get("baseVersion").and_then(Value::as_i64);
767 let id = need_client(client)?
768 .patch(table, row_id, partial, base_version)
769 .map_err(client_err)?;
770 Ok(json!({
771 "clientCommitId": id,
772 "effects": CommandEffects::interactive()
773 }))
774 }
775 "purgeLocalData" => {
776 let input: LocalDataPurgeInput =
777 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
778 client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
779 })?)
780 .map_err(|error| {
781 client_err(format!(
782 "sync.invalid_request: invalid purgeLocalData input: {error}"
783 ))
784 })?;
785 let result = need_client(client)?
786 .purge_local_data(&input)
787 .map_err(client_err)?;
788 serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
789 }
790 "rebootstrapLocalData" => {
791 let input: LocalDataRebootstrapInput =
792 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
793 client_err(
794 "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
795 )
796 })?)
797 .map_err(|error| {
798 client_err(format!(
799 "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
800 ))
801 })?;
802 let result = need_client(client)?
803 .rebootstrap_local_data(&input)
804 .map_err(client_err)?;
805 Ok(json!({
806 "alreadyApplied": result.already_applied,
807 "retainedCommits": result.retained_commits,
808 "resetSubscriptions": result.reset_subscriptions,
809 "effects": if result.already_applied {
810 CommandEffects::none()
811 } else {
812 CommandEffects::interactive()
813 }
814 }))
815 }
816 "sync" => {
817 let outcome = need_client(client)?.sync(transport);
818 Ok(outcome.to_json())
819 }
820 "syncUntilIdle" => {
821 let max_rounds = params
822 .get("maxRounds")
823 .and_then(Value::as_u64)
824 .map(|v| v as u32);
825 let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
826 Ok(outcome.to_json())
827 }
828 "readRows" => {
829 let table = params
830 .get("table")
831 .and_then(Value::as_str)
832 .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
833 let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
834 Ok(json!({ "rows": rows }))
835 }
836 "query" => {
837 let sql = params
841 .get("sql")
842 .and_then(Value::as_str)
843 .ok_or_else(|| client_err("query missing sql".to_owned()))?;
844 let bind = match params.get("params") {
845 Some(Value::Array(list)) => list.as_slice(),
846 None | Some(Value::Null) => &[],
847 Some(_) => return Err(client_err("query params must be a list".to_owned())),
848 };
849 let rows = need_client(client)?.query(sql, bind).map_err(client_err)?;
850 Ok(Value::Object(serde_json::Map::from_iter([(
851 "rows".to_owned(),
852 Value::Array(rows.into_iter().map(Value::Object).collect()),
853 )])))
854 }
855 "querySnapshot" => {
856 let sql = params
857 .get("sql")
858 .and_then(Value::as_str)
859 .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
860 let bind = match params.get("params") {
861 Some(Value::Array(list)) => list.as_slice(),
862 None | Some(Value::Null) => &[],
863 Some(_) => {
864 return Err(client_err("querySnapshot params must be a list".to_owned()))
865 }
866 };
867 let mut coverage = Vec::new();
868 for entry in params
869 .get("coverage")
870 .and_then(Value::as_array)
871 .into_iter()
872 .flatten()
873 {
874 let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
875 let units = entry
876 .get("units")
877 .and_then(Value::as_array)
878 .map(|values| {
879 values
880 .iter()
881 .filter_map(|value| value.as_str().map(str::to_owned))
882 .collect()
883 })
884 .unwrap_or_default();
885 coverage.push(WindowCoverage { base, units });
886 }
887 let owner = match params.get("owner") {
889 None | Some(Value::Null) => None,
890 Some(value) => {
891 let id = value
892 .get("id")
893 .and_then(Value::as_str)
894 .filter(|id| !id.is_empty());
895 let tables = value
896 .get("tables")
897 .and_then(Value::as_array)
898 .and_then(|list| {
899 list.iter()
900 .map(Value::as_str)
901 .collect::<Option<Vec<&str>>>()
902 });
903 match (id, tables) {
904 (Some(id), Some(tables)) => Some((id, tables)),
905 _ => {
906 return Err(client_err(
907 "sync.invalid_request: querySnapshot owner must be {id: non-empty string, tables: string[]}"
908 .to_owned(),
909 ))
910 }
911 }
912 }
913 };
914 let mut snapshot = need_client(client)?
915 .query_snapshot(
916 sql,
917 bind,
918 &coverage,
919 owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
920 )
921 .map_err(client_err)?;
922 let rows = std::mem::take(&mut snapshot.rows);
923 let mut result = serde_json::to_value(snapshot).expect("snapshot serializes");
924 result["rows"] = Value::Array(rows.into_iter().map(Value::Object).collect());
925 Ok(result)
926 }
927 "localRevision" => Ok(json!({
928 "revision": need_client(client)?.local_revision().to_string()
929 })),
930 "previousVersionSnapshot" | "previous_version_snapshot" => {
931 let spec = parse_previous_version_snapshot_spec(params)?;
932 let snapshot = need_client(client)?
933 .previous_version_snapshot(&spec)
934 .map_err(client_err)?;
935 serde_json::to_value(snapshot).map_err(|error| client_err(error.to_string()))
936 }
937 "previousVersionAudit" | "previous_version_audit" => {
938 Ok(match need_client(client)?.previous_version_audit() {
939 Some(audit) => {
940 serde_json::to_value(audit).map_err(|error| client_err(error.to_string()))?
941 }
942 None => Value::Null,
943 })
944 }
945 "previousVersionDiscard" | "previous_version_discard" => {
946 let outcome = need_client(client)?.previous_version_discard();
947 Ok(json!({
948 "present": outcome.present,
949 "discarded": outcome.discarded,
950 }))
951 }
952 "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
953 .expect("status serializes")),
954 "diagnosticsSnapshot" => {
955 let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
956 .map_err(|error| {
957 client_err(format!(
958 "sync.invalid_request: invalid diagnostics request: {error}"
959 ))
960 })?;
961 let snapshot = need_client(client)?
962 .diagnostics_snapshot(&request)
963 .map_err(client_err)?;
964 Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
965 }
966 "progressSnapshot" => Ok(
971 serde_json::to_value(need_client(client)?.progress().snapshot())
972 .expect("progress JSON"),
973 ),
974 "drainChangeBatches" => Ok(json!({
975 "batches": need_client(client)?.drain_change_batches()
976 })),
977 "drainSyncIntents" => Ok(json!({
978 "intents": need_client(client)?.drain_sync_intents()
979 })),
980 #[cfg(feature = "crdt-yjs")]
986 "crdtText" => {
987 let (table, row_id, column, name) = crdt_target(params)?;
988 let text = need_client(client)?
989 .crdt_text(&table, &row_id, &column, &name)
990 .map_err(client_err)?;
991 Ok(json!({ "text": text }))
992 }
993 #[cfg(feature = "crdt-yjs")]
994 "crdtInsertText" => {
995 let (table, row_id, column, name) = crdt_target(params)?;
996 let index = params
997 .get("index")
998 .and_then(Value::as_u64)
999 .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
1000 as u32;
1001 let value = params
1002 .get("value")
1003 .and_then(Value::as_str)
1004 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
1005 let id = need_client(client)?
1006 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
1007 .map_err(client_err)?;
1008 Ok(json!({ "clientCommitId": id }))
1009 }
1010 #[cfg(feature = "crdt-yjs")]
1011 "crdtDeleteText" => {
1012 let (table, row_id, column, name) = crdt_target(params)?;
1013 let index = params
1014 .get("index")
1015 .and_then(Value::as_u64)
1016 .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
1017 as u32;
1018 let len = params
1019 .get("len")
1020 .and_then(Value::as_u64)
1021 .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
1022 as u32;
1023 let id = need_client(client)?
1024 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
1025 .map_err(client_err)?;
1026 Ok(json!({ "clientCommitId": id }))
1027 }
1028 #[cfg(feature = "crdt-yjs")]
1029 "crdtApplyUpdate" => {
1030 let (table, row_id, column, _name) = crdt_target(params)?;
1031 let update = value_bytes(params.get("update")).map_err(client_err)?;
1032 let id = need_client(client)?
1033 .crdt_apply_update(&table, &row_id, &column, &update)
1034 .map_err(client_err)?;
1035 Ok(json!({ "clientCommitId": id }))
1036 }
1037 #[cfg(not(feature = "crdt-yjs"))]
1038 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
1039 "client.crdt_unavailable".to_owned(),
1040 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
1041 )),
1042
1043 "uploadBlob" => {
1044 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1045 let media_type = params
1046 .get("mediaType")
1047 .and_then(Value::as_str)
1048 .map(str::to_owned);
1049 let name = params
1050 .get("name")
1051 .and_then(Value::as_str)
1052 .map(str::to_owned);
1053 let reference = need_client(client)?
1054 .upload_blob(&bytes, media_type, name)
1055 .map_err(client_err)?;
1056 Ok(json!({ "ref": reference }))
1057 }
1058 "fetchBlob" => {
1059 let blob = params
1060 .get("blob")
1061 .and_then(Value::as_str)
1062 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
1063 .to_owned();
1064 let blob = need_client(client)?.fetch_blob_bytes(transport, &blob)?;
1068 let mut value = serde_json::Map::new();
1069 value.insert("blobId".to_owned(), Value::from(blob.blob_id));
1070 value.insert("byteLength".to_owned(), Value::from(blob.byte_length));
1071 value.insert("bytes".to_owned(), bytes_value(&blob.bytes));
1072 if let Some(media_type) = blob.media_type {
1073 value.insert("mediaType".to_owned(), Value::from(media_type));
1074 }
1075 Ok(Value::Object(serde_json::Map::from_iter([(
1076 "blob".to_owned(),
1077 Value::Object(value),
1078 )])))
1079 }
1080 "conflicts" => {
1081 let conflicts = need_client(client)?.conflicts().to_vec();
1082 Ok(json!({ "conflicts": conflicts }))
1083 }
1084 "rejections" => {
1085 let rejections = need_client(client)?.rejections().to_vec();
1086 Ok(json!({ "rejections": rejections }))
1087 }
1088 "commitOutcome" => {
1089 let client_commit_id = params
1090 .get("clientCommitId")
1091 .and_then(Value::as_str)
1092 .ok_or_else(|| {
1093 client_err(
1094 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
1095 )
1096 })?;
1097 let outcome = need_client(client)?
1098 .commit_outcome(client_commit_id)
1099 .map_err(client_err)?;
1100 Ok(json!({ "outcome": outcome }))
1101 }
1102 "commitOutcomes" => {
1103 let query = serde_json::from_value::<CommitOutcomeQuery>(
1104 params.get("query").cloned().unwrap_or_else(|| json!({})),
1105 )
1106 .map_err(|error| {
1107 client_err(format!(
1108 "sync.invalid_request: invalid commit outcome query: {error}"
1109 ))
1110 })?;
1111 let outcomes = need_client(client)?
1112 .commit_outcomes(query)
1113 .map_err(client_err)?;
1114 Ok(json!({ "outcomes": outcomes }))
1115 }
1116 "resolveCommitOutcome" => {
1117 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
1118 params.get("input").cloned().ok_or_else(|| {
1119 client_err(
1120 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
1121 )
1122 })?,
1123 )
1124 .map_err(|error| {
1125 client_err(format!(
1126 "sync.invalid_request: invalid outcome resolution: {error}"
1127 ))
1128 })?;
1129 let outcome = need_client(client)?
1130 .resolve_commit_outcome(input)
1131 .map_err(client_err)?;
1132 Ok(json!({ "outcome": outcome }))
1133 }
1134 "pendingCommitIds" => {
1135 let ids = need_client(client)?.pending_commit_ids();
1136 Ok(json!({ "ids": ids }))
1137 }
1138 "pendingPayloads" => {
1139 let payloads = need_client(client)?.pending_payloads();
1140 Ok(json!({
1141 "payloads": payloads.iter().map(|p| bytes_value(p)).collect::<Vec<_>>()
1142 }))
1143 }
1144 "subscriptionState" => {
1145 let id = params
1146 .get("id")
1147 .and_then(Value::as_str)
1148 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
1149 let state = need_client(client)?.subscription_state(id);
1150 Ok(json!({ "state": state }))
1151 }
1152 "recreateWithSchema" => {
1153 let schema = params
1157 .get("schema")
1158 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
1159 need_client(client)?
1160 .recreate_with_schema(schema)
1161 .map_err(client_err)?;
1162 Ok(json!({}))
1163 }
1164 "connectRealtime" => {
1165 need_client(client)?
1166 .connect_realtime(transport)
1167 .map_err(client_err)?;
1168 Ok(json!({}))
1169 }
1170 "disconnectRealtime" => {
1171 need_client(client)?.disconnect_realtime(transport);
1172 Ok(json!({}))
1173 }
1174 "setPresence" => {
1175 let scope_key = params
1176 .get("scopeKey")
1177 .and_then(Value::as_str)
1178 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
1179 .to_owned();
1180 let doc = params.get("doc");
1182 let doc_ref = match doc {
1183 None | Some(Value::Null) => None,
1184 Some(v) => Some(v),
1185 };
1186 need_client(client)?
1187 .set_presence(transport, &scope_key, doc_ref)
1188 .map_err(client_err)?;
1189 Ok(json!({}))
1190 }
1191 "presence" => {
1192 let scope_key = params
1193 .get("scopeKey")
1194 .and_then(Value::as_str)
1195 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
1196 let peers = need_client(client)?.presence(scope_key);
1197 Ok(json!({ "peers": peers }))
1198 }
1199
1200 "messageRoundtrip" => {
1202 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1203 match decode_message(&bytes) {
1204 Ok(message) => Ok(json!({
1205 "ok": true,
1206 "bytes": bytes_value(&encode_message(&message)),
1207 "renderedJson": render_message(&message).to_string(),
1208 })),
1209 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1210 }
1211 }
1212 "segmentRoundtrip" => {
1213 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1214 match decode_rows_segment(&bytes) {
1215 Ok(segment) => Ok(json!({
1216 "ok": true,
1217 "bytes": bytes_value(&encode_rows_segment(&segment)),
1218 "renderedJson": render_rows_segment(&segment).to_string(),
1219 })),
1220 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1221 }
1222 }
1223 "realtimeKnown" => {
1224 let text = params
1225 .get("text")
1226 .and_then(Value::as_str)
1227 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
1228 let known = matches!(
1229 parse_control(text),
1230 Ok(ControlMessage::Hello { .. })
1231 | Ok(ControlMessage::Wake { .. })
1232 | Ok(ControlMessage::Heartbeat { .. })
1233 | Ok(ControlMessage::Presence { .. })
1234 );
1235 Ok(json!({ "value": known }))
1236 }
1237
1238 other => Err(client_err(format!("unknown method {other:?}"))),
1239 };
1240 if client
1241 .as_ref()
1242 .is_some_and(|running| !running.transport_enabled())
1243 {
1244 if let Ok(value) = &mut result {
1245 if let Some(effects) = value.get_mut("effects").and_then(Value::as_object_mut) {
1246 effects.remove("sync");
1247 }
1248 }
1249 }
1250 result
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255 use serde_json::{json, Value};
1256 use syncular_client::{
1257 Mutation, SegmentRequest, SyncClient, Transport, TransportError,
1258 SECURITY_PREFLIGHT_REQUIRED_CODE,
1259 };
1260
1261 use super::{
1262 dispatch, parse_encryption, parse_headers, parse_previous_version_context, CreateEffects,
1263 };
1264
1265 #[derive(Default)]
1266 struct NoNetwork {
1267 realtime_connects: usize,
1268 realtime_closes: usize,
1269 }
1270
1271 impl Transport for NoNetwork {
1272 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1273 Err(TransportError::new("sync.transport_failed", "offline"))
1274 }
1275
1276 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1277 Err(TransportError::new("sync.transport_failed", "offline"))
1278 }
1279
1280 fn download_segment(
1281 &mut self,
1282 _request: &SegmentRequest,
1283 _on_progress: &mut dyn FnMut(u64),
1284 ) -> Result<Vec<u8>, TransportError> {
1285 Err(TransportError::new("sync.transport_failed", "offline"))
1286 }
1287
1288 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1289 self.realtime_connects += 1;
1290 Ok(())
1291 }
1292
1293 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1294 Ok(())
1295 }
1296
1297 fn realtime_close(&mut self) -> Result<(), TransportError> {
1298 self.realtime_closes += 1;
1299 Ok(())
1300 }
1301 }
1302
1303 fn schema() -> Value {
1304 json!({
1305 "version": 1,
1306 "tables": [{
1307 "name": "todos",
1308 "columns": [
1309 { "name": "id", "type": "string", "nullable": false },
1310 { "name": "list_id", "type": "string", "nullable": false }
1311 ],
1312 "primaryKey": "id",
1313 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1314 }]
1315 })
1316 }
1317
1318 #[test]
1319 fn native_command_realtime_connection_is_idempotent() {
1320 let mut transport = NoNetwork::default();
1321 let mut client: Option<SyncClient> = None;
1322 let mut effects = CreateEffects::default();
1323 dispatch(
1324 &mut transport,
1325 &mut client,
1326 &mut effects,
1327 "create",
1328 &json!({ "schema": schema() }),
1329 )
1330 .expect("create client");
1331
1332 for _ in 0..2 {
1333 dispatch(
1334 &mut transport,
1335 &mut client,
1336 &mut effects,
1337 "connectRealtime",
1338 &json!({}),
1339 )
1340 .expect("idempotent connect command");
1341 }
1342 assert_eq!(transport.realtime_connects, 1);
1343
1344 dispatch(
1345 &mut transport,
1346 &mut client,
1347 &mut effects,
1348 "disconnectRealtime",
1349 &json!({}),
1350 )
1351 .expect("disconnect command");
1352 dispatch(
1353 &mut transport,
1354 &mut client,
1355 &mut effects,
1356 "disconnectRealtime",
1357 &json!({}),
1358 )
1359 .expect("idempotent disconnect command");
1360 assert_eq!(transport.realtime_closes, 1);
1361 dispatch(
1362 &mut transport,
1363 &mut client,
1364 &mut effects,
1365 "connectRealtime",
1366 &json!({}),
1367 )
1368 .expect("deliberate reconnect command");
1369 assert_eq!(transport.realtime_connects, 2);
1370 }
1371
1372 #[test]
1373 fn native_command_realtime_policy_rides_create_and_refuses_http() {
1374 let mut transport = NoNetwork::default();
1375 let mut client: Option<SyncClient> = None;
1376 let mut effects = CreateEffects::default();
1377 dispatch(
1378 &mut transport,
1379 &mut client,
1380 &mut effects,
1381 "create",
1382 &json!({ "schema": schema(), "realtimePolicy": "required" }),
1383 )
1384 .expect("create client with a required realtime policy");
1385 let result = dispatch(
1386 &mut transport,
1387 &mut client,
1388 &mut effects,
1389 "sync",
1390 &json!({}),
1391 )
1392 .expect("sync command");
1393 assert_eq!(result["ok"], json!(false));
1394 assert_eq!(result["errorCode"], json!("sync.realtime_unavailable"));
1395 assert_eq!(result["realtimeState"], json!("disconnected"));
1396 assert_eq!(result["retryDelayMs"], json!(250));
1397 let snapshot = dispatch(
1398 &mut transport,
1399 &mut client,
1400 &mut effects,
1401 "diagnosticsSnapshot",
1402 &json!({}),
1403 )
1404 .expect("diagnostics command");
1405 assert_eq!(snapshot["host"]["realtime"], json!("disconnected"));
1406 assert_eq!(snapshot["host"]["realtimePolicy"], json!("required"));
1407 assert_eq!(snapshot["host"]["realtimeRetryDelayMs"], json!(250));
1408
1409 let mut second: Option<SyncClient> = None;
1410 let error = dispatch(
1411 &mut transport,
1412 &mut second,
1413 &mut effects,
1414 "create",
1415 &json!({ "schema": schema(), "realtimePolicy": "always" }),
1416 )
1417 .expect_err("an unknown policy is refused");
1418 assert_eq!(error.0, "sync.invalid_request");
1419 }
1420
1421 #[test]
1422 fn parses_portable_encryption_keyring_and_key_id_columns() {
1423 let key_hex = "2a".repeat(32);
1424 let config = parse_encryption(&json!({
1425 "keys": { "practice-key-v1": { "$bytes": key_hex } },
1426 "keyIdColumns": { "patients": "encryption_key_id" }
1427 }))
1428 .expect("portable keyring parses");
1429 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1430 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1431 }
1432
1433 #[test]
1434 fn rejects_non_string_key_id_columns() {
1435 let error = parse_encryption(&json!({
1436 "keys": {},
1437 "keyIdColumns": { "patients": 7 }
1438 }))
1439 .expect_err("invalid selector must fail");
1440 assert!(error.contains("must be a string"), "{error}");
1441 }
1442
1443 #[test]
1444 fn native_command_hosts_reject_subscription_identity_rebinds() {
1445 let mut transport = NoNetwork::default();
1446 let mut client: Option<SyncClient> = None;
1447 let mut effects = CreateEffects::default();
1448 dispatch(
1449 &mut transport,
1450 &mut client,
1451 &mut effects,
1452 "create",
1453 &json!({ "schema": schema() }),
1454 )
1455 .expect("create client");
1456
1457 let original = json!({
1458 "id": "stable-subscription",
1459 "table": "todos",
1460 "scopes": { "list_id": ["list-2", "list-1"] },
1461 "params": "{\"view\":\"v1\"}"
1462 });
1463 dispatch(
1464 &mut transport,
1465 &mut client,
1466 &mut effects,
1467 "subscribe",
1468 &original,
1469 )
1470 .expect("register subscription");
1471 dispatch(
1472 &mut transport,
1473 &mut client,
1474 &mut effects,
1475 "subscribe",
1476 &json!({
1477 "id": "stable-subscription",
1478 "table": "todos",
1479 "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
1480 "params": "{\"view\":\"v1\"}"
1481 }),
1482 )
1483 .expect("canonical re-declaration is idempotent");
1484
1485 let error = dispatch(
1486 &mut transport,
1487 &mut client,
1488 &mut effects,
1489 "subscribe",
1490 &json!({
1491 "id": "stable-subscription",
1492 "table": "todos",
1493 "scopes": { "list_id": ["list-2"] },
1494 "params": "{\"view\":\"v1\"}"
1495 }),
1496 )
1497 .expect_err("changed native query identity must fail");
1498 assert_eq!(error.0, "client.subscription_intent_mismatch");
1499
1500 let state = dispatch(
1501 &mut transport,
1502 &mut client,
1503 &mut effects,
1504 "subscriptionState",
1505 &json!({ "id": "stable-subscription" }),
1506 )
1507 .expect("subscription state");
1508 assert_eq!(state["state"]["cursor"], -1);
1509 assert_eq!(state["state"]["table"], "todos");
1510 }
1511
1512 #[test]
1513 fn security_preflight_is_fail_closed_until_exact_activation() {
1514 let mut transport = NoNetwork::default();
1515 let mut client: Option<SyncClient> = None;
1516 let mut effects = CreateEffects::default();
1517 dispatch(
1518 &mut transport,
1519 &mut client,
1520 &mut effects,
1521 "create",
1522 &json!({ "schema": schema(), "securityPreflight": true }),
1523 )
1524 .expect("preflight create");
1525
1526 let lifecycle = dispatch(
1527 &mut transport,
1528 &mut client,
1529 &mut effects,
1530 "securityLifecycle",
1531 &json!({}),
1532 )
1533 .expect("lifecycle");
1534 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1535
1536 let query_error = dispatch(
1537 &mut transport,
1538 &mut client,
1539 &mut effects,
1540 "query",
1541 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1542 )
1543 .expect_err("protected query must fail");
1544 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1545 let diagnostics_error = dispatch(
1546 &mut transport,
1547 &mut client,
1548 &mut effects,
1549 "diagnosticsSnapshot",
1550 &json!({}),
1551 )
1552 .expect_err("diagnostics table/subscription evidence remains protected");
1553 assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1554
1555 dispatch(
1556 &mut transport,
1557 &mut client,
1558 &mut effects,
1559 "purgeLocalData",
1560 &json!({
1561 "input": {
1562 "purgeId": "directive-1",
1563 "targets": [{
1564 "table": "todos",
1565 "selectors": { "list_id": ["list-1"] }
1566 }]
1567 }
1568 }),
1569 )
1570 .expect("authorized local purge remains available");
1571
1572 let repair_error = dispatch(
1573 &mut transport,
1574 &mut client,
1575 &mut effects,
1576 "rebootstrapLocalData",
1577 &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1578 )
1579 .expect_err("projection repair must remain protected during preflight");
1580 assert_eq!(repair_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1581
1582 dispatch(
1583 &mut transport,
1584 &mut client,
1585 &mut effects,
1586 "activateSecurity",
1587 &json!({}),
1588 )
1589 .expect("activation");
1590 let rows = dispatch(
1591 &mut transport,
1592 &mut client,
1593 &mut effects,
1594 "query",
1595 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1596 )
1597 .expect("active query");
1598 assert_eq!(rows, json!({ "rows": [] }));
1599
1600 dispatch(
1601 &mut transport,
1602 &mut client,
1603 &mut effects,
1604 "beginSecurityPreflight",
1605 &json!({}),
1606 )
1607 .expect("re-enter preflight");
1608 let blocked = dispatch(
1609 &mut transport,
1610 &mut client,
1611 &mut effects,
1612 "mutate",
1613 &json!({ "mutations": [] }),
1614 )
1615 .expect_err("mutation must be gated");
1616 assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1617 }
1618
1619 #[test]
1620 fn preflight_allows_only_the_authorized_previous_version_discard() {
1621 let mut transport = NoNetwork::default();
1622 let mut client: Option<SyncClient> = None;
1623 let mut effects = CreateEffects::default();
1624 dispatch(
1625 &mut transport,
1626 &mut client,
1627 &mut effects,
1628 "create",
1629 &json!({ "schema": schema(), "securityPreflight": true }),
1630 )
1631 .expect("preflight create");
1632
1633 for method in ["previousVersionSnapshot", "previousVersionAudit"] {
1635 let error = dispatch(
1636 &mut transport,
1637 &mut client,
1638 &mut effects,
1639 method,
1640 &json!({}),
1641 )
1642 .expect_err("previous-version reads stay gated during preflight");
1643 assert_eq!(error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1644 }
1645 let discarded = dispatch(
1648 &mut transport,
1649 &mut client,
1650 &mut effects,
1651 "previousVersionDiscard",
1652 &json!({}),
1653 )
1654 .expect("authorized cleanup is allowed during preflight");
1655 assert_eq!(discarded, json!({ "present": false, "discarded": false }));
1656 assert!(client.as_ref().is_some_and(SyncClient::security_preflight));
1657 }
1658
1659 #[test]
1660 fn preflight_refuses_a_replacement_create_without_the_flag() {
1661 let mut transport = NoNetwork::default();
1662 let mut client: Option<SyncClient> = None;
1663 let mut effects = CreateEffects::default();
1664 dispatch(
1665 &mut transport,
1666 &mut client,
1667 &mut effects,
1668 "create",
1669 &json!({ "schema": schema(), "securityPreflight": true }),
1670 )
1671 .expect("preflight create");
1672
1673 let escape = dispatch(
1675 &mut transport,
1676 &mut client,
1677 &mut effects,
1678 "create",
1679 &json!({ "schema": schema() }),
1680 )
1681 .expect_err("plain create must be refused during preflight");
1682 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1683 let query_error = dispatch(
1685 &mut transport,
1686 &mut client,
1687 &mut effects,
1688 "query",
1689 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1690 )
1691 .expect_err("protected query stays gated");
1692 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1693
1694 dispatch(
1696 &mut transport,
1697 &mut client,
1698 &mut effects,
1699 "create",
1700 &json!({ "schema": schema(), "securityPreflight": true }),
1701 )
1702 .expect("preflighted replacement create");
1703 let still_gated = dispatch(
1704 &mut transport,
1705 &mut client,
1706 &mut effects,
1707 "query",
1708 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1709 )
1710 .expect_err("replacement stays gated");
1711 assert_eq!(still_gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1712
1713 dispatch(
1715 &mut transport,
1716 &mut client,
1717 &mut effects,
1718 "activateSecurity",
1719 &json!({}),
1720 )
1721 .expect("activation");
1722 dispatch(
1723 &mut transport,
1724 &mut client,
1725 &mut effects,
1726 "create",
1727 &json!({ "schema": schema() }),
1728 )
1729 .expect("plain create after activation");
1730 let rows = dispatch(
1731 &mut transport,
1732 &mut client,
1733 &mut effects,
1734 "query",
1735 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1736 )
1737 .expect("active query");
1738 assert_eq!(rows, json!({ "rows": [] }));
1739 }
1740
1741 fn temp_db_path(tag: &str) -> String {
1744 std::env::temp_dir()
1745 .join(format!(
1746 "syncular-command-{tag}-{}-{:?}.db",
1747 std::process::id(),
1748 std::time::SystemTime::now()
1749 .duration_since(std::time::UNIX_EPOCH)
1750 .expect("clock")
1751 .as_nanos()
1752 ))
1753 .to_string_lossy()
1754 .into_owned()
1755 }
1756
1757 #[test]
1758 fn an_activated_replica_reopens_plainly_after_shutdown() {
1759 let path = temp_db_path("activated-reopen");
1765 let mut transport = NoNetwork::default();
1766 let mut client: Option<SyncClient> = None;
1767 let mut effects = CreateEffects::default();
1768
1769 dispatch(
1770 &mut transport,
1771 &mut client,
1772 &mut effects,
1773 "create",
1774 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1775 )
1776 .expect("preflight create");
1777 dispatch(
1778 &mut transport,
1779 &mut client,
1780 &mut effects,
1781 "activateSecurity",
1782 &json!({}),
1783 )
1784 .expect("activation clears the gate");
1785 dispatch(
1786 &mut transport,
1787 &mut client,
1788 &mut effects,
1789 "shutdown",
1790 &json!({}),
1791 )
1792 .expect("shutdown after activation");
1793
1794 dispatch(
1796 &mut transport,
1797 &mut client,
1798 &mut effects,
1799 "create",
1800 &json!({ "schema": schema(), "dbPath": path }),
1801 )
1802 .expect("an activated replica must reopen plainly after shutdown");
1803 let state = dispatch(
1804 &mut transport,
1805 &mut client,
1806 &mut effects,
1807 "securityLifecycle",
1808 &json!({}),
1809 )
1810 .expect("lifecycle");
1811 assert_eq!(state, json!({ "state": "active" }));
1812
1813 let _ = std::fs::remove_file(&path);
1814 }
1815
1816 #[test]
1817 fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
1818 let path = temp_db_path("quarantined-reopen");
1823 let mut transport = NoNetwork::default();
1824 let mut client: Option<SyncClient> = None;
1825 let mut effects = CreateEffects::default();
1826
1827 dispatch(
1828 &mut transport,
1829 &mut client,
1830 &mut effects,
1831 "create",
1832 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1833 )
1834 .expect("preflight create");
1835 dispatch(
1836 &mut transport,
1837 &mut client,
1838 &mut effects,
1839 "shutdown",
1840 &json!({}),
1841 )
1842 .expect("shutdown during preflight");
1843
1844 let mut rebuilt = CreateEffects::default();
1847 let escape = dispatch(
1848 &mut transport,
1849 &mut client,
1850 &mut rebuilt,
1851 "create",
1852 &json!({ "schema": schema(), "dbPath": path }),
1853 )
1854 .expect_err("a quarantined replica must refuse a plain re-create");
1855 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1856
1857 let _ = std::fs::remove_file(&path);
1858 }
1859
1860 #[test]
1861 fn preflight_gate_survives_shutdown_before_replacement_creates() {
1862 let mut transport = NoNetwork::default();
1863 let mut client: Option<SyncClient> = None;
1864 let mut effects = CreateEffects::default();
1865 dispatch(
1866 &mut transport,
1867 &mut client,
1868 &mut effects,
1869 "create",
1870 &json!({ "schema": schema(), "securityPreflight": true }),
1871 )
1872 .expect("preflight create");
1873 dispatch(
1874 &mut transport,
1875 &mut client,
1876 &mut effects,
1877 "shutdown",
1878 &json!({}),
1879 )
1880 .expect("shutdown during preflight");
1881 assert!(client.is_none());
1882
1883 let escape = dispatch(
1885 &mut transport,
1886 &mut client,
1887 &mut effects,
1888 "create",
1889 &json!({ "schema": schema() }),
1890 )
1891 .expect_err("shutdown + plain create must stay refused");
1892 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1893
1894 dispatch(
1895 &mut transport,
1896 &mut client,
1897 &mut effects,
1898 "create",
1899 &json!({ "schema": schema(), "securityPreflight": true }),
1900 )
1901 .expect("preflighted re-create");
1902 dispatch(
1903 &mut transport,
1904 &mut client,
1905 &mut effects,
1906 "activateSecurity",
1907 &json!({}),
1908 )
1909 .expect("activation");
1910
1911 dispatch(
1913 &mut transport,
1914 &mut client,
1915 &mut effects,
1916 "shutdown",
1917 &json!({}),
1918 )
1919 .expect("shutdown after activation");
1920 dispatch(
1921 &mut transport,
1922 &mut client,
1923 &mut effects,
1924 "create",
1925 &json!({ "schema": schema() }),
1926 )
1927 .expect("plain create after an activated shutdown");
1928 }
1929
1930 #[test]
1931 fn activate_security_validates_optional_headers_atomically() {
1932 let mut transport = NoNetwork::default();
1933 let mut client: Option<SyncClient> = None;
1934 let mut effects = CreateEffects::default();
1935 dispatch(
1936 &mut transport,
1937 &mut client,
1938 &mut effects,
1939 "create",
1940 &json!({ "schema": schema(), "securityPreflight": true }),
1941 )
1942 .expect("preflight create");
1943
1944 let invalid = dispatch(
1946 &mut transport,
1947 &mut client,
1948 &mut effects,
1949 "activateSecurity",
1950 &json!({ "headers": { "authorization": 7 } }),
1951 )
1952 .expect_err("non-string header must fail");
1953 assert_eq!(invalid.0, "sync.invalid_request");
1954 let lifecycle = dispatch(
1955 &mut transport,
1956 &mut client,
1957 &mut effects,
1958 "securityLifecycle",
1959 &json!({}),
1960 )
1961 .expect("lifecycle");
1962 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1963
1964 dispatch(
1966 &mut transport,
1967 &mut client,
1968 &mut effects,
1969 "activateSecurity",
1970 &json!({ "headers": { "authorization": "Bearer fresh" } }),
1971 )
1972 .expect("activation with fresh headers");
1973 let lifecycle = dispatch(
1974 &mut transport,
1975 &mut client,
1976 &mut effects,
1977 "securityLifecycle",
1978 &json!({}),
1979 )
1980 .expect("lifecycle");
1981 assert_eq!(lifecycle, json!({ "state": "active" }));
1982 }
1983
1984 #[test]
1985 fn parse_headers_reads_the_full_replacement_set() {
1986 let parsed = parse_headers(&json!({
1987 "authorization": "Bearer fresh",
1988 "x-tenant": "t1"
1989 }))
1990 .expect("valid headers parse");
1991 assert_eq!(
1992 parsed,
1993 vec![
1994 ("authorization".to_owned(), "Bearer fresh".to_owned()),
1995 ("x-tenant".to_owned(), "t1".to_owned())
1996 ]
1997 );
1998 assert!(parse_headers(&json!(["authorization"])).is_err());
1999 assert!(parse_headers(&json!({ "authorization": null })).is_err());
2000 }
2001
2002 #[test]
2003 fn set_headers_validates_replacement_and_respects_preflight() {
2004 let mut transport = NoNetwork::default();
2005 let mut client: Option<SyncClient> = None;
2006 let mut effects = CreateEffects::default();
2007 dispatch(
2008 &mut transport,
2009 &mut client,
2010 &mut effects,
2011 "create",
2012 &json!({ "schema": schema() }),
2013 )
2014 .expect("create");
2015 dispatch(
2016 &mut transport,
2017 &mut client,
2018 &mut effects,
2019 "setHeaders",
2020 &json!({ "headers": { "authorization": "Bearer fresh" } }),
2021 )
2022 .expect("valid replacement");
2023 let invalid = dispatch(
2024 &mut transport,
2025 &mut client,
2026 &mut effects,
2027 "setHeaders",
2028 &json!({ "headers": { "authorization": 7 } }),
2029 )
2030 .expect_err("invalid replacement");
2031 assert_eq!(invalid.0, "sync.invalid_request");
2032
2033 dispatch(
2034 &mut transport,
2035 &mut client,
2036 &mut effects,
2037 "beginSecurityPreflight",
2038 &json!({}),
2039 )
2040 .expect("preflight");
2041 let gated = dispatch(
2042 &mut transport,
2043 &mut client,
2044 &mut effects,
2045 "setHeaders",
2046 &json!({ "headers": {} }),
2047 )
2048 .expect_err("direct rotation must respect preflight");
2049 assert_eq!(gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
2050 }
2051
2052 fn schema_v2() -> Value {
2053 json!({
2054 "version": 2,
2055 "tables": [{
2056 "name": "todos",
2057 "columns": [
2058 { "name": "id", "type": "string", "nullable": false },
2059 { "name": "list_id", "type": "string", "nullable": false },
2060 { "name": "note", "type": "string", "nullable": true }
2061 ],
2062 "primaryKey": "id",
2063 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2064 }]
2065 })
2066 }
2067
2068 fn previous_version_enabled() -> Value {
2069 json!({ "enabled": true })
2070 }
2071
2072 fn container_path(db_path: &str) -> String {
2073 format!("{db_path}.prev-context")
2074 }
2075
2076 #[test]
2077 fn previous_version_config_parser_matches_ts_keys_and_defaults() {
2078 assert_eq!(parse_previous_version_context(None).expect("absent"), None);
2079 let defaults = parse_previous_version_context(Some(&json!({})))
2080 .expect("empty")
2081 .expect("present");
2082 assert!(!defaults.enabled);
2083 assert_eq!(defaults.max_bytes, 8 * 1024 * 1024);
2084 assert_eq!(defaults.max_rows, 20_000);
2085 assert_eq!(defaults.max_tables, 32);
2086 assert_eq!(defaults.max_row_bytes, 1024 * 1024);
2087 assert_eq!(defaults.max_age_ms, 24 * 60 * 60 * 1000);
2088
2089 let configured = parse_previous_version_context(Some(&json!({
2090 "enabled": true,
2091 "maxBytes": 1024,
2092 "maxRows": 3,
2093 "maxTables": 2,
2094 "maxRowBytes": 5,
2095 "maxAgeMs": 7
2096 })))
2097 .expect("configured")
2098 .expect("present");
2099 assert!(configured.enabled);
2100 assert_eq!(configured.max_bytes, 1024);
2101 assert_eq!(configured.max_rows, 3);
2102 assert_eq!(configured.max_tables, 2);
2103 assert_eq!(configured.max_row_bytes, 5);
2104 assert_eq!(configured.max_age_ms, 7);
2105
2106 for (value, expected) in [
2108 (json!([]), "must be an object"),
2109 (json!({ "enabled": "yes" }), "enabled must be a boolean"),
2110 (
2111 json!({ "maxBytes": 0 }),
2112 "maxBytes must be a positive safe integer",
2113 ),
2114 (
2115 json!({ "maxRows": -1 }),
2116 "maxRows must be a positive safe integer",
2117 ),
2118 (
2119 json!({ "maxTables": 1.5 }),
2120 "maxTables must be a positive safe integer",
2121 ),
2122 (
2123 json!({ "maxRowBytes": "1" }),
2124 "maxRowBytes must be a positive safe integer",
2125 ),
2126 (
2127 json!({ "maxAgeMs": 0 }),
2128 "maxAgeMs must be a positive safe integer",
2129 ),
2130 ] {
2131 let error = parse_previous_version_context(Some(&value)).expect_err("must fail");
2132 assert_eq!(error.0, "sync.invalid_request", "{value}");
2133 assert!(error.1.contains(expected), "{value}: {}", error.1);
2134 }
2135 }
2136
2137 #[test]
2138 fn invalid_previous_version_config_fails_create_loudly() {
2139 let mut transport = NoNetwork::default();
2140 let mut client: Option<SyncClient> = None;
2141 let mut effects = CreateEffects::default();
2142 let error = dispatch(
2143 &mut transport,
2144 &mut client,
2145 &mut effects,
2146 "create",
2147 &json!({ "schema": schema(), "previousVersionContext": { "enabled": true, "maxRows": 0 } }),
2148 )
2149 .expect_err("invalid config must fail the create");
2150 assert_eq!(error.0, "sync.invalid_request");
2151 assert!(error.1.contains("maxRows"), "{}", error.1);
2152 assert!(client.is_none(), "no client may be installed");
2153 }
2154
2155 #[test]
2156 fn previous_version_snapshot_reports_not_configured_by_default() {
2157 let mut transport = NoNetwork::default();
2158 let mut client: Option<SyncClient> = None;
2159 let mut effects = CreateEffects::default();
2160 dispatch(
2161 &mut transport,
2162 &mut client,
2163 &mut effects,
2164 "create",
2165 &json!({ "schema": schema() }),
2166 )
2167 .expect("create");
2168 let snapshot = dispatch(
2169 &mut transport,
2170 &mut client,
2171 &mut effects,
2172 "previousVersionSnapshot",
2173 &json!({ "table": "todos" }),
2174 )
2175 .expect("snapshot");
2176 assert_eq!(snapshot["state"], json!("previousVersion"));
2177 assert_eq!(snapshot["available"], json!(false));
2178 assert_eq!(snapshot["reason"], json!("not-configured"));
2179 assert_eq!(snapshot["rows"], json!([]));
2180 assert_eq!(snapshot["truncated"], json!(false));
2181 assert_eq!(snapshot["currentVersion"], json!(1));
2182 let discarded = dispatch(
2183 &mut transport,
2184 &mut client,
2185 &mut effects,
2186 "previousVersionDiscard",
2187 &json!({}),
2188 )
2189 .expect("discard");
2190 assert_eq!(discarded, json!({ "present": false, "discarded": false }));
2191 assert_eq!(
2192 dispatch(
2193 &mut transport,
2194 &mut client,
2195 &mut effects,
2196 "previousVersionAudit",
2197 &json!({}),
2198 )
2199 .expect("audit"),
2200 Value::Null
2201 );
2202 }
2203
2204 #[test]
2205 fn previous_version_commands_round_trip_through_the_dispatcher() {
2206 let path = temp_db_path("previous-version");
2207 let mut transport = NoNetwork::default();
2208 let mut client: Option<SyncClient> = None;
2209 let mut effects = CreateEffects::default();
2210
2211 dispatch(
2213 &mut transport,
2214 &mut client,
2215 &mut effects,
2216 "create",
2217 &json!({
2218 "schema": schema(),
2219 "dbPath": path,
2220 "previousVersionContext": previous_version_enabled()
2221 }),
2222 )
2223 .expect("create v1");
2224 client
2225 .as_mut()
2226 .expect("client")
2227 .mutate(vec![Mutation::Upsert {
2228 table: "todos".to_owned(),
2229 values: serde_json::Map::from_iter([
2230 ("id".to_owned(), json!("t1")),
2231 ("list_id".to_owned(), json!("l1")),
2232 ]),
2233 base_version: None,
2234 }])
2235 .expect("seed a local row");
2236 dispatch(
2237 &mut transport,
2238 &mut client,
2239 &mut effects,
2240 "shutdown",
2241 &json!({}),
2242 )
2243 .expect("shutdown");
2244
2245 dispatch(
2247 &mut transport,
2248 &mut client,
2249 &mut effects,
2250 "create",
2251 &json!({
2252 "schema": schema_v2(),
2253 "dbPath": path,
2254 "previousVersionContext": previous_version_enabled()
2255 }),
2256 )
2257 .expect("create v2");
2258 assert!(
2259 std::path::Path::new(&container_path(&path)).exists(),
2260 "the bump must leave a container file"
2261 );
2262 let status = dispatch(
2263 &mut transport,
2264 &mut client,
2265 &mut effects,
2266 "statusSnapshot",
2267 &json!({}),
2268 )
2269 .expect("status");
2270 assert_eq!(
2271 status["previousVersionContext"]["present"],
2272 json!(true),
2273 "{status}"
2274 );
2275 assert!(
2276 status["previousVersionContext"]["createdAtMs"].is_i64(),
2277 "{status}"
2278 );
2279
2280 let snapshot = dispatch(
2281 &mut transport,
2282 &mut client,
2283 &mut effects,
2284 "previousVersionSnapshot",
2285 &json!({ "table": "todos" }),
2286 )
2287 .expect("snapshot");
2288 assert_eq!(snapshot["state"], json!("previousVersion"));
2289 assert_eq!(snapshot["available"], json!(true));
2290 assert_eq!(snapshot["previousVersion"], json!(1));
2291 assert_eq!(snapshot["currentVersion"], json!(2));
2292 assert_eq!(snapshot["truncated"], json!(false));
2293 assert_eq!(snapshot["rows"][0]["id"], json!("t1"));
2294 assert_eq!(snapshot["rows"][0]["list_id"], json!("l1"));
2295
2296 let unknown = dispatch(
2298 &mut transport,
2299 &mut client,
2300 &mut effects,
2301 "previousVersionSnapshot",
2302 &json!({ "table": "absent" }),
2303 )
2304 .expect_err("unknown previous table");
2305 assert_eq!(unknown.0, "sync.invalid_request");
2306 let bad_limit = dispatch(
2307 &mut transport,
2308 &mut client,
2309 &mut effects,
2310 "previousVersionSnapshot",
2311 &json!({ "table": "todos", "limit": 0 }),
2312 )
2313 .expect_err("bad limit");
2314 assert_eq!(bad_limit.0, "sync.invalid_request");
2315
2316 let audit = dispatch(
2319 &mut transport,
2320 &mut client,
2321 &mut effects,
2322 "previousVersionAudit",
2323 &json!({}),
2324 )
2325 .expect("audit");
2326 assert_eq!(audit["v"], json!(1));
2327 assert_eq!(audit["fromVersion"], json!(1));
2328 assert_eq!(audit["toVersion"], json!(2));
2329 assert_eq!(audit["pending"], json!(1));
2330 assert_eq!(audit["encodable"], json!(1));
2331 assert_eq!(audit["incompatible"], json!([]));
2332 assert!(audit.get("operations").is_none());
2333
2334 let discarded = dispatch(
2336 &mut transport,
2337 &mut client,
2338 &mut effects,
2339 "previousVersionDiscard",
2340 &json!({}),
2341 )
2342 .expect("discard");
2343 assert_eq!(discarded, json!({ "present": true, "discarded": true }));
2344 assert!(!std::path::Path::new(&container_path(&path)).exists());
2345 let again = dispatch(
2346 &mut transport,
2347 &mut client,
2348 &mut effects,
2349 "previousVersionDiscard",
2350 &json!({}),
2351 )
2352 .expect("idempotent discard");
2353 assert_eq!(again, json!({ "present": false, "discarded": false }));
2354
2355 let inspect = rusqlite::Connection::open(&path).expect("inspect replica");
2357 let remaining: i64 = inspect
2358 .query_row(
2359 "SELECT COUNT(*) FROM _syncular_meta WHERE key IN (?1, ?2)",
2360 rusqlite::params!["previousVersionContext", "previousVersionAudit"],
2361 |row| row.get(0),
2362 )
2363 .expect("count meta");
2364 assert_eq!(remaining, 0);
2365
2366 let _ = std::fs::remove_file(&path);
2367 }
2368
2369 #[test]
2370 fn previous_version_ttl_expires_at_read_through_the_dispatcher() {
2371 let path = temp_db_path("previous-version-ttl");
2372 let mut transport = NoNetwork::default();
2373 let mut client: Option<SyncClient> = None;
2374 let mut effects = CreateEffects::default();
2375 dispatch(
2376 &mut transport,
2377 &mut client,
2378 &mut effects,
2379 "create",
2380 &json!({
2381 "schema": schema(),
2382 "dbPath": path,
2383 "previousVersionContext": previous_version_enabled()
2384 }),
2385 )
2386 .expect("create v1");
2387 dispatch(
2388 &mut transport,
2389 &mut client,
2390 &mut effects,
2391 "shutdown",
2392 &json!({}),
2393 )
2394 .expect("shutdown");
2395
2396 let now_ms = std::time::SystemTime::now()
2400 .duration_since(std::time::UNIX_EPOCH)
2401 .expect("clock")
2402 .as_millis() as i64
2403 + 2_000;
2404 dispatch(
2405 &mut transport,
2406 &mut client,
2407 &mut effects,
2408 "create",
2409 &json!({
2410 "schema": schema_v2(),
2411 "dbPath": path,
2412 "nowMs": now_ms,
2413 "previousVersionContext": { "enabled": true, "maxAgeMs": 500 }
2414 }),
2415 )
2416 .expect("create v2");
2417 assert!(std::path::Path::new(&container_path(&path)).exists());
2418 let snapshot = dispatch(
2419 &mut transport,
2420 &mut client,
2421 &mut effects,
2422 "previousVersionSnapshot",
2423 &json!({ "table": "todos" }),
2424 )
2425 .expect("snapshot");
2426 assert_eq!(snapshot["available"], json!(false));
2427 assert_eq!(snapshot["reason"], json!("expired"));
2428 assert_eq!(snapshot["rows"], json!([]));
2429 assert!(!std::path::Path::new(&container_path(&path)).exists());
2431
2432 let _ = std::fs::remove_file(&path);
2433 }
2434}