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 "securityLifecycle"
467 | "beginSecurityPreflight"
468 | "activateSecurity"
469 | "setTransportEnabled"
470 | "purgeLocalData"
471 | "previousVersionDiscard"
472 | "localRevision"
473 | "statusSnapshot"
474 | "shutdown"
475 );
476 if client
477 .as_ref()
478 .is_some_and(|running| running.security_preflight())
479 && !allowed_during_preflight
480 {
481 return Err((
482 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
483 "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data".to_owned(),
484 ));
485 }
486 }
487 let mut result = match method {
488 "create" => {
489 let client_id = params
490 .get("clientId")
491 .and_then(Value::as_str)
492 .map(str::to_owned);
493 let schema = params
494 .get("schema")
495 .ok_or_else(|| client_err("create missing schema".to_owned()))?;
496 let limits = parse_limits(params.get("limits"));
497 let previous_version_context =
501 parse_previous_version_context(params.get("previousVersionContext"))?;
502 let mut limits = limits;
503 limits.previous_version_context = previous_version_context;
504 let mut instance = match params.get("dbPath").and_then(Value::as_str) {
508 Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
509 .map_err(client_err)?,
510 None => {
511 SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
512 }
513 };
514 instance
515 .set_retain_failed_commits(
516 params
517 .get("retainFailedCommits")
518 .and_then(Value::as_bool)
519 .unwrap_or(false),
520 )
521 .map_err(client_err)?;
522 let realtime_policy = parse_realtime_policy(params.get("realtimePolicy"))?;
525 instance
526 .set_realtime_policy(realtime_policy)
527 .map_err(client_err)?;
528 if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
530 instance.set_now_ms(now_ms);
531 }
532 effects.signed_urls = params
535 .get("signedUrls")
536 .and_then(Value::as_bool)
537 .unwrap_or(false);
538 let security_preflight = params
542 .get("securityPreflight")
543 .and_then(Value::as_bool)
544 .unwrap_or(false);
545 if instance.security_preflight() && !security_preflight {
553 return Err((
554 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
555 "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
556 ));
557 }
558 if security_preflight && params.get("encryption").is_some() {
559 return Err(client_err(
560 "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
561 .to_owned(),
562 ));
563 }
564 if let Some(enc) = params.get("encryption") {
565 let config = parse_encryption(enc).map_err(client_err)?;
566 instance.set_encryption(config);
567 }
568 if security_preflight {
569 instance.begin_security_preflight();
570 }
571 if let Some(enabled) = params.get("transportEnabled") {
572 let enabled = enabled.as_bool().ok_or_else(|| {
573 client_err("sync.invalid_request: transportEnabled must be a boolean".into())
574 })?;
575 instance.set_transport_enabled(transport, enabled);
576 }
577 effects.security_preflight_pending = security_preflight;
578 *client = Some(instance);
579 Ok(json!({}))
580 }
581 "securityLifecycle" => Ok(json!({
582 "state": need_client(client)?.security_lifecycle()
583 })),
584 "beginSecurityPreflight" => {
585 let running = need_client(client)?;
586 running.disconnect_realtime(transport);
587 running.begin_security_preflight();
588 effects.security_preflight_pending = true;
589 Ok(json!({}))
590 }
591 "activateSecurity" => {
592 let encryption = match params.get("encryption") {
593 Some(value) => parse_encryption(value).map_err(client_err)?,
594 None => syncular_client::values::EncryptionConfig::default(),
595 };
596 if let Some(headers) = params.get("headers") {
602 parse_headers(headers).map_err(client_err)?;
603 }
604 need_client(client)?
605 .activate_security(encryption)
606 .map_err(client_err)?;
607 effects.security_preflight_pending = false;
608 Ok(json!({}))
609 }
610 "setTransportEnabled" => {
611 let enabled = params
612 .get("enabled")
613 .and_then(Value::as_bool)
614 .ok_or_else(|| {
615 client_err(
616 "sync.invalid_request: setTransportEnabled.enabled must be a boolean"
617 .into(),
618 )
619 })?;
620 need_client(client)?.set_transport_enabled(transport, enabled);
621 Ok(json!({}))
622 }
623 "setHeaders" => {
624 let headers = params
625 .get("headers")
626 .ok_or_else(|| client_err("setHeaders missing headers".to_owned()))?;
627 parse_headers(headers).map_err(client_err)?;
628 Ok(json!({}))
629 }
630 "shutdown" => {
631 if let Some(running) = client.as_mut() {
632 effects.security_preflight_pending = running.security_preflight();
637 running.disconnect_realtime(transport);
638 running.seal_security_on_teardown();
642 }
643 *client = None;
644 Ok(json!({}))
645 }
646 "subscribe" => {
647 let id = params
648 .get("id")
649 .and_then(Value::as_str)
650 .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
651 .to_owned();
652 let table = params
653 .get("table")
654 .and_then(Value::as_str)
655 .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
656 .to_owned();
657 let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
658 let sub_params = params
659 .get("params")
660 .and_then(Value::as_str)
661 .map(str::to_owned);
662 need_client(client)?
663 .subscribe(id, table, scopes, sub_params)
664 .map_err(client_err)?;
665 Ok(json!({}))
666 }
667 "unsubscribe" => {
668 let id = params
669 .get("id")
670 .and_then(Value::as_str)
671 .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
672 need_client(client)?.unsubscribe(id);
673 Ok(json!({}))
674 }
675 "setWindow" => {
676 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
677 let units: Vec<String> = params
678 .get("units")
679 .and_then(Value::as_array)
680 .map(|arr| {
681 arr.iter()
682 .filter_map(|v| v.as_str().map(str::to_owned))
683 .collect()
684 })
685 .unwrap_or_default();
686 let command_effects = need_client(client)?
687 .set_window(&base, &units)
688 .map_err(client_err)?;
689 Ok(json!({ "effects": command_effects }))
690 }
691 "windowState" => {
692 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
693 let state = need_client(client)?.window_state(&base);
694 Ok(json!({ "units": state.units, "pending": state.pending }))
695 }
696 "timeWindowSugar" => {
697 let created_at_ms = params
698 .get("createdAtMs")
699 .and_then(Value::as_i64)
700 .ok_or_else(|| client_err("timeWindowSugar missing createdAtMs".to_owned()))?;
701 let count = params
702 .get("count")
703 .and_then(Value::as_u64)
704 .and_then(|value| usize::try_from(value).ok())
705 .ok_or_else(|| client_err("timeWindowSugar missing count".to_owned()))?;
706 let now_ms = params
707 .get("nowMs")
708 .and_then(Value::as_i64)
709 .ok_or_else(|| client_err("timeWindowSugar missing nowMs".to_owned()))?;
710 if params.get("unit").and_then(Value::as_str) != Some("month") {
711 return Err(client_err(
712 "sync.invalid_request: timeWindowSugar requires the month unit".to_owned(),
713 ));
714 }
715 let unit = syncular_client::TimeBucketUnit::Month;
716 Ok(json!({
717 "bucket": syncular_client::creation_time_bucket(created_at_ms, unit)
718 .map_err(client_err)?,
719 "units": syncular_client::last(count, unit, now_ms).map_err(client_err)?,
720 }))
721 }
722 "mutate" => {
723 let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
724 let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
725 Ok(json!({
726 "clientCommitId": id,
727 "effects": CommandEffects::interactive()
728 }))
729 }
730 "patch" => {
731 let table = params
732 .get("table")
733 .and_then(Value::as_str)
734 .ok_or_else(|| client_err("patch missing table".to_owned()))?;
735 let row_id = params
736 .get("rowId")
737 .and_then(Value::as_str)
738 .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
739 let mut partial = params
740 .get("partial")
741 .and_then(Value::as_object)
742 .cloned()
743 .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
744 decode_bigint_members(&mut partial).map_err(client_err)?;
745 let base_version = params.get("baseVersion").and_then(Value::as_i64);
746 let id = need_client(client)?
747 .patch(table, row_id, partial, base_version)
748 .map_err(client_err)?;
749 Ok(json!({
750 "clientCommitId": id,
751 "effects": CommandEffects::interactive()
752 }))
753 }
754 "purgeLocalData" => {
755 let input: LocalDataPurgeInput =
756 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
757 client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
758 })?)
759 .map_err(|error| {
760 client_err(format!(
761 "sync.invalid_request: invalid purgeLocalData input: {error}"
762 ))
763 })?;
764 let result = need_client(client)?
765 .purge_local_data(&input)
766 .map_err(client_err)?;
767 serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
768 }
769 "rebootstrapLocalData" => {
770 let input: LocalDataRebootstrapInput =
771 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
772 client_err(
773 "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
774 )
775 })?)
776 .map_err(|error| {
777 client_err(format!(
778 "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
779 ))
780 })?;
781 let result = need_client(client)?
782 .rebootstrap_local_data(&input)
783 .map_err(client_err)?;
784 Ok(json!({
785 "alreadyApplied": result.already_applied,
786 "retainedCommits": result.retained_commits,
787 "resetSubscriptions": result.reset_subscriptions,
788 "effects": if result.already_applied {
789 CommandEffects::none()
790 } else {
791 CommandEffects::interactive()
792 }
793 }))
794 }
795 "sync" => {
796 let outcome = need_client(client)?.sync(transport);
797 Ok(outcome.to_json())
798 }
799 "syncUntilIdle" => {
800 let max_rounds = params
801 .get("maxRounds")
802 .and_then(Value::as_u64)
803 .map(|v| v as u32);
804 let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
805 Ok(outcome.to_json())
806 }
807 "readRows" => {
808 let table = params
809 .get("table")
810 .and_then(Value::as_str)
811 .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
812 let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
813 Ok(json!({ "rows": rows }))
814 }
815 "query" => {
816 let sql = params
820 .get("sql")
821 .and_then(Value::as_str)
822 .ok_or_else(|| client_err("query missing sql".to_owned()))?;
823 let bind = match params.get("params") {
824 Some(Value::Array(list)) => list.as_slice(),
825 None | Some(Value::Null) => &[],
826 Some(_) => return Err(client_err("query params must be a list".to_owned())),
827 };
828 let rows = need_client(client)?.query(sql, bind).map_err(client_err)?;
829 Ok(Value::Object(serde_json::Map::from_iter([(
830 "rows".to_owned(),
831 Value::Array(rows.into_iter().map(Value::Object).collect()),
832 )])))
833 }
834 "querySnapshot" => {
835 let sql = params
836 .get("sql")
837 .and_then(Value::as_str)
838 .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
839 let bind = match params.get("params") {
840 Some(Value::Array(list)) => list.as_slice(),
841 None | Some(Value::Null) => &[],
842 Some(_) => {
843 return Err(client_err("querySnapshot params must be a list".to_owned()))
844 }
845 };
846 let mut coverage = Vec::new();
847 for entry in params
848 .get("coverage")
849 .and_then(Value::as_array)
850 .into_iter()
851 .flatten()
852 {
853 let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
854 let units = entry
855 .get("units")
856 .and_then(Value::as_array)
857 .map(|values| {
858 values
859 .iter()
860 .filter_map(|value| value.as_str().map(str::to_owned))
861 .collect()
862 })
863 .unwrap_or_default();
864 coverage.push(WindowCoverage { base, units });
865 }
866 let owner = match params.get("owner") {
868 None | Some(Value::Null) => None,
869 Some(value) => {
870 let id = value
871 .get("id")
872 .and_then(Value::as_str)
873 .filter(|id| !id.is_empty());
874 let tables = value
875 .get("tables")
876 .and_then(Value::as_array)
877 .and_then(|list| {
878 list.iter()
879 .map(Value::as_str)
880 .collect::<Option<Vec<&str>>>()
881 });
882 match (id, tables) {
883 (Some(id), Some(tables)) => Some((id, tables)),
884 _ => {
885 return Err(client_err(
886 "sync.invalid_request: querySnapshot owner must be {id: non-empty string, tables: string[]}"
887 .to_owned(),
888 ))
889 }
890 }
891 }
892 };
893 let mut snapshot = need_client(client)?
894 .query_snapshot(
895 sql,
896 bind,
897 &coverage,
898 owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
899 )
900 .map_err(client_err)?;
901 let rows = std::mem::take(&mut snapshot.rows);
902 let mut result = serde_json::to_value(snapshot).expect("snapshot serializes");
903 result["rows"] = Value::Array(rows.into_iter().map(Value::Object).collect());
904 Ok(result)
905 }
906 "localRevision" => Ok(json!({
907 "revision": need_client(client)?.local_revision().to_string()
908 })),
909 "previousVersionSnapshot" | "previous_version_snapshot" => {
910 let spec = parse_previous_version_snapshot_spec(params)?;
911 let snapshot = need_client(client)?
912 .previous_version_snapshot(&spec)
913 .map_err(client_err)?;
914 serde_json::to_value(snapshot).map_err(|error| client_err(error.to_string()))
915 }
916 "previousVersionAudit" | "previous_version_audit" => {
917 Ok(match need_client(client)?.previous_version_audit() {
918 Some(audit) => {
919 serde_json::to_value(audit).map_err(|error| client_err(error.to_string()))?
920 }
921 None => Value::Null,
922 })
923 }
924 "previousVersionDiscard" | "previous_version_discard" => {
925 let outcome = need_client(client)?.previous_version_discard();
926 Ok(json!({
927 "present": outcome.present,
928 "discarded": outcome.discarded,
929 }))
930 }
931 "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
932 .expect("status serializes")),
933 "diagnosticsSnapshot" => {
934 let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
935 .map_err(|error| {
936 client_err(format!(
937 "sync.invalid_request: invalid diagnostics request: {error}"
938 ))
939 })?;
940 let snapshot = need_client(client)?
941 .diagnostics_snapshot(&request)
942 .map_err(client_err)?;
943 Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
944 }
945 "progressSnapshot" => Ok(
950 serde_json::to_value(need_client(client)?.progress().snapshot())
951 .expect("progress JSON"),
952 ),
953 "drainChangeBatches" => Ok(json!({
954 "batches": need_client(client)?.drain_change_batches()
955 })),
956 "drainSyncIntents" => Ok(json!({
957 "intents": need_client(client)?.drain_sync_intents()
958 })),
959 #[cfg(feature = "crdt-yjs")]
965 "crdtText" => {
966 let (table, row_id, column, name) = crdt_target(params)?;
967 let text = need_client(client)?
968 .crdt_text(&table, &row_id, &column, &name)
969 .map_err(client_err)?;
970 Ok(json!({ "text": text }))
971 }
972 #[cfg(feature = "crdt-yjs")]
973 "crdtInsertText" => {
974 let (table, row_id, column, name) = crdt_target(params)?;
975 let index = params
976 .get("index")
977 .and_then(Value::as_u64)
978 .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
979 as u32;
980 let value = params
981 .get("value")
982 .and_then(Value::as_str)
983 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
984 let id = need_client(client)?
985 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
986 .map_err(client_err)?;
987 Ok(json!({ "clientCommitId": id }))
988 }
989 #[cfg(feature = "crdt-yjs")]
990 "crdtDeleteText" => {
991 let (table, row_id, column, name) = crdt_target(params)?;
992 let index = params
993 .get("index")
994 .and_then(Value::as_u64)
995 .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
996 as u32;
997 let len = params
998 .get("len")
999 .and_then(Value::as_u64)
1000 .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
1001 as u32;
1002 let id = need_client(client)?
1003 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
1004 .map_err(client_err)?;
1005 Ok(json!({ "clientCommitId": id }))
1006 }
1007 #[cfg(feature = "crdt-yjs")]
1008 "crdtApplyUpdate" => {
1009 let (table, row_id, column, _name) = crdt_target(params)?;
1010 let update = value_bytes(params.get("update")).map_err(client_err)?;
1011 let id = need_client(client)?
1012 .crdt_apply_update(&table, &row_id, &column, &update)
1013 .map_err(client_err)?;
1014 Ok(json!({ "clientCommitId": id }))
1015 }
1016 #[cfg(not(feature = "crdt-yjs"))]
1017 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
1018 "client.crdt_unavailable".to_owned(),
1019 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
1020 )),
1021
1022 "uploadBlob" => {
1023 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1024 let media_type = params
1025 .get("mediaType")
1026 .and_then(Value::as_str)
1027 .map(str::to_owned);
1028 let name = params
1029 .get("name")
1030 .and_then(Value::as_str)
1031 .map(str::to_owned);
1032 let reference = need_client(client)?
1033 .upload_blob(&bytes, media_type, name)
1034 .map_err(client_err)?;
1035 Ok(json!({ "ref": reference }))
1036 }
1037 "fetchBlob" => {
1038 let blob = params
1039 .get("blob")
1040 .and_then(Value::as_str)
1041 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
1042 .to_owned();
1043 let blob = need_client(client)?.fetch_blob_bytes(transport, &blob)?;
1047 let mut value = serde_json::Map::new();
1048 value.insert("blobId".to_owned(), Value::from(blob.blob_id));
1049 value.insert("byteLength".to_owned(), Value::from(blob.byte_length));
1050 value.insert("bytes".to_owned(), bytes_value(&blob.bytes));
1051 if let Some(media_type) = blob.media_type {
1052 value.insert("mediaType".to_owned(), Value::from(media_type));
1053 }
1054 Ok(Value::Object(serde_json::Map::from_iter([(
1055 "blob".to_owned(),
1056 Value::Object(value),
1057 )])))
1058 }
1059 "conflicts" => {
1060 let conflicts = need_client(client)?.conflicts().to_vec();
1061 Ok(json!({ "conflicts": conflicts }))
1062 }
1063 "rejections" => {
1064 let rejections = need_client(client)?.rejections().to_vec();
1065 Ok(json!({ "rejections": rejections }))
1066 }
1067 "commitOutcome" => {
1068 let client_commit_id = params
1069 .get("clientCommitId")
1070 .and_then(Value::as_str)
1071 .ok_or_else(|| {
1072 client_err(
1073 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
1074 )
1075 })?;
1076 let outcome = need_client(client)?
1077 .commit_outcome(client_commit_id)
1078 .map_err(client_err)?;
1079 Ok(json!({ "outcome": outcome }))
1080 }
1081 "commitOutcomes" => {
1082 let query = serde_json::from_value::<CommitOutcomeQuery>(
1083 params.get("query").cloned().unwrap_or_else(|| json!({})),
1084 )
1085 .map_err(|error| {
1086 client_err(format!(
1087 "sync.invalid_request: invalid commit outcome query: {error}"
1088 ))
1089 })?;
1090 let outcomes = need_client(client)?
1091 .commit_outcomes(query)
1092 .map_err(client_err)?;
1093 Ok(json!({ "outcomes": outcomes }))
1094 }
1095 "resolveCommitOutcome" => {
1096 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
1097 params.get("input").cloned().ok_or_else(|| {
1098 client_err(
1099 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
1100 )
1101 })?,
1102 )
1103 .map_err(|error| {
1104 client_err(format!(
1105 "sync.invalid_request: invalid outcome resolution: {error}"
1106 ))
1107 })?;
1108 let outcome = need_client(client)?
1109 .resolve_commit_outcome(input)
1110 .map_err(client_err)?;
1111 Ok(json!({ "outcome": outcome }))
1112 }
1113 "pendingCommitIds" => {
1114 let ids = need_client(client)?.pending_commit_ids();
1115 Ok(json!({ "ids": ids }))
1116 }
1117 "pendingPayloads" => {
1118 let payloads = need_client(client)?.pending_payloads();
1119 Ok(json!({
1120 "payloads": payloads.iter().map(|p| bytes_value(p)).collect::<Vec<_>>()
1121 }))
1122 }
1123 "subscriptionState" => {
1124 let id = params
1125 .get("id")
1126 .and_then(Value::as_str)
1127 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
1128 let state = need_client(client)?.subscription_state(id);
1129 Ok(json!({ "state": state }))
1130 }
1131 "recreateWithSchema" => {
1132 let schema = params
1136 .get("schema")
1137 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
1138 need_client(client)?
1139 .recreate_with_schema(schema)
1140 .map_err(client_err)?;
1141 Ok(json!({}))
1142 }
1143 "connectRealtime" => {
1144 need_client(client)?
1145 .connect_realtime(transport)
1146 .map_err(client_err)?;
1147 Ok(json!({}))
1148 }
1149 "disconnectRealtime" => {
1150 need_client(client)?.disconnect_realtime(transport);
1151 Ok(json!({}))
1152 }
1153 "setPresence" => {
1154 let scope_key = params
1155 .get("scopeKey")
1156 .and_then(Value::as_str)
1157 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
1158 .to_owned();
1159 let doc = params.get("doc");
1161 let doc_ref = match doc {
1162 None | Some(Value::Null) => None,
1163 Some(v) => Some(v),
1164 };
1165 need_client(client)?
1166 .set_presence(transport, &scope_key, doc_ref)
1167 .map_err(client_err)?;
1168 Ok(json!({}))
1169 }
1170 "presence" => {
1171 let scope_key = params
1172 .get("scopeKey")
1173 .and_then(Value::as_str)
1174 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
1175 let peers = need_client(client)?.presence(scope_key);
1176 Ok(json!({ "peers": peers }))
1177 }
1178
1179 "messageRoundtrip" => {
1181 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1182 match decode_message(&bytes) {
1183 Ok(message) => Ok(json!({
1184 "ok": true,
1185 "bytes": bytes_value(&encode_message(&message)),
1186 "renderedJson": render_message(&message).to_string(),
1187 })),
1188 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1189 }
1190 }
1191 "segmentRoundtrip" => {
1192 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1193 match decode_rows_segment(&bytes) {
1194 Ok(segment) => Ok(json!({
1195 "ok": true,
1196 "bytes": bytes_value(&encode_rows_segment(&segment)),
1197 "renderedJson": render_rows_segment(&segment).to_string(),
1198 })),
1199 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1200 }
1201 }
1202 "realtimeKnown" => {
1203 let text = params
1204 .get("text")
1205 .and_then(Value::as_str)
1206 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
1207 let known = matches!(
1208 parse_control(text),
1209 Ok(ControlMessage::Hello { .. })
1210 | Ok(ControlMessage::Wake { .. })
1211 | Ok(ControlMessage::Heartbeat { .. })
1212 | Ok(ControlMessage::Presence { .. })
1213 );
1214 Ok(json!({ "value": known }))
1215 }
1216
1217 other => Err(client_err(format!("unknown method {other:?}"))),
1218 };
1219 if client
1220 .as_ref()
1221 .is_some_and(|running| !running.transport_enabled())
1222 {
1223 if let Ok(value) = &mut result {
1224 if let Some(effects) = value.get_mut("effects").and_then(Value::as_object_mut) {
1225 effects.remove("sync");
1226 }
1227 }
1228 }
1229 result
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234 use serde_json::{json, Value};
1235 use syncular_client::{
1236 Mutation, SegmentRequest, SyncClient, Transport, TransportError,
1237 SECURITY_PREFLIGHT_REQUIRED_CODE,
1238 };
1239
1240 use super::{
1241 dispatch, parse_encryption, parse_headers, parse_previous_version_context, CreateEffects,
1242 };
1243
1244 #[derive(Default)]
1245 struct NoNetwork {
1246 realtime_connects: usize,
1247 realtime_closes: usize,
1248 }
1249
1250 impl Transport for NoNetwork {
1251 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1252 Err(TransportError::new("sync.transport_failed", "offline"))
1253 }
1254
1255 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1256 Err(TransportError::new("sync.transport_failed", "offline"))
1257 }
1258
1259 fn download_segment(
1260 &mut self,
1261 _request: &SegmentRequest,
1262 _on_progress: &mut dyn FnMut(u64),
1263 ) -> Result<Vec<u8>, TransportError> {
1264 Err(TransportError::new("sync.transport_failed", "offline"))
1265 }
1266
1267 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1268 self.realtime_connects += 1;
1269 Ok(())
1270 }
1271
1272 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1273 Ok(())
1274 }
1275
1276 fn realtime_close(&mut self) -> Result<(), TransportError> {
1277 self.realtime_closes += 1;
1278 Ok(())
1279 }
1280 }
1281
1282 fn schema() -> Value {
1283 json!({
1284 "version": 1,
1285 "tables": [{
1286 "name": "todos",
1287 "columns": [
1288 { "name": "id", "type": "string", "nullable": false },
1289 { "name": "list_id", "type": "string", "nullable": false }
1290 ],
1291 "primaryKey": "id",
1292 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1293 }]
1294 })
1295 }
1296
1297 #[test]
1298 fn native_command_realtime_connection_is_idempotent() {
1299 let mut transport = NoNetwork::default();
1300 let mut client: Option<SyncClient> = None;
1301 let mut effects = CreateEffects::default();
1302 dispatch(
1303 &mut transport,
1304 &mut client,
1305 &mut effects,
1306 "create",
1307 &json!({ "schema": schema() }),
1308 )
1309 .expect("create client");
1310
1311 for _ in 0..2 {
1312 dispatch(
1313 &mut transport,
1314 &mut client,
1315 &mut effects,
1316 "connectRealtime",
1317 &json!({}),
1318 )
1319 .expect("idempotent connect command");
1320 }
1321 assert_eq!(transport.realtime_connects, 1);
1322
1323 dispatch(
1324 &mut transport,
1325 &mut client,
1326 &mut effects,
1327 "disconnectRealtime",
1328 &json!({}),
1329 )
1330 .expect("disconnect command");
1331 dispatch(
1332 &mut transport,
1333 &mut client,
1334 &mut effects,
1335 "disconnectRealtime",
1336 &json!({}),
1337 )
1338 .expect("idempotent disconnect command");
1339 assert_eq!(transport.realtime_closes, 1);
1340 dispatch(
1341 &mut transport,
1342 &mut client,
1343 &mut effects,
1344 "connectRealtime",
1345 &json!({}),
1346 )
1347 .expect("deliberate reconnect command");
1348 assert_eq!(transport.realtime_connects, 2);
1349 }
1350
1351 #[test]
1352 fn native_command_realtime_policy_rides_create_and_refuses_http() {
1353 let mut transport = NoNetwork::default();
1354 let mut client: Option<SyncClient> = None;
1355 let mut effects = CreateEffects::default();
1356 dispatch(
1357 &mut transport,
1358 &mut client,
1359 &mut effects,
1360 "create",
1361 &json!({ "schema": schema(), "realtimePolicy": "required" }),
1362 )
1363 .expect("create client with a required realtime policy");
1364 let result = dispatch(
1365 &mut transport,
1366 &mut client,
1367 &mut effects,
1368 "sync",
1369 &json!({}),
1370 )
1371 .expect("sync command");
1372 assert_eq!(result["ok"], json!(false));
1373 assert_eq!(result["errorCode"], json!("sync.realtime_unavailable"));
1374 assert_eq!(result["realtimeState"], json!("disconnected"));
1375 assert_eq!(result["retryDelayMs"], json!(250));
1376 let snapshot = dispatch(
1377 &mut transport,
1378 &mut client,
1379 &mut effects,
1380 "diagnosticsSnapshot",
1381 &json!({}),
1382 )
1383 .expect("diagnostics command");
1384 assert_eq!(snapshot["host"]["realtime"], json!("disconnected"));
1385 assert_eq!(snapshot["host"]["realtimePolicy"], json!("required"));
1386 assert_eq!(snapshot["host"]["realtimeRetryDelayMs"], json!(250));
1387
1388 let mut second: Option<SyncClient> = None;
1389 let error = dispatch(
1390 &mut transport,
1391 &mut second,
1392 &mut effects,
1393 "create",
1394 &json!({ "schema": schema(), "realtimePolicy": "always" }),
1395 )
1396 .expect_err("an unknown policy is refused");
1397 assert_eq!(error.0, "sync.invalid_request");
1398 }
1399
1400 #[test]
1401 fn parses_portable_encryption_keyring_and_key_id_columns() {
1402 let key_hex = "2a".repeat(32);
1403 let config = parse_encryption(&json!({
1404 "keys": { "practice-key-v1": { "$bytes": key_hex } },
1405 "keyIdColumns": { "patients": "encryption_key_id" }
1406 }))
1407 .expect("portable keyring parses");
1408 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1409 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1410 }
1411
1412 #[test]
1413 fn rejects_non_string_key_id_columns() {
1414 let error = parse_encryption(&json!({
1415 "keys": {},
1416 "keyIdColumns": { "patients": 7 }
1417 }))
1418 .expect_err("invalid selector must fail");
1419 assert!(error.contains("must be a string"), "{error}");
1420 }
1421
1422 #[test]
1423 fn native_command_hosts_reject_subscription_identity_rebinds() {
1424 let mut transport = NoNetwork::default();
1425 let mut client: Option<SyncClient> = None;
1426 let mut effects = CreateEffects::default();
1427 dispatch(
1428 &mut transport,
1429 &mut client,
1430 &mut effects,
1431 "create",
1432 &json!({ "schema": schema() }),
1433 )
1434 .expect("create client");
1435
1436 let original = json!({
1437 "id": "stable-subscription",
1438 "table": "todos",
1439 "scopes": { "list_id": ["list-2", "list-1"] },
1440 "params": "{\"view\":\"v1\"}"
1441 });
1442 dispatch(
1443 &mut transport,
1444 &mut client,
1445 &mut effects,
1446 "subscribe",
1447 &original,
1448 )
1449 .expect("register subscription");
1450 dispatch(
1451 &mut transport,
1452 &mut client,
1453 &mut effects,
1454 "subscribe",
1455 &json!({
1456 "id": "stable-subscription",
1457 "table": "todos",
1458 "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
1459 "params": "{\"view\":\"v1\"}"
1460 }),
1461 )
1462 .expect("canonical re-declaration is idempotent");
1463
1464 let error = dispatch(
1465 &mut transport,
1466 &mut client,
1467 &mut effects,
1468 "subscribe",
1469 &json!({
1470 "id": "stable-subscription",
1471 "table": "todos",
1472 "scopes": { "list_id": ["list-2"] },
1473 "params": "{\"view\":\"v1\"}"
1474 }),
1475 )
1476 .expect_err("changed native query identity must fail");
1477 assert_eq!(error.0, "client.subscription_intent_mismatch");
1478
1479 let state = dispatch(
1480 &mut transport,
1481 &mut client,
1482 &mut effects,
1483 "subscriptionState",
1484 &json!({ "id": "stable-subscription" }),
1485 )
1486 .expect("subscription state");
1487 assert_eq!(state["state"]["cursor"], -1);
1488 assert_eq!(state["state"]["table"], "todos");
1489 }
1490
1491 #[test]
1492 fn security_preflight_is_fail_closed_until_exact_activation() {
1493 let mut transport = NoNetwork::default();
1494 let mut client: Option<SyncClient> = None;
1495 let mut effects = CreateEffects::default();
1496 dispatch(
1497 &mut transport,
1498 &mut client,
1499 &mut effects,
1500 "create",
1501 &json!({ "schema": schema(), "securityPreflight": true }),
1502 )
1503 .expect("preflight create");
1504
1505 let lifecycle = dispatch(
1506 &mut transport,
1507 &mut client,
1508 &mut effects,
1509 "securityLifecycle",
1510 &json!({}),
1511 )
1512 .expect("lifecycle");
1513 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1514
1515 let query_error = dispatch(
1516 &mut transport,
1517 &mut client,
1518 &mut effects,
1519 "query",
1520 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1521 )
1522 .expect_err("protected query must fail");
1523 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1524 let diagnostics_error = dispatch(
1525 &mut transport,
1526 &mut client,
1527 &mut effects,
1528 "diagnosticsSnapshot",
1529 &json!({}),
1530 )
1531 .expect_err("diagnostics table/subscription evidence remains protected");
1532 assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1533
1534 dispatch(
1535 &mut transport,
1536 &mut client,
1537 &mut effects,
1538 "purgeLocalData",
1539 &json!({
1540 "input": {
1541 "purgeId": "directive-1",
1542 "targets": [{
1543 "table": "todos",
1544 "selectors": { "list_id": ["list-1"] }
1545 }]
1546 }
1547 }),
1548 )
1549 .expect("authorized local purge remains available");
1550
1551 let repair_error = dispatch(
1552 &mut transport,
1553 &mut client,
1554 &mut effects,
1555 "rebootstrapLocalData",
1556 &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1557 )
1558 .expect_err("projection repair must remain protected during preflight");
1559 assert_eq!(repair_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1560
1561 dispatch(
1562 &mut transport,
1563 &mut client,
1564 &mut effects,
1565 "activateSecurity",
1566 &json!({}),
1567 )
1568 .expect("activation");
1569 let rows = dispatch(
1570 &mut transport,
1571 &mut client,
1572 &mut effects,
1573 "query",
1574 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1575 )
1576 .expect("active query");
1577 assert_eq!(rows, json!({ "rows": [] }));
1578
1579 dispatch(
1580 &mut transport,
1581 &mut client,
1582 &mut effects,
1583 "beginSecurityPreflight",
1584 &json!({}),
1585 )
1586 .expect("re-enter preflight");
1587 let blocked = dispatch(
1588 &mut transport,
1589 &mut client,
1590 &mut effects,
1591 "mutate",
1592 &json!({ "mutations": [] }),
1593 )
1594 .expect_err("mutation must be gated");
1595 assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1596 }
1597
1598 #[test]
1599 fn preflight_allows_only_the_authorized_previous_version_discard() {
1600 let mut transport = NoNetwork::default();
1601 let mut client: Option<SyncClient> = None;
1602 let mut effects = CreateEffects::default();
1603 dispatch(
1604 &mut transport,
1605 &mut client,
1606 &mut effects,
1607 "create",
1608 &json!({ "schema": schema(), "securityPreflight": true }),
1609 )
1610 .expect("preflight create");
1611
1612 for method in ["previousVersionSnapshot", "previousVersionAudit"] {
1614 let error = dispatch(
1615 &mut transport,
1616 &mut client,
1617 &mut effects,
1618 method,
1619 &json!({}),
1620 )
1621 .expect_err("previous-version reads stay gated during preflight");
1622 assert_eq!(error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1623 }
1624 let discarded = dispatch(
1627 &mut transport,
1628 &mut client,
1629 &mut effects,
1630 "previousVersionDiscard",
1631 &json!({}),
1632 )
1633 .expect("authorized cleanup is allowed during preflight");
1634 assert_eq!(discarded, json!({ "present": false, "discarded": false }));
1635 assert!(client.as_ref().is_some_and(SyncClient::security_preflight));
1636 }
1637
1638 #[test]
1639 fn preflight_refuses_a_replacement_create_without_the_flag() {
1640 let mut transport = NoNetwork::default();
1641 let mut client: Option<SyncClient> = None;
1642 let mut effects = CreateEffects::default();
1643 dispatch(
1644 &mut transport,
1645 &mut client,
1646 &mut effects,
1647 "create",
1648 &json!({ "schema": schema(), "securityPreflight": true }),
1649 )
1650 .expect("preflight create");
1651
1652 let escape = dispatch(
1654 &mut transport,
1655 &mut client,
1656 &mut effects,
1657 "create",
1658 &json!({ "schema": schema() }),
1659 )
1660 .expect_err("plain create must be refused during preflight");
1661 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1662 let query_error = dispatch(
1664 &mut transport,
1665 &mut client,
1666 &mut effects,
1667 "query",
1668 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1669 )
1670 .expect_err("protected query stays gated");
1671 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1672
1673 dispatch(
1675 &mut transport,
1676 &mut client,
1677 &mut effects,
1678 "create",
1679 &json!({ "schema": schema(), "securityPreflight": true }),
1680 )
1681 .expect("preflighted replacement create");
1682 let still_gated = dispatch(
1683 &mut transport,
1684 &mut client,
1685 &mut effects,
1686 "query",
1687 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1688 )
1689 .expect_err("replacement stays gated");
1690 assert_eq!(still_gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1691
1692 dispatch(
1694 &mut transport,
1695 &mut client,
1696 &mut effects,
1697 "activateSecurity",
1698 &json!({}),
1699 )
1700 .expect("activation");
1701 dispatch(
1702 &mut transport,
1703 &mut client,
1704 &mut effects,
1705 "create",
1706 &json!({ "schema": schema() }),
1707 )
1708 .expect("plain create after activation");
1709 let rows = dispatch(
1710 &mut transport,
1711 &mut client,
1712 &mut effects,
1713 "query",
1714 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1715 )
1716 .expect("active query");
1717 assert_eq!(rows, json!({ "rows": [] }));
1718 }
1719
1720 fn temp_db_path(tag: &str) -> String {
1723 std::env::temp_dir()
1724 .join(format!(
1725 "syncular-command-{tag}-{}-{:?}.db",
1726 std::process::id(),
1727 std::time::SystemTime::now()
1728 .duration_since(std::time::UNIX_EPOCH)
1729 .expect("clock")
1730 .as_nanos()
1731 ))
1732 .to_string_lossy()
1733 .into_owned()
1734 }
1735
1736 #[test]
1737 fn an_activated_replica_reopens_plainly_after_shutdown() {
1738 let path = temp_db_path("activated-reopen");
1744 let mut transport = NoNetwork::default();
1745 let mut client: Option<SyncClient> = None;
1746 let mut effects = CreateEffects::default();
1747
1748 dispatch(
1749 &mut transport,
1750 &mut client,
1751 &mut effects,
1752 "create",
1753 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1754 )
1755 .expect("preflight create");
1756 dispatch(
1757 &mut transport,
1758 &mut client,
1759 &mut effects,
1760 "activateSecurity",
1761 &json!({}),
1762 )
1763 .expect("activation clears the gate");
1764 dispatch(
1765 &mut transport,
1766 &mut client,
1767 &mut effects,
1768 "shutdown",
1769 &json!({}),
1770 )
1771 .expect("shutdown after activation");
1772
1773 dispatch(
1775 &mut transport,
1776 &mut client,
1777 &mut effects,
1778 "create",
1779 &json!({ "schema": schema(), "dbPath": path }),
1780 )
1781 .expect("an activated replica must reopen plainly after shutdown");
1782 let state = dispatch(
1783 &mut transport,
1784 &mut client,
1785 &mut effects,
1786 "securityLifecycle",
1787 &json!({}),
1788 )
1789 .expect("lifecycle");
1790 assert_eq!(state, json!({ "state": "active" }));
1791
1792 let _ = std::fs::remove_file(&path);
1793 }
1794
1795 #[test]
1796 fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
1797 let path = temp_db_path("quarantined-reopen");
1802 let mut transport = NoNetwork::default();
1803 let mut client: Option<SyncClient> = None;
1804 let mut effects = CreateEffects::default();
1805
1806 dispatch(
1807 &mut transport,
1808 &mut client,
1809 &mut effects,
1810 "create",
1811 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1812 )
1813 .expect("preflight create");
1814 dispatch(
1815 &mut transport,
1816 &mut client,
1817 &mut effects,
1818 "shutdown",
1819 &json!({}),
1820 )
1821 .expect("shutdown during preflight");
1822
1823 let mut rebuilt = CreateEffects::default();
1826 let escape = dispatch(
1827 &mut transport,
1828 &mut client,
1829 &mut rebuilt,
1830 "create",
1831 &json!({ "schema": schema(), "dbPath": path }),
1832 )
1833 .expect_err("a quarantined replica must refuse a plain re-create");
1834 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1835
1836 let _ = std::fs::remove_file(&path);
1837 }
1838
1839 #[test]
1840 fn preflight_gate_survives_shutdown_before_replacement_creates() {
1841 let mut transport = NoNetwork::default();
1842 let mut client: Option<SyncClient> = None;
1843 let mut effects = CreateEffects::default();
1844 dispatch(
1845 &mut transport,
1846 &mut client,
1847 &mut effects,
1848 "create",
1849 &json!({ "schema": schema(), "securityPreflight": true }),
1850 )
1851 .expect("preflight create");
1852 dispatch(
1853 &mut transport,
1854 &mut client,
1855 &mut effects,
1856 "shutdown",
1857 &json!({}),
1858 )
1859 .expect("shutdown during preflight");
1860 assert!(client.is_none());
1861
1862 let escape = dispatch(
1864 &mut transport,
1865 &mut client,
1866 &mut effects,
1867 "create",
1868 &json!({ "schema": schema() }),
1869 )
1870 .expect_err("shutdown + plain create must stay refused");
1871 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1872
1873 dispatch(
1874 &mut transport,
1875 &mut client,
1876 &mut effects,
1877 "create",
1878 &json!({ "schema": schema(), "securityPreflight": true }),
1879 )
1880 .expect("preflighted re-create");
1881 dispatch(
1882 &mut transport,
1883 &mut client,
1884 &mut effects,
1885 "activateSecurity",
1886 &json!({}),
1887 )
1888 .expect("activation");
1889
1890 dispatch(
1892 &mut transport,
1893 &mut client,
1894 &mut effects,
1895 "shutdown",
1896 &json!({}),
1897 )
1898 .expect("shutdown after activation");
1899 dispatch(
1900 &mut transport,
1901 &mut client,
1902 &mut effects,
1903 "create",
1904 &json!({ "schema": schema() }),
1905 )
1906 .expect("plain create after an activated shutdown");
1907 }
1908
1909 #[test]
1910 fn activate_security_validates_optional_headers_atomically() {
1911 let mut transport = NoNetwork::default();
1912 let mut client: Option<SyncClient> = None;
1913 let mut effects = CreateEffects::default();
1914 dispatch(
1915 &mut transport,
1916 &mut client,
1917 &mut effects,
1918 "create",
1919 &json!({ "schema": schema(), "securityPreflight": true }),
1920 )
1921 .expect("preflight create");
1922
1923 let invalid = dispatch(
1925 &mut transport,
1926 &mut client,
1927 &mut effects,
1928 "activateSecurity",
1929 &json!({ "headers": { "authorization": 7 } }),
1930 )
1931 .expect_err("non-string header must fail");
1932 assert_eq!(invalid.0, "sync.invalid_request");
1933 let lifecycle = dispatch(
1934 &mut transport,
1935 &mut client,
1936 &mut effects,
1937 "securityLifecycle",
1938 &json!({}),
1939 )
1940 .expect("lifecycle");
1941 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1942
1943 dispatch(
1945 &mut transport,
1946 &mut client,
1947 &mut effects,
1948 "activateSecurity",
1949 &json!({ "headers": { "authorization": "Bearer fresh" } }),
1950 )
1951 .expect("activation with fresh headers");
1952 let lifecycle = dispatch(
1953 &mut transport,
1954 &mut client,
1955 &mut effects,
1956 "securityLifecycle",
1957 &json!({}),
1958 )
1959 .expect("lifecycle");
1960 assert_eq!(lifecycle, json!({ "state": "active" }));
1961 }
1962
1963 #[test]
1964 fn parse_headers_reads_the_full_replacement_set() {
1965 let parsed = parse_headers(&json!({
1966 "authorization": "Bearer fresh",
1967 "x-tenant": "t1"
1968 }))
1969 .expect("valid headers parse");
1970 assert_eq!(
1971 parsed,
1972 vec![
1973 ("authorization".to_owned(), "Bearer fresh".to_owned()),
1974 ("x-tenant".to_owned(), "t1".to_owned())
1975 ]
1976 );
1977 assert!(parse_headers(&json!(["authorization"])).is_err());
1978 assert!(parse_headers(&json!({ "authorization": null })).is_err());
1979 }
1980
1981 #[test]
1982 fn set_headers_validates_replacement_and_respects_preflight() {
1983 let mut transport = NoNetwork::default();
1984 let mut client: Option<SyncClient> = None;
1985 let mut effects = CreateEffects::default();
1986 dispatch(
1987 &mut transport,
1988 &mut client,
1989 &mut effects,
1990 "create",
1991 &json!({ "schema": schema() }),
1992 )
1993 .expect("create");
1994 dispatch(
1995 &mut transport,
1996 &mut client,
1997 &mut effects,
1998 "setHeaders",
1999 &json!({ "headers": { "authorization": "Bearer fresh" } }),
2000 )
2001 .expect("valid replacement");
2002 let invalid = dispatch(
2003 &mut transport,
2004 &mut client,
2005 &mut effects,
2006 "setHeaders",
2007 &json!({ "headers": { "authorization": 7 } }),
2008 )
2009 .expect_err("invalid replacement");
2010 assert_eq!(invalid.0, "sync.invalid_request");
2011
2012 dispatch(
2013 &mut transport,
2014 &mut client,
2015 &mut effects,
2016 "beginSecurityPreflight",
2017 &json!({}),
2018 )
2019 .expect("preflight");
2020 let gated = dispatch(
2021 &mut transport,
2022 &mut client,
2023 &mut effects,
2024 "setHeaders",
2025 &json!({ "headers": {} }),
2026 )
2027 .expect_err("direct rotation must respect preflight");
2028 assert_eq!(gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
2029 }
2030
2031 fn schema_v2() -> Value {
2032 json!({
2033 "version": 2,
2034 "tables": [{
2035 "name": "todos",
2036 "columns": [
2037 { "name": "id", "type": "string", "nullable": false },
2038 { "name": "list_id", "type": "string", "nullable": false },
2039 { "name": "note", "type": "string", "nullable": true }
2040 ],
2041 "primaryKey": "id",
2042 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2043 }]
2044 })
2045 }
2046
2047 fn previous_version_enabled() -> Value {
2048 json!({ "enabled": true })
2049 }
2050
2051 fn container_path(db_path: &str) -> String {
2052 format!("{db_path}.prev-context")
2053 }
2054
2055 #[test]
2056 fn previous_version_config_parser_matches_ts_keys_and_defaults() {
2057 assert_eq!(parse_previous_version_context(None).expect("absent"), None);
2058 let defaults = parse_previous_version_context(Some(&json!({})))
2059 .expect("empty")
2060 .expect("present");
2061 assert!(!defaults.enabled);
2062 assert_eq!(defaults.max_bytes, 8 * 1024 * 1024);
2063 assert_eq!(defaults.max_rows, 20_000);
2064 assert_eq!(defaults.max_tables, 32);
2065 assert_eq!(defaults.max_row_bytes, 1024 * 1024);
2066 assert_eq!(defaults.max_age_ms, 24 * 60 * 60 * 1000);
2067
2068 let configured = parse_previous_version_context(Some(&json!({
2069 "enabled": true,
2070 "maxBytes": 1024,
2071 "maxRows": 3,
2072 "maxTables": 2,
2073 "maxRowBytes": 5,
2074 "maxAgeMs": 7
2075 })))
2076 .expect("configured")
2077 .expect("present");
2078 assert!(configured.enabled);
2079 assert_eq!(configured.max_bytes, 1024);
2080 assert_eq!(configured.max_rows, 3);
2081 assert_eq!(configured.max_tables, 2);
2082 assert_eq!(configured.max_row_bytes, 5);
2083 assert_eq!(configured.max_age_ms, 7);
2084
2085 for (value, expected) in [
2087 (json!([]), "must be an object"),
2088 (json!({ "enabled": "yes" }), "enabled must be a boolean"),
2089 (
2090 json!({ "maxBytes": 0 }),
2091 "maxBytes must be a positive safe integer",
2092 ),
2093 (
2094 json!({ "maxRows": -1 }),
2095 "maxRows must be a positive safe integer",
2096 ),
2097 (
2098 json!({ "maxTables": 1.5 }),
2099 "maxTables must be a positive safe integer",
2100 ),
2101 (
2102 json!({ "maxRowBytes": "1" }),
2103 "maxRowBytes must be a positive safe integer",
2104 ),
2105 (
2106 json!({ "maxAgeMs": 0 }),
2107 "maxAgeMs must be a positive safe integer",
2108 ),
2109 ] {
2110 let error = parse_previous_version_context(Some(&value)).expect_err("must fail");
2111 assert_eq!(error.0, "sync.invalid_request", "{value}");
2112 assert!(error.1.contains(expected), "{value}: {}", error.1);
2113 }
2114 }
2115
2116 #[test]
2117 fn invalid_previous_version_config_fails_create_loudly() {
2118 let mut transport = NoNetwork::default();
2119 let mut client: Option<SyncClient> = None;
2120 let mut effects = CreateEffects::default();
2121 let error = dispatch(
2122 &mut transport,
2123 &mut client,
2124 &mut effects,
2125 "create",
2126 &json!({ "schema": schema(), "previousVersionContext": { "enabled": true, "maxRows": 0 } }),
2127 )
2128 .expect_err("invalid config must fail the create");
2129 assert_eq!(error.0, "sync.invalid_request");
2130 assert!(error.1.contains("maxRows"), "{}", error.1);
2131 assert!(client.is_none(), "no client may be installed");
2132 }
2133
2134 #[test]
2135 fn previous_version_snapshot_reports_not_configured_by_default() {
2136 let mut transport = NoNetwork::default();
2137 let mut client: Option<SyncClient> = None;
2138 let mut effects = CreateEffects::default();
2139 dispatch(
2140 &mut transport,
2141 &mut client,
2142 &mut effects,
2143 "create",
2144 &json!({ "schema": schema() }),
2145 )
2146 .expect("create");
2147 let snapshot = dispatch(
2148 &mut transport,
2149 &mut client,
2150 &mut effects,
2151 "previousVersionSnapshot",
2152 &json!({ "table": "todos" }),
2153 )
2154 .expect("snapshot");
2155 assert_eq!(snapshot["state"], json!("previousVersion"));
2156 assert_eq!(snapshot["available"], json!(false));
2157 assert_eq!(snapshot["reason"], json!("not-configured"));
2158 assert_eq!(snapshot["rows"], json!([]));
2159 assert_eq!(snapshot["truncated"], json!(false));
2160 assert_eq!(snapshot["currentVersion"], json!(1));
2161 let discarded = dispatch(
2162 &mut transport,
2163 &mut client,
2164 &mut effects,
2165 "previousVersionDiscard",
2166 &json!({}),
2167 )
2168 .expect("discard");
2169 assert_eq!(discarded, json!({ "present": false, "discarded": false }));
2170 assert_eq!(
2171 dispatch(
2172 &mut transport,
2173 &mut client,
2174 &mut effects,
2175 "previousVersionAudit",
2176 &json!({}),
2177 )
2178 .expect("audit"),
2179 Value::Null
2180 );
2181 }
2182
2183 #[test]
2184 fn previous_version_commands_round_trip_through_the_dispatcher() {
2185 let path = temp_db_path("previous-version");
2186 let mut transport = NoNetwork::default();
2187 let mut client: Option<SyncClient> = None;
2188 let mut effects = CreateEffects::default();
2189
2190 dispatch(
2192 &mut transport,
2193 &mut client,
2194 &mut effects,
2195 "create",
2196 &json!({
2197 "schema": schema(),
2198 "dbPath": path,
2199 "previousVersionContext": previous_version_enabled()
2200 }),
2201 )
2202 .expect("create v1");
2203 client
2204 .as_mut()
2205 .expect("client")
2206 .mutate(vec![Mutation::Upsert {
2207 table: "todos".to_owned(),
2208 values: serde_json::Map::from_iter([
2209 ("id".to_owned(), json!("t1")),
2210 ("list_id".to_owned(), json!("l1")),
2211 ]),
2212 base_version: None,
2213 }])
2214 .expect("seed a local row");
2215 dispatch(
2216 &mut transport,
2217 &mut client,
2218 &mut effects,
2219 "shutdown",
2220 &json!({}),
2221 )
2222 .expect("shutdown");
2223
2224 dispatch(
2226 &mut transport,
2227 &mut client,
2228 &mut effects,
2229 "create",
2230 &json!({
2231 "schema": schema_v2(),
2232 "dbPath": path,
2233 "previousVersionContext": previous_version_enabled()
2234 }),
2235 )
2236 .expect("create v2");
2237 assert!(
2238 std::path::Path::new(&container_path(&path)).exists(),
2239 "the bump must leave a container file"
2240 );
2241 let status = dispatch(
2242 &mut transport,
2243 &mut client,
2244 &mut effects,
2245 "statusSnapshot",
2246 &json!({}),
2247 )
2248 .expect("status");
2249 assert_eq!(
2250 status["previousVersionContext"]["present"],
2251 json!(true),
2252 "{status}"
2253 );
2254 assert!(
2255 status["previousVersionContext"]["createdAtMs"].is_i64(),
2256 "{status}"
2257 );
2258
2259 let snapshot = dispatch(
2260 &mut transport,
2261 &mut client,
2262 &mut effects,
2263 "previousVersionSnapshot",
2264 &json!({ "table": "todos" }),
2265 )
2266 .expect("snapshot");
2267 assert_eq!(snapshot["state"], json!("previousVersion"));
2268 assert_eq!(snapshot["available"], json!(true));
2269 assert_eq!(snapshot["previousVersion"], json!(1));
2270 assert_eq!(snapshot["currentVersion"], json!(2));
2271 assert_eq!(snapshot["truncated"], json!(false));
2272 assert_eq!(snapshot["rows"][0]["id"], json!("t1"));
2273 assert_eq!(snapshot["rows"][0]["list_id"], json!("l1"));
2274
2275 let unknown = dispatch(
2277 &mut transport,
2278 &mut client,
2279 &mut effects,
2280 "previousVersionSnapshot",
2281 &json!({ "table": "absent" }),
2282 )
2283 .expect_err("unknown previous table");
2284 assert_eq!(unknown.0, "sync.invalid_request");
2285 let bad_limit = dispatch(
2286 &mut transport,
2287 &mut client,
2288 &mut effects,
2289 "previousVersionSnapshot",
2290 &json!({ "table": "todos", "limit": 0 }),
2291 )
2292 .expect_err("bad limit");
2293 assert_eq!(bad_limit.0, "sync.invalid_request");
2294
2295 let audit = dispatch(
2298 &mut transport,
2299 &mut client,
2300 &mut effects,
2301 "previousVersionAudit",
2302 &json!({}),
2303 )
2304 .expect("audit");
2305 assert_eq!(audit["v"], json!(1));
2306 assert_eq!(audit["fromVersion"], json!(1));
2307 assert_eq!(audit["toVersion"], json!(2));
2308 assert_eq!(audit["pending"], json!(1));
2309 assert_eq!(audit["encodable"], json!(1));
2310 assert_eq!(audit["incompatible"], json!([]));
2311 assert!(audit.get("operations").is_none());
2312
2313 let discarded = dispatch(
2315 &mut transport,
2316 &mut client,
2317 &mut effects,
2318 "previousVersionDiscard",
2319 &json!({}),
2320 )
2321 .expect("discard");
2322 assert_eq!(discarded, json!({ "present": true, "discarded": true }));
2323 assert!(!std::path::Path::new(&container_path(&path)).exists());
2324 let again = dispatch(
2325 &mut transport,
2326 &mut client,
2327 &mut effects,
2328 "previousVersionDiscard",
2329 &json!({}),
2330 )
2331 .expect("idempotent discard");
2332 assert_eq!(again, json!({ "present": false, "discarded": false }));
2333
2334 let inspect = rusqlite::Connection::open(&path).expect("inspect replica");
2336 let remaining: i64 = inspect
2337 .query_row(
2338 "SELECT COUNT(*) FROM _syncular_meta WHERE key IN (?1, ?2)",
2339 rusqlite::params!["previousVersionContext", "previousVersionAudit"],
2340 |row| row.get(0),
2341 )
2342 .expect("count meta");
2343 assert_eq!(remaining, 0);
2344
2345 let _ = std::fs::remove_file(&path);
2346 }
2347
2348 #[test]
2349 fn previous_version_ttl_expires_at_read_through_the_dispatcher() {
2350 let path = temp_db_path("previous-version-ttl");
2351 let mut transport = NoNetwork::default();
2352 let mut client: Option<SyncClient> = None;
2353 let mut effects = CreateEffects::default();
2354 dispatch(
2355 &mut transport,
2356 &mut client,
2357 &mut effects,
2358 "create",
2359 &json!({
2360 "schema": schema(),
2361 "dbPath": path,
2362 "previousVersionContext": previous_version_enabled()
2363 }),
2364 )
2365 .expect("create v1");
2366 dispatch(
2367 &mut transport,
2368 &mut client,
2369 &mut effects,
2370 "shutdown",
2371 &json!({}),
2372 )
2373 .expect("shutdown");
2374
2375 let now_ms = std::time::SystemTime::now()
2379 .duration_since(std::time::UNIX_EPOCH)
2380 .expect("clock")
2381 .as_millis() as i64
2382 + 2_000;
2383 dispatch(
2384 &mut transport,
2385 &mut client,
2386 &mut effects,
2387 "create",
2388 &json!({
2389 "schema": schema_v2(),
2390 "dbPath": path,
2391 "nowMs": now_ms,
2392 "previousVersionContext": { "enabled": true, "maxAgeMs": 500 }
2393 }),
2394 )
2395 .expect("create v2");
2396 assert!(std::path::Path::new(&container_path(&path)).exists());
2397 let snapshot = dispatch(
2398 &mut transport,
2399 &mut client,
2400 &mut effects,
2401 "previousVersionSnapshot",
2402 &json!({ "table": "todos" }),
2403 )
2404 .expect("snapshot");
2405 assert_eq!(snapshot["available"], json!(false));
2406 assert_eq!(snapshot["reason"], json!("expired"));
2407 assert_eq!(snapshot["rows"], json!([]));
2408 assert!(!std::path::Path::new(&container_path(&path)).exists());
2410
2411 let _ = std::fs::remove_file(&path);
2412 }
2413}