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