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