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