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, ResolveCommitOutcomeInput,
26 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 mut snapshot = need_client(client)?
807 .query_snapshot(sql, bind, &coverage)
808 .map_err(client_err)?;
809 let rows = std::mem::take(&mut snapshot.rows);
810 let mut result = serde_json::to_value(snapshot).expect("snapshot serializes");
811 result["rows"] = Value::Array(rows.into_iter().map(Value::Object).collect());
812 Ok(result)
813 }
814 "localRevision" => Ok(json!({
815 "revision": need_client(client)?.local_revision().to_string()
816 })),
817 "previousVersionSnapshot" | "previous_version_snapshot" => {
818 let spec = parse_previous_version_snapshot_spec(params)?;
819 let snapshot = need_client(client)?
820 .previous_version_snapshot(&spec)
821 .map_err(client_err)?;
822 serde_json::to_value(snapshot).map_err(|error| client_err(error.to_string()))
823 }
824 "previousVersionAudit" | "previous_version_audit" => {
825 Ok(match need_client(client)?.previous_version_audit() {
826 Some(audit) => {
827 serde_json::to_value(audit).map_err(|error| client_err(error.to_string()))?
828 }
829 None => Value::Null,
830 })
831 }
832 "previousVersionDiscard" | "previous_version_discard" => {
833 let outcome = need_client(client)?.previous_version_discard();
834 Ok(json!({
835 "present": outcome.present,
836 "discarded": outcome.discarded,
837 }))
838 }
839 "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
840 .expect("status serializes")),
841 "diagnosticsSnapshot" => {
842 let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
843 .map_err(|error| {
844 client_err(format!(
845 "sync.invalid_request: invalid diagnostics request: {error}"
846 ))
847 })?;
848 let snapshot = need_client(client)?
849 .diagnostics_snapshot(&request)
850 .map_err(client_err)?;
851 Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
852 }
853 "progressSnapshot" => Ok(
858 serde_json::to_value(need_client(client)?.progress().snapshot())
859 .expect("progress JSON"),
860 ),
861 "drainChangeBatches" => Ok(json!({
862 "batches": need_client(client)?.drain_change_batches()
863 })),
864 "drainSyncIntents" => Ok(json!({
865 "intents": need_client(client)?.drain_sync_intents()
866 })),
867 #[cfg(feature = "crdt-yjs")]
873 "crdtText" => {
874 let (table, row_id, column, name) = crdt_target(params)?;
875 let text = need_client(client)?
876 .crdt_text(&table, &row_id, &column, &name)
877 .map_err(client_err)?;
878 Ok(json!({ "text": text }))
879 }
880 #[cfg(feature = "crdt-yjs")]
881 "crdtInsertText" => {
882 let (table, row_id, column, name) = crdt_target(params)?;
883 let index = params
884 .get("index")
885 .and_then(Value::as_u64)
886 .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
887 as u32;
888 let value = params
889 .get("value")
890 .and_then(Value::as_str)
891 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
892 let id = need_client(client)?
893 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
894 .map_err(client_err)?;
895 Ok(json!({ "clientCommitId": id }))
896 }
897 #[cfg(feature = "crdt-yjs")]
898 "crdtDeleteText" => {
899 let (table, row_id, column, name) = crdt_target(params)?;
900 let index = params
901 .get("index")
902 .and_then(Value::as_u64)
903 .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
904 as u32;
905 let len = params
906 .get("len")
907 .and_then(Value::as_u64)
908 .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
909 as u32;
910 let id = need_client(client)?
911 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
912 .map_err(client_err)?;
913 Ok(json!({ "clientCommitId": id }))
914 }
915 #[cfg(feature = "crdt-yjs")]
916 "crdtApplyUpdate" => {
917 let (table, row_id, column, _name) = crdt_target(params)?;
918 let update = value_bytes(params.get("update")).map_err(client_err)?;
919 let id = need_client(client)?
920 .crdt_apply_update(&table, &row_id, &column, &update)
921 .map_err(client_err)?;
922 Ok(json!({ "clientCommitId": id }))
923 }
924 #[cfg(not(feature = "crdt-yjs"))]
925 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
926 "client.crdt_unavailable".to_owned(),
927 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
928 )),
929
930 "uploadBlob" => {
931 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
932 let media_type = params
933 .get("mediaType")
934 .and_then(Value::as_str)
935 .map(str::to_owned);
936 let name = params
937 .get("name")
938 .and_then(Value::as_str)
939 .map(str::to_owned);
940 let reference = need_client(client)?
941 .upload_blob(&bytes, media_type, name)
942 .map_err(client_err)?;
943 Ok(json!({ "ref": reference }))
944 }
945 "fetchBlob" => {
946 let blob = params
947 .get("blob")
948 .and_then(Value::as_str)
949 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
950 .to_owned();
951 let blob = need_client(client)?.fetch_blob_bytes(transport, &blob)?;
955 let mut value = serde_json::Map::new();
956 value.insert("blobId".to_owned(), Value::from(blob.blob_id));
957 value.insert("byteLength".to_owned(), Value::from(blob.byte_length));
958 value.insert("bytes".to_owned(), bytes_value(&blob.bytes));
959 if let Some(media_type) = blob.media_type {
960 value.insert("mediaType".to_owned(), Value::from(media_type));
961 }
962 Ok(Value::Object(serde_json::Map::from_iter([(
963 "blob".to_owned(),
964 Value::Object(value),
965 )])))
966 }
967 "conflicts" => {
968 let conflicts = need_client(client)?.conflicts().to_vec();
969 Ok(json!({ "conflicts": conflicts }))
970 }
971 "rejections" => {
972 let rejections = need_client(client)?.rejections().to_vec();
973 Ok(json!({ "rejections": rejections }))
974 }
975 "commitOutcome" => {
976 let client_commit_id = params
977 .get("clientCommitId")
978 .and_then(Value::as_str)
979 .ok_or_else(|| {
980 client_err(
981 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
982 )
983 })?;
984 let outcome = need_client(client)?
985 .commit_outcome(client_commit_id)
986 .map_err(client_err)?;
987 Ok(json!({ "outcome": outcome }))
988 }
989 "commitOutcomes" => {
990 let query = serde_json::from_value::<CommitOutcomeQuery>(
991 params.get("query").cloned().unwrap_or_else(|| json!({})),
992 )
993 .map_err(|error| {
994 client_err(format!(
995 "sync.invalid_request: invalid commit outcome query: {error}"
996 ))
997 })?;
998 let outcomes = need_client(client)?
999 .commit_outcomes(query)
1000 .map_err(client_err)?;
1001 Ok(json!({ "outcomes": outcomes }))
1002 }
1003 "resolveCommitOutcome" => {
1004 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
1005 params.get("input").cloned().ok_or_else(|| {
1006 client_err(
1007 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
1008 )
1009 })?,
1010 )
1011 .map_err(|error| {
1012 client_err(format!(
1013 "sync.invalid_request: invalid outcome resolution: {error}"
1014 ))
1015 })?;
1016 let outcome = need_client(client)?
1017 .resolve_commit_outcome(input)
1018 .map_err(client_err)?;
1019 Ok(json!({ "outcome": outcome }))
1020 }
1021 "pendingCommitIds" => {
1022 let ids = need_client(client)?.pending_commit_ids();
1023 Ok(json!({ "ids": ids }))
1024 }
1025 "pendingPayloads" => {
1026 let payloads = need_client(client)?.pending_payloads();
1027 Ok(json!({
1028 "payloads": payloads.iter().map(|p| bytes_value(p)).collect::<Vec<_>>()
1029 }))
1030 }
1031 "subscriptionState" => {
1032 let id = params
1033 .get("id")
1034 .and_then(Value::as_str)
1035 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
1036 let state = need_client(client)?.subscription_state(id);
1037 Ok(json!({ "state": state }))
1038 }
1039 "recreateWithSchema" => {
1040 let schema = params
1044 .get("schema")
1045 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
1046 need_client(client)?
1047 .recreate_with_schema(schema)
1048 .map_err(client_err)?;
1049 Ok(json!({}))
1050 }
1051 "connectRealtime" => {
1052 need_client(client)?
1053 .connect_realtime(transport)
1054 .map_err(client_err)?;
1055 Ok(json!({}))
1056 }
1057 "disconnectRealtime" => {
1058 need_client(client)?.disconnect_realtime(transport);
1059 Ok(json!({}))
1060 }
1061 "setPresence" => {
1062 let scope_key = params
1063 .get("scopeKey")
1064 .and_then(Value::as_str)
1065 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
1066 .to_owned();
1067 let doc = params.get("doc");
1069 let doc_ref = match doc {
1070 None | Some(Value::Null) => None,
1071 Some(v) => Some(v),
1072 };
1073 need_client(client)?
1074 .set_presence(transport, &scope_key, doc_ref)
1075 .map_err(client_err)?;
1076 Ok(json!({}))
1077 }
1078 "presence" => {
1079 let scope_key = params
1080 .get("scopeKey")
1081 .and_then(Value::as_str)
1082 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
1083 let peers = need_client(client)?.presence(scope_key);
1084 Ok(json!({ "peers": peers }))
1085 }
1086
1087 "messageRoundtrip" => {
1089 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1090 match decode_message(&bytes) {
1091 Ok(message) => Ok(json!({
1092 "ok": true,
1093 "bytes": bytes_value(&encode_message(&message)),
1094 "renderedJson": render_message(&message).to_string(),
1095 })),
1096 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1097 }
1098 }
1099 "segmentRoundtrip" => {
1100 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1101 match decode_rows_segment(&bytes) {
1102 Ok(segment) => Ok(json!({
1103 "ok": true,
1104 "bytes": bytes_value(&encode_rows_segment(&segment)),
1105 "renderedJson": render_rows_segment(&segment).to_string(),
1106 })),
1107 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1108 }
1109 }
1110 "realtimeKnown" => {
1111 let text = params
1112 .get("text")
1113 .and_then(Value::as_str)
1114 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
1115 let known = matches!(
1116 parse_control(text),
1117 Ok(ControlMessage::Hello { .. })
1118 | Ok(ControlMessage::Wake { .. })
1119 | Ok(ControlMessage::Heartbeat { .. })
1120 | Ok(ControlMessage::Presence { .. })
1121 );
1122 Ok(json!({ "value": known }))
1123 }
1124
1125 other => Err(client_err(format!("unknown method {other:?}"))),
1126 }
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131 use serde_json::{json, Value};
1132 use syncular_client::{
1133 Mutation, SegmentRequest, SyncClient, Transport, TransportError,
1134 SECURITY_PREFLIGHT_REQUIRED_CODE,
1135 };
1136
1137 use super::{
1138 dispatch, parse_encryption, parse_headers, parse_previous_version_context, CreateEffects,
1139 };
1140
1141 #[derive(Default)]
1142 struct NoNetwork {
1143 realtime_connects: usize,
1144 realtime_closes: usize,
1145 }
1146
1147 impl Transport for NoNetwork {
1148 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1149 Err(TransportError::new("sync.transport_failed", "offline"))
1150 }
1151
1152 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1153 Err(TransportError::new("sync.transport_failed", "offline"))
1154 }
1155
1156 fn download_segment(
1157 &mut self,
1158 _request: &SegmentRequest,
1159 _on_progress: &mut dyn FnMut(u64),
1160 ) -> Result<Vec<u8>, TransportError> {
1161 Err(TransportError::new("sync.transport_failed", "offline"))
1162 }
1163
1164 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1165 self.realtime_connects += 1;
1166 Ok(())
1167 }
1168
1169 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1170 Ok(())
1171 }
1172
1173 fn realtime_close(&mut self) -> Result<(), TransportError> {
1174 self.realtime_closes += 1;
1175 Ok(())
1176 }
1177 }
1178
1179 fn schema() -> Value {
1180 json!({
1181 "version": 1,
1182 "tables": [{
1183 "name": "todos",
1184 "columns": [
1185 { "name": "id", "type": "string", "nullable": false },
1186 { "name": "list_id", "type": "string", "nullable": false }
1187 ],
1188 "primaryKey": "id",
1189 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1190 }]
1191 })
1192 }
1193
1194 #[test]
1195 fn native_command_realtime_connection_is_idempotent() {
1196 let mut transport = NoNetwork::default();
1197 let mut client: Option<SyncClient> = None;
1198 let mut effects = CreateEffects::default();
1199 dispatch(
1200 &mut transport,
1201 &mut client,
1202 &mut effects,
1203 "create",
1204 &json!({ "schema": schema() }),
1205 )
1206 .expect("create client");
1207
1208 for _ in 0..2 {
1209 dispatch(
1210 &mut transport,
1211 &mut client,
1212 &mut effects,
1213 "connectRealtime",
1214 &json!({}),
1215 )
1216 .expect("idempotent connect command");
1217 }
1218 assert_eq!(transport.realtime_connects, 1);
1219
1220 dispatch(
1221 &mut transport,
1222 &mut client,
1223 &mut effects,
1224 "disconnectRealtime",
1225 &json!({}),
1226 )
1227 .expect("disconnect command");
1228 dispatch(
1229 &mut transport,
1230 &mut client,
1231 &mut effects,
1232 "disconnectRealtime",
1233 &json!({}),
1234 )
1235 .expect("idempotent disconnect command");
1236 assert_eq!(transport.realtime_closes, 1);
1237 dispatch(
1238 &mut transport,
1239 &mut client,
1240 &mut effects,
1241 "connectRealtime",
1242 &json!({}),
1243 )
1244 .expect("deliberate reconnect command");
1245 assert_eq!(transport.realtime_connects, 2);
1246 }
1247
1248 #[test]
1249 fn parses_portable_encryption_keyring_and_key_id_columns() {
1250 let key_hex = "2a".repeat(32);
1251 let config = parse_encryption(&json!({
1252 "keys": { "practice-key-v1": { "$bytes": key_hex } },
1253 "keyIdColumns": { "patients": "encryption_key_id" }
1254 }))
1255 .expect("portable keyring parses");
1256 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1257 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1258 }
1259
1260 #[test]
1261 fn rejects_non_string_key_id_columns() {
1262 let error = parse_encryption(&json!({
1263 "keys": {},
1264 "keyIdColumns": { "patients": 7 }
1265 }))
1266 .expect_err("invalid selector must fail");
1267 assert!(error.contains("must be a string"), "{error}");
1268 }
1269
1270 #[test]
1271 fn native_command_hosts_reject_subscription_identity_rebinds() {
1272 let mut transport = NoNetwork::default();
1273 let mut client: Option<SyncClient> = None;
1274 let mut effects = CreateEffects::default();
1275 dispatch(
1276 &mut transport,
1277 &mut client,
1278 &mut effects,
1279 "create",
1280 &json!({ "schema": schema() }),
1281 )
1282 .expect("create client");
1283
1284 let original = json!({
1285 "id": "stable-subscription",
1286 "table": "todos",
1287 "scopes": { "list_id": ["list-2", "list-1"] },
1288 "params": "{\"view\":\"v1\"}"
1289 });
1290 dispatch(
1291 &mut transport,
1292 &mut client,
1293 &mut effects,
1294 "subscribe",
1295 &original,
1296 )
1297 .expect("register subscription");
1298 dispatch(
1299 &mut transport,
1300 &mut client,
1301 &mut effects,
1302 "subscribe",
1303 &json!({
1304 "id": "stable-subscription",
1305 "table": "todos",
1306 "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
1307 "params": "{\"view\":\"v1\"}"
1308 }),
1309 )
1310 .expect("canonical re-declaration is idempotent");
1311
1312 let error = dispatch(
1313 &mut transport,
1314 &mut client,
1315 &mut effects,
1316 "subscribe",
1317 &json!({
1318 "id": "stable-subscription",
1319 "table": "todos",
1320 "scopes": { "list_id": ["list-2"] },
1321 "params": "{\"view\":\"v1\"}"
1322 }),
1323 )
1324 .expect_err("changed native query identity must fail");
1325 assert_eq!(error.0, "client.subscription_intent_mismatch");
1326
1327 let state = dispatch(
1328 &mut transport,
1329 &mut client,
1330 &mut effects,
1331 "subscriptionState",
1332 &json!({ "id": "stable-subscription" }),
1333 )
1334 .expect("subscription state");
1335 assert_eq!(state["state"]["cursor"], -1);
1336 assert_eq!(state["state"]["table"], "todos");
1337 }
1338
1339 #[test]
1340 fn security_preflight_is_fail_closed_until_exact_activation() {
1341 let mut transport = NoNetwork::default();
1342 let mut client: Option<SyncClient> = None;
1343 let mut effects = CreateEffects::default();
1344 dispatch(
1345 &mut transport,
1346 &mut client,
1347 &mut effects,
1348 "create",
1349 &json!({ "schema": schema(), "securityPreflight": true }),
1350 )
1351 .expect("preflight create");
1352
1353 let lifecycle = dispatch(
1354 &mut transport,
1355 &mut client,
1356 &mut effects,
1357 "securityLifecycle",
1358 &json!({}),
1359 )
1360 .expect("lifecycle");
1361 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1362
1363 let query_error = dispatch(
1364 &mut transport,
1365 &mut client,
1366 &mut effects,
1367 "query",
1368 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1369 )
1370 .expect_err("protected query must fail");
1371 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1372 let diagnostics_error = dispatch(
1373 &mut transport,
1374 &mut client,
1375 &mut effects,
1376 "diagnosticsSnapshot",
1377 &json!({}),
1378 )
1379 .expect_err("diagnostics table/subscription evidence remains protected");
1380 assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1381
1382 dispatch(
1383 &mut transport,
1384 &mut client,
1385 &mut effects,
1386 "purgeLocalData",
1387 &json!({
1388 "input": {
1389 "purgeId": "directive-1",
1390 "targets": [{
1391 "table": "todos",
1392 "selectors": { "list_id": ["list-1"] }
1393 }]
1394 }
1395 }),
1396 )
1397 .expect("authorized local purge remains available");
1398
1399 let repair_error = dispatch(
1400 &mut transport,
1401 &mut client,
1402 &mut effects,
1403 "rebootstrapLocalData",
1404 &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1405 )
1406 .expect_err("projection repair must remain protected during preflight");
1407 assert_eq!(repair_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1408
1409 dispatch(
1410 &mut transport,
1411 &mut client,
1412 &mut effects,
1413 "activateSecurity",
1414 &json!({}),
1415 )
1416 .expect("activation");
1417 let rows = dispatch(
1418 &mut transport,
1419 &mut client,
1420 &mut effects,
1421 "query",
1422 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1423 )
1424 .expect("active query");
1425 assert_eq!(rows, json!({ "rows": [] }));
1426
1427 dispatch(
1428 &mut transport,
1429 &mut client,
1430 &mut effects,
1431 "beginSecurityPreflight",
1432 &json!({}),
1433 )
1434 .expect("re-enter preflight");
1435 let blocked = dispatch(
1436 &mut transport,
1437 &mut client,
1438 &mut effects,
1439 "mutate",
1440 &json!({ "mutations": [] }),
1441 )
1442 .expect_err("mutation must be gated");
1443 assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1444 }
1445
1446 #[test]
1447 fn preflight_allows_only_the_authorized_previous_version_discard() {
1448 let mut transport = NoNetwork::default();
1449 let mut client: Option<SyncClient> = None;
1450 let mut effects = CreateEffects::default();
1451 dispatch(
1452 &mut transport,
1453 &mut client,
1454 &mut effects,
1455 "create",
1456 &json!({ "schema": schema(), "securityPreflight": true }),
1457 )
1458 .expect("preflight create");
1459
1460 for method in ["previousVersionSnapshot", "previousVersionAudit"] {
1462 let error = dispatch(
1463 &mut transport,
1464 &mut client,
1465 &mut effects,
1466 method,
1467 &json!({}),
1468 )
1469 .expect_err("previous-version reads stay gated during preflight");
1470 assert_eq!(error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1471 }
1472 let discarded = dispatch(
1475 &mut transport,
1476 &mut client,
1477 &mut effects,
1478 "previousVersionDiscard",
1479 &json!({}),
1480 )
1481 .expect("authorized cleanup is allowed during preflight");
1482 assert_eq!(discarded, json!({ "present": false, "discarded": false }));
1483 assert!(client.as_ref().is_some_and(SyncClient::security_preflight));
1484 }
1485
1486 #[test]
1487 fn preflight_refuses_a_replacement_create_without_the_flag() {
1488 let mut transport = NoNetwork::default();
1489 let mut client: Option<SyncClient> = None;
1490 let mut effects = CreateEffects::default();
1491 dispatch(
1492 &mut transport,
1493 &mut client,
1494 &mut effects,
1495 "create",
1496 &json!({ "schema": schema(), "securityPreflight": true }),
1497 )
1498 .expect("preflight create");
1499
1500 let escape = dispatch(
1502 &mut transport,
1503 &mut client,
1504 &mut effects,
1505 "create",
1506 &json!({ "schema": schema() }),
1507 )
1508 .expect_err("plain create must be refused during preflight");
1509 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1510 let query_error = dispatch(
1512 &mut transport,
1513 &mut client,
1514 &mut effects,
1515 "query",
1516 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1517 )
1518 .expect_err("protected query stays gated");
1519 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1520
1521 dispatch(
1523 &mut transport,
1524 &mut client,
1525 &mut effects,
1526 "create",
1527 &json!({ "schema": schema(), "securityPreflight": true }),
1528 )
1529 .expect("preflighted replacement create");
1530 let still_gated = dispatch(
1531 &mut transport,
1532 &mut client,
1533 &mut effects,
1534 "query",
1535 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1536 )
1537 .expect_err("replacement stays gated");
1538 assert_eq!(still_gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1539
1540 dispatch(
1542 &mut transport,
1543 &mut client,
1544 &mut effects,
1545 "activateSecurity",
1546 &json!({}),
1547 )
1548 .expect("activation");
1549 dispatch(
1550 &mut transport,
1551 &mut client,
1552 &mut effects,
1553 "create",
1554 &json!({ "schema": schema() }),
1555 )
1556 .expect("plain create after activation");
1557 let rows = dispatch(
1558 &mut transport,
1559 &mut client,
1560 &mut effects,
1561 "query",
1562 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1563 )
1564 .expect("active query");
1565 assert_eq!(rows, json!({ "rows": [] }));
1566 }
1567
1568 fn temp_db_path(tag: &str) -> String {
1571 std::env::temp_dir()
1572 .join(format!(
1573 "syncular-command-{tag}-{}-{:?}.db",
1574 std::process::id(),
1575 std::time::SystemTime::now()
1576 .duration_since(std::time::UNIX_EPOCH)
1577 .expect("clock")
1578 .as_nanos()
1579 ))
1580 .to_string_lossy()
1581 .into_owned()
1582 }
1583
1584 #[test]
1585 fn an_activated_replica_reopens_plainly_after_shutdown() {
1586 let path = temp_db_path("activated-reopen");
1592 let mut transport = NoNetwork::default();
1593 let mut client: Option<SyncClient> = None;
1594 let mut effects = CreateEffects::default();
1595
1596 dispatch(
1597 &mut transport,
1598 &mut client,
1599 &mut effects,
1600 "create",
1601 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1602 )
1603 .expect("preflight create");
1604 dispatch(
1605 &mut transport,
1606 &mut client,
1607 &mut effects,
1608 "activateSecurity",
1609 &json!({}),
1610 )
1611 .expect("activation clears the gate");
1612 dispatch(
1613 &mut transport,
1614 &mut client,
1615 &mut effects,
1616 "shutdown",
1617 &json!({}),
1618 )
1619 .expect("shutdown after activation");
1620
1621 dispatch(
1623 &mut transport,
1624 &mut client,
1625 &mut effects,
1626 "create",
1627 &json!({ "schema": schema(), "dbPath": path }),
1628 )
1629 .expect("an activated replica must reopen plainly after shutdown");
1630 let state = dispatch(
1631 &mut transport,
1632 &mut client,
1633 &mut effects,
1634 "securityLifecycle",
1635 &json!({}),
1636 )
1637 .expect("lifecycle");
1638 assert_eq!(state, json!({ "state": "active" }));
1639
1640 let _ = std::fs::remove_file(&path);
1641 }
1642
1643 #[test]
1644 fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
1645 let path = temp_db_path("quarantined-reopen");
1650 let mut transport = NoNetwork::default();
1651 let mut client: Option<SyncClient> = None;
1652 let mut effects = CreateEffects::default();
1653
1654 dispatch(
1655 &mut transport,
1656 &mut client,
1657 &mut effects,
1658 "create",
1659 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1660 )
1661 .expect("preflight create");
1662 dispatch(
1663 &mut transport,
1664 &mut client,
1665 &mut effects,
1666 "shutdown",
1667 &json!({}),
1668 )
1669 .expect("shutdown during preflight");
1670
1671 let mut rebuilt = CreateEffects::default();
1674 let escape = dispatch(
1675 &mut transport,
1676 &mut client,
1677 &mut rebuilt,
1678 "create",
1679 &json!({ "schema": schema(), "dbPath": path }),
1680 )
1681 .expect_err("a quarantined replica must refuse a plain re-create");
1682 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1683
1684 let _ = std::fs::remove_file(&path);
1685 }
1686
1687 #[test]
1688 fn preflight_gate_survives_shutdown_before_replacement_creates() {
1689 let mut transport = NoNetwork::default();
1690 let mut client: Option<SyncClient> = None;
1691 let mut effects = CreateEffects::default();
1692 dispatch(
1693 &mut transport,
1694 &mut client,
1695 &mut effects,
1696 "create",
1697 &json!({ "schema": schema(), "securityPreflight": true }),
1698 )
1699 .expect("preflight create");
1700 dispatch(
1701 &mut transport,
1702 &mut client,
1703 &mut effects,
1704 "shutdown",
1705 &json!({}),
1706 )
1707 .expect("shutdown during preflight");
1708 assert!(client.is_none());
1709
1710 let escape = dispatch(
1712 &mut transport,
1713 &mut client,
1714 &mut effects,
1715 "create",
1716 &json!({ "schema": schema() }),
1717 )
1718 .expect_err("shutdown + plain create must stay refused");
1719 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1720
1721 dispatch(
1722 &mut transport,
1723 &mut client,
1724 &mut effects,
1725 "create",
1726 &json!({ "schema": schema(), "securityPreflight": true }),
1727 )
1728 .expect("preflighted re-create");
1729 dispatch(
1730 &mut transport,
1731 &mut client,
1732 &mut effects,
1733 "activateSecurity",
1734 &json!({}),
1735 )
1736 .expect("activation");
1737
1738 dispatch(
1740 &mut transport,
1741 &mut client,
1742 &mut effects,
1743 "shutdown",
1744 &json!({}),
1745 )
1746 .expect("shutdown after activation");
1747 dispatch(
1748 &mut transport,
1749 &mut client,
1750 &mut effects,
1751 "create",
1752 &json!({ "schema": schema() }),
1753 )
1754 .expect("plain create after an activated shutdown");
1755 }
1756
1757 #[test]
1758 fn activate_security_validates_optional_headers_atomically() {
1759 let mut transport = NoNetwork::default();
1760 let mut client: Option<SyncClient> = None;
1761 let mut effects = CreateEffects::default();
1762 dispatch(
1763 &mut transport,
1764 &mut client,
1765 &mut effects,
1766 "create",
1767 &json!({ "schema": schema(), "securityPreflight": true }),
1768 )
1769 .expect("preflight create");
1770
1771 let invalid = dispatch(
1773 &mut transport,
1774 &mut client,
1775 &mut effects,
1776 "activateSecurity",
1777 &json!({ "headers": { "authorization": 7 } }),
1778 )
1779 .expect_err("non-string header must fail");
1780 assert_eq!(invalid.0, "sync.invalid_request");
1781 let lifecycle = dispatch(
1782 &mut transport,
1783 &mut client,
1784 &mut effects,
1785 "securityLifecycle",
1786 &json!({}),
1787 )
1788 .expect("lifecycle");
1789 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1790
1791 dispatch(
1793 &mut transport,
1794 &mut client,
1795 &mut effects,
1796 "activateSecurity",
1797 &json!({ "headers": { "authorization": "Bearer fresh" } }),
1798 )
1799 .expect("activation with fresh headers");
1800 let lifecycle = dispatch(
1801 &mut transport,
1802 &mut client,
1803 &mut effects,
1804 "securityLifecycle",
1805 &json!({}),
1806 )
1807 .expect("lifecycle");
1808 assert_eq!(lifecycle, json!({ "state": "active" }));
1809 }
1810
1811 #[test]
1812 fn parse_headers_reads_the_full_replacement_set() {
1813 let parsed = parse_headers(&json!({
1814 "authorization": "Bearer fresh",
1815 "x-tenant": "t1"
1816 }))
1817 .expect("valid headers parse");
1818 assert_eq!(
1819 parsed,
1820 vec![
1821 ("authorization".to_owned(), "Bearer fresh".to_owned()),
1822 ("x-tenant".to_owned(), "t1".to_owned())
1823 ]
1824 );
1825 assert!(parse_headers(&json!(["authorization"])).is_err());
1826 assert!(parse_headers(&json!({ "authorization": null })).is_err());
1827 }
1828
1829 #[test]
1830 fn set_headers_validates_replacement_and_respects_preflight() {
1831 let mut transport = NoNetwork::default();
1832 let mut client: Option<SyncClient> = None;
1833 let mut effects = CreateEffects::default();
1834 dispatch(
1835 &mut transport,
1836 &mut client,
1837 &mut effects,
1838 "create",
1839 &json!({ "schema": schema() }),
1840 )
1841 .expect("create");
1842 dispatch(
1843 &mut transport,
1844 &mut client,
1845 &mut effects,
1846 "setHeaders",
1847 &json!({ "headers": { "authorization": "Bearer fresh" } }),
1848 )
1849 .expect("valid replacement");
1850 let invalid = dispatch(
1851 &mut transport,
1852 &mut client,
1853 &mut effects,
1854 "setHeaders",
1855 &json!({ "headers": { "authorization": 7 } }),
1856 )
1857 .expect_err("invalid replacement");
1858 assert_eq!(invalid.0, "sync.invalid_request");
1859
1860 dispatch(
1861 &mut transport,
1862 &mut client,
1863 &mut effects,
1864 "beginSecurityPreflight",
1865 &json!({}),
1866 )
1867 .expect("preflight");
1868 let gated = dispatch(
1869 &mut transport,
1870 &mut client,
1871 &mut effects,
1872 "setHeaders",
1873 &json!({ "headers": {} }),
1874 )
1875 .expect_err("direct rotation must respect preflight");
1876 assert_eq!(gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1877 }
1878
1879 fn schema_v2() -> Value {
1880 json!({
1881 "version": 2,
1882 "tables": [{
1883 "name": "todos",
1884 "columns": [
1885 { "name": "id", "type": "string", "nullable": false },
1886 { "name": "list_id", "type": "string", "nullable": false },
1887 { "name": "note", "type": "string", "nullable": true }
1888 ],
1889 "primaryKey": "id",
1890 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1891 }]
1892 })
1893 }
1894
1895 fn previous_version_enabled() -> Value {
1896 json!({ "enabled": true })
1897 }
1898
1899 fn container_path(db_path: &str) -> String {
1900 format!("{db_path}.prev-context")
1901 }
1902
1903 #[test]
1904 fn previous_version_config_parser_matches_ts_keys_and_defaults() {
1905 assert_eq!(parse_previous_version_context(None).expect("absent"), None);
1906 let defaults = parse_previous_version_context(Some(&json!({})))
1907 .expect("empty")
1908 .expect("present");
1909 assert!(!defaults.enabled);
1910 assert_eq!(defaults.max_bytes, 8 * 1024 * 1024);
1911 assert_eq!(defaults.max_rows, 20_000);
1912 assert_eq!(defaults.max_tables, 32);
1913 assert_eq!(defaults.max_row_bytes, 1024 * 1024);
1914 assert_eq!(defaults.max_age_ms, 24 * 60 * 60 * 1000);
1915
1916 let configured = parse_previous_version_context(Some(&json!({
1917 "enabled": true,
1918 "maxBytes": 1024,
1919 "maxRows": 3,
1920 "maxTables": 2,
1921 "maxRowBytes": 5,
1922 "maxAgeMs": 7
1923 })))
1924 .expect("configured")
1925 .expect("present");
1926 assert!(configured.enabled);
1927 assert_eq!(configured.max_bytes, 1024);
1928 assert_eq!(configured.max_rows, 3);
1929 assert_eq!(configured.max_tables, 2);
1930 assert_eq!(configured.max_row_bytes, 5);
1931 assert_eq!(configured.max_age_ms, 7);
1932
1933 for (value, expected) in [
1935 (json!([]), "must be an object"),
1936 (json!({ "enabled": "yes" }), "enabled must be a boolean"),
1937 (
1938 json!({ "maxBytes": 0 }),
1939 "maxBytes must be a positive safe integer",
1940 ),
1941 (
1942 json!({ "maxRows": -1 }),
1943 "maxRows must be a positive safe integer",
1944 ),
1945 (
1946 json!({ "maxTables": 1.5 }),
1947 "maxTables must be a positive safe integer",
1948 ),
1949 (
1950 json!({ "maxRowBytes": "1" }),
1951 "maxRowBytes must be a positive safe integer",
1952 ),
1953 (
1954 json!({ "maxAgeMs": 0 }),
1955 "maxAgeMs must be a positive safe integer",
1956 ),
1957 ] {
1958 let error = parse_previous_version_context(Some(&value)).expect_err("must fail");
1959 assert_eq!(error.0, "sync.invalid_request", "{value}");
1960 assert!(error.1.contains(expected), "{value}: {}", error.1);
1961 }
1962 }
1963
1964 #[test]
1965 fn invalid_previous_version_config_fails_create_loudly() {
1966 let mut transport = NoNetwork::default();
1967 let mut client: Option<SyncClient> = None;
1968 let mut effects = CreateEffects::default();
1969 let error = dispatch(
1970 &mut transport,
1971 &mut client,
1972 &mut effects,
1973 "create",
1974 &json!({ "schema": schema(), "previousVersionContext": { "enabled": true, "maxRows": 0 } }),
1975 )
1976 .expect_err("invalid config must fail the create");
1977 assert_eq!(error.0, "sync.invalid_request");
1978 assert!(error.1.contains("maxRows"), "{}", error.1);
1979 assert!(client.is_none(), "no client may be installed");
1980 }
1981
1982 #[test]
1983 fn previous_version_snapshot_reports_not_configured_by_default() {
1984 let mut transport = NoNetwork::default();
1985 let mut client: Option<SyncClient> = None;
1986 let mut effects = CreateEffects::default();
1987 dispatch(
1988 &mut transport,
1989 &mut client,
1990 &mut effects,
1991 "create",
1992 &json!({ "schema": schema() }),
1993 )
1994 .expect("create");
1995 let snapshot = dispatch(
1996 &mut transport,
1997 &mut client,
1998 &mut effects,
1999 "previousVersionSnapshot",
2000 &json!({ "table": "todos" }),
2001 )
2002 .expect("snapshot");
2003 assert_eq!(snapshot["state"], json!("previousVersion"));
2004 assert_eq!(snapshot["available"], json!(false));
2005 assert_eq!(snapshot["reason"], json!("not-configured"));
2006 assert_eq!(snapshot["rows"], json!([]));
2007 assert_eq!(snapshot["truncated"], json!(false));
2008 assert_eq!(snapshot["currentVersion"], json!(1));
2009 let discarded = dispatch(
2010 &mut transport,
2011 &mut client,
2012 &mut effects,
2013 "previousVersionDiscard",
2014 &json!({}),
2015 )
2016 .expect("discard");
2017 assert_eq!(discarded, json!({ "present": false, "discarded": false }));
2018 assert_eq!(
2019 dispatch(
2020 &mut transport,
2021 &mut client,
2022 &mut effects,
2023 "previousVersionAudit",
2024 &json!({}),
2025 )
2026 .expect("audit"),
2027 Value::Null
2028 );
2029 }
2030
2031 #[test]
2032 fn previous_version_commands_round_trip_through_the_dispatcher() {
2033 let path = temp_db_path("previous-version");
2034 let mut transport = NoNetwork::default();
2035 let mut client: Option<SyncClient> = None;
2036 let mut effects = CreateEffects::default();
2037
2038 dispatch(
2040 &mut transport,
2041 &mut client,
2042 &mut effects,
2043 "create",
2044 &json!({
2045 "schema": schema(),
2046 "dbPath": path,
2047 "previousVersionContext": previous_version_enabled()
2048 }),
2049 )
2050 .expect("create v1");
2051 client
2052 .as_mut()
2053 .expect("client")
2054 .mutate(vec![Mutation::Upsert {
2055 table: "todos".to_owned(),
2056 values: serde_json::Map::from_iter([
2057 ("id".to_owned(), json!("t1")),
2058 ("list_id".to_owned(), json!("l1")),
2059 ]),
2060 base_version: None,
2061 }])
2062 .expect("seed a local row");
2063 dispatch(
2064 &mut transport,
2065 &mut client,
2066 &mut effects,
2067 "shutdown",
2068 &json!({}),
2069 )
2070 .expect("shutdown");
2071
2072 dispatch(
2074 &mut transport,
2075 &mut client,
2076 &mut effects,
2077 "create",
2078 &json!({
2079 "schema": schema_v2(),
2080 "dbPath": path,
2081 "previousVersionContext": previous_version_enabled()
2082 }),
2083 )
2084 .expect("create v2");
2085 assert!(
2086 std::path::Path::new(&container_path(&path)).exists(),
2087 "the bump must leave a container file"
2088 );
2089 let status = dispatch(
2090 &mut transport,
2091 &mut client,
2092 &mut effects,
2093 "statusSnapshot",
2094 &json!({}),
2095 )
2096 .expect("status");
2097 assert_eq!(
2098 status["previousVersionContext"]["present"],
2099 json!(true),
2100 "{status}"
2101 );
2102 assert!(
2103 status["previousVersionContext"]["createdAtMs"].is_i64(),
2104 "{status}"
2105 );
2106
2107 let snapshot = dispatch(
2108 &mut transport,
2109 &mut client,
2110 &mut effects,
2111 "previousVersionSnapshot",
2112 &json!({ "table": "todos" }),
2113 )
2114 .expect("snapshot");
2115 assert_eq!(snapshot["state"], json!("previousVersion"));
2116 assert_eq!(snapshot["available"], json!(true));
2117 assert_eq!(snapshot["previousVersion"], json!(1));
2118 assert_eq!(snapshot["currentVersion"], json!(2));
2119 assert_eq!(snapshot["truncated"], json!(false));
2120 assert_eq!(snapshot["rows"][0]["id"], json!("t1"));
2121 assert_eq!(snapshot["rows"][0]["list_id"], json!("l1"));
2122
2123 let unknown = dispatch(
2125 &mut transport,
2126 &mut client,
2127 &mut effects,
2128 "previousVersionSnapshot",
2129 &json!({ "table": "absent" }),
2130 )
2131 .expect_err("unknown previous table");
2132 assert_eq!(unknown.0, "sync.invalid_request");
2133 let bad_limit = dispatch(
2134 &mut transport,
2135 &mut client,
2136 &mut effects,
2137 "previousVersionSnapshot",
2138 &json!({ "table": "todos", "limit": 0 }),
2139 )
2140 .expect_err("bad limit");
2141 assert_eq!(bad_limit.0, "sync.invalid_request");
2142
2143 let audit = dispatch(
2146 &mut transport,
2147 &mut client,
2148 &mut effects,
2149 "previousVersionAudit",
2150 &json!({}),
2151 )
2152 .expect("audit");
2153 assert_eq!(audit["v"], json!(1));
2154 assert_eq!(audit["fromVersion"], json!(1));
2155 assert_eq!(audit["toVersion"], json!(2));
2156 assert_eq!(audit["pending"], json!(1));
2157 assert_eq!(audit["encodable"], json!(1));
2158 assert_eq!(audit["incompatible"], json!([]));
2159 assert!(audit.get("operations").is_none());
2160
2161 let discarded = dispatch(
2163 &mut transport,
2164 &mut client,
2165 &mut effects,
2166 "previousVersionDiscard",
2167 &json!({}),
2168 )
2169 .expect("discard");
2170 assert_eq!(discarded, json!({ "present": true, "discarded": true }));
2171 assert!(!std::path::Path::new(&container_path(&path)).exists());
2172 let again = dispatch(
2173 &mut transport,
2174 &mut client,
2175 &mut effects,
2176 "previousVersionDiscard",
2177 &json!({}),
2178 )
2179 .expect("idempotent discard");
2180 assert_eq!(again, json!({ "present": false, "discarded": false }));
2181
2182 let inspect = rusqlite::Connection::open(&path).expect("inspect replica");
2184 let remaining: i64 = inspect
2185 .query_row(
2186 "SELECT COUNT(*) FROM _syncular_meta WHERE key IN (?1, ?2)",
2187 rusqlite::params!["previousVersionContext", "previousVersionAudit"],
2188 |row| row.get(0),
2189 )
2190 .expect("count meta");
2191 assert_eq!(remaining, 0);
2192
2193 let _ = std::fs::remove_file(&path);
2194 }
2195
2196 #[test]
2197 fn previous_version_ttl_expires_at_read_through_the_dispatcher() {
2198 let path = temp_db_path("previous-version-ttl");
2199 let mut transport = NoNetwork::default();
2200 let mut client: Option<SyncClient> = None;
2201 let mut effects = CreateEffects::default();
2202 dispatch(
2203 &mut transport,
2204 &mut client,
2205 &mut effects,
2206 "create",
2207 &json!({
2208 "schema": schema(),
2209 "dbPath": path,
2210 "previousVersionContext": previous_version_enabled()
2211 }),
2212 )
2213 .expect("create v1");
2214 dispatch(
2215 &mut transport,
2216 &mut client,
2217 &mut effects,
2218 "shutdown",
2219 &json!({}),
2220 )
2221 .expect("shutdown");
2222
2223 let now_ms = std::time::SystemTime::now()
2227 .duration_since(std::time::UNIX_EPOCH)
2228 .expect("clock")
2229 .as_millis() as i64
2230 + 2_000;
2231 dispatch(
2232 &mut transport,
2233 &mut client,
2234 &mut effects,
2235 "create",
2236 &json!({
2237 "schema": schema_v2(),
2238 "dbPath": path,
2239 "nowMs": now_ms,
2240 "previousVersionContext": { "enabled": true, "maxAgeMs": 500 }
2241 }),
2242 )
2243 .expect("create v2");
2244 assert!(std::path::Path::new(&container_path(&path)).exists());
2245 let snapshot = dispatch(
2246 &mut transport,
2247 &mut client,
2248 &mut effects,
2249 "previousVersionSnapshot",
2250 &json!({ "table": "todos" }),
2251 )
2252 .expect("snapshot");
2253 assert_eq!(snapshot["available"], json!(false));
2254 assert_eq!(snapshot["reason"], json!("expired"));
2255 assert_eq!(snapshot["rows"], json!([]));
2256 assert!(!std::path::Path::new(&container_path(&path)).exists());
2258
2259 let _ = std::fs::remove_file(&path);
2260 }
2261}