1use std::collections::BTreeSet;
37use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
38use std::sync::{Condvar, Mutex};
39use std::time::{Duration, Instant};
40
41use serde_json::{json, Value};
42use tauri::plugin::{Builder, TauriPlugin};
43use tauri::{Emitter, Manager, RunEvent, Runtime};
44
45pub mod core;
46pub mod transport;
47
48use core::SyncularCore;
49use syncular_client::{
50 FileQuerySnapshotReader, QueryReadFailure, WindowBase, WindowCoverage,
51 MAX_DIAGNOSTIC_QUERY_FAILURES,
52};
53
54pub const EVENT_NAME: &str = "syncular://event";
56
57#[derive(Debug, Clone)]
62pub struct SyncularConfig {
63 pub base_url: Option<String>,
66 pub ws_url: Option<String>,
68 pub headers: Vec<(String, String)>,
70 pub request_timeout_ms: Option<u64>,
73 pub round_deadline_ms: Option<u64>,
77 pub max_request_bytes: Option<u64>,
80 pub max_response_bytes: Option<u64>,
83 pub redirects: Option<String>,
89 pub db_path: Option<String>,
93 pub database_dir: Option<String>,
98 pub auto_sync: bool,
100 pub authority_columns: std::collections::BTreeMap<String, Vec<String>>,
103}
104
105impl Default for SyncularConfig {
106 fn default() -> Self {
107 Self {
108 base_url: None,
109 ws_url: None,
110 headers: Vec::new(),
111 request_timeout_ms: None,
112 round_deadline_ms: None,
113 max_request_bytes: None,
114 max_response_bytes: None,
115 redirects: None,
116 db_path: None,
117 database_dir: None,
118 auto_sync: true,
119 authority_columns: std::collections::BTreeMap::new(),
120 }
121 }
122}
123
124impl SyncularConfig {
125 fn to_transport_json(&self) -> Value {
127 let mut map = serde_json::Map::new();
128 if let Some(base) = &self.base_url {
129 map.insert("baseUrl".to_owned(), Value::from(base.clone()));
130 }
131 if let Some(ws) = &self.ws_url {
132 map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
133 }
134 if !self.headers.is_empty() {
135 let headers: serde_json::Map<String, Value> = self
136 .headers
137 .iter()
138 .map(|(k, v)| (k.clone(), Value::from(v.clone())))
139 .collect();
140 map.insert("headers".to_owned(), Value::Object(headers));
141 }
142 if let Some(ms) = self.request_timeout_ms {
143 map.insert("requestTimeoutMs".to_owned(), Value::from(ms));
144 }
145 if let Some(ms) = self.round_deadline_ms {
146 map.insert("roundDeadlineMs".to_owned(), Value::from(ms));
147 }
148 if let Some(bytes) = self.max_request_bytes {
149 map.insert("maxRequestBytes".to_owned(), Value::from(bytes));
150 }
151 if let Some(bytes) = self.max_response_bytes {
152 map.insert("maxResponseBytes".to_owned(), Value::from(bytes));
153 }
154 if let Some(redirects) = &self.redirects {
155 map.insert("redirects".to_owned(), Value::from(redirects.clone()));
156 }
157 Value::Object(map)
158 }
159}
160
161enum Request {
164 Command {
165 command: Value,
166 reply: Sender<Value>,
167 },
168 Query {
169 sql: String,
170 params: Value,
171 reply: Sender<Value>,
172 },
173 SetHeaders {
177 headers: Vec<(String, String)>,
178 reply: Sender<Value>,
179 },
180 TransportWake,
182 RoundFinished(
183 Box<(
184 syncular_client::CompletedSyncRound,
185 crate::transport::HostTransport,
186 )>,
187 ),
188 QueryRead {
190 id: String,
191 tables: Vec<String>,
192 failure: Option<QueryReadFailure>,
193 },
194 #[cfg(test)]
195 Block {
196 duration: Duration,
197 entered: Sender<()>,
198 },
199 Shutdown,
200}
201
202enum ReadRequest {
206 QuerySnapshot {
207 sql: String,
208 params: Vec<Value>,
209 coverage: Vec<WindowCoverage>,
210 owner: Option<(String, Vec<String>)>,
211 reply: Sender<Value>,
212 },
213 SnapshotRead {
216 statements: Vec<(String, Vec<Value>)>,
217 coverage: Vec<WindowCoverage>,
218 subscriptions: Vec<String>,
219 commit_ids: Vec<String>,
220 owner: Option<(String, Vec<String>)>,
221 reply: Sender<Value>,
222 },
223 Shutdown,
225}
226
227fn read_failure_json(failure: syncular_client::QueryReadFailure) -> Value {
231 json!({
232 "error": {
233 "code": failure.code.unwrap_or("client.failed"),
234 "message": failure.message,
235 "details": failure.details(),
236 "retryable": failure.retryable(),
237 }
238 })
239}
240
241struct SyncularState {
244 sender: Mutex<Sender<Request>>,
245 reader: Mutex<Option<Sender<ReadRequest>>>,
250 config: SyncularConfig,
251 security_gate: SecurityGate,
252}
253
254struct SecurityGateState {
255 preflight: bool,
256 active_reads: usize,
257}
258
259struct SecurityGate {
260 state: Mutex<SecurityGateState>,
261 idle: Condvar,
262}
263
264struct SecurityReadGuard<'a> {
265 gate: &'a SecurityGate,
266}
267
268impl SecurityGate {
269 fn new_preflight() -> Self {
270 Self {
271 state: Mutex::new(SecurityGateState {
272 preflight: true,
273 active_reads: 0,
274 }),
275 idle: Condvar::new(),
276 }
277 }
278
279 fn begin_preflight(&self) -> Result<(), String> {
280 let mut state = self
281 .state
282 .lock()
283 .map_err(|_| "syncular security gate poisoned".to_owned())?;
284 state.preflight = true;
285 while state.active_reads > 0 {
286 state = self
287 .idle
288 .wait(state)
289 .map_err(|_| "syncular security gate poisoned".to_owned())?;
290 }
291 Ok(())
292 }
293
294 fn activate(&self) -> Result<(), String> {
295 let mut state = self
296 .state
297 .lock()
298 .map_err(|_| "syncular security gate poisoned".to_owned())?;
299 state.preflight = false;
300 Ok(())
301 }
302
303 fn enter_read(&self) -> Result<SecurityReadGuard<'_>, Value> {
304 let mut state = self
305 .state
306 .lock()
307 .map_err(|_| client_error("syncular security gate poisoned"))?;
308 if state.preflight {
309 return Err(security_preflight_error());
310 }
311 state.active_reads += 1;
312 Ok(SecurityReadGuard { gate: self })
313 }
314}
315
316impl Drop for SecurityReadGuard<'_> {
317 fn drop(&mut self) {
318 let Ok(mut state) = self.gate.state.lock() else {
319 return;
320 };
321 state.active_reads = state.active_reads.saturating_sub(1);
322 if state.active_reads == 0 {
323 self.gate.idle.notify_all();
324 }
325 }
326}
327
328impl SyncularState {
329 fn send(&self, request: Request) -> Result<(), String> {
330 self.sender
331 .lock()
332 .map_err(|_| "syncular mailbox poisoned".to_owned())?
333 .send(request)
334 .map_err(|_| "the syncular core thread has stopped".to_owned())
335 }
336
337 fn reader(&self) -> Result<Option<Sender<ReadRequest>>, String> {
338 Ok(self
339 .reader
340 .lock()
341 .map_err(|_| "syncular read mailbox poisoned".to_owned())?
342 .clone())
343 }
344}
345
346fn spawn_reader(path: String, owner_tx: Sender<Request>) -> Result<Sender<ReadRequest>, String> {
347 let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
348 std::thread::Builder::new()
349 .name("syncular-read".to_owned())
350 .spawn(move || run_reader_thread(path, reader_rx, owner_tx))
351 .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
352 Ok(reader_tx)
353}
354
355struct ReportedQueryFailure {
359 id: String,
360 tables: Vec<String>,
361 code: &'static str,
362 sqlite_code: Option<i32>,
363}
364
365fn report_owned_read(
373 reported: &mut Vec<ReportedQueryFailure>,
374 owner_tx: &Sender<Request>,
375 id: String,
376 tables: Vec<String>,
377 failure: Option<QueryReadFailure>,
378) {
379 let position = reported.iter().position(|entry| entry.id == id);
380 let Some(failure) = failure else {
381 let Some(index) = position else {
383 return;
384 };
385 reported.remove(index);
386 let _ = owner_tx.send(Request::QueryRead {
387 id,
388 tables,
389 failure: None,
390 });
391 return;
392 };
393 let code = failure.code.unwrap_or("client.query_failed");
394 let sqlite_code = failure.sqlite_code;
395 let signature: Vec<String> = BTreeSet::from_iter(tables.iter().cloned())
396 .into_iter()
397 .collect();
398 let unchanged = position.is_some_and(|index| {
399 let entry = &reported[index];
400 entry.tables == signature && entry.code == code && entry.sqlite_code == sqlite_code
401 });
402 if !unchanged {
403 if let Some(index) = position {
404 reported.remove(index);
405 }
406 reported.push(ReportedQueryFailure {
407 id: id.clone(),
408 tables: signature,
409 code,
410 sqlite_code,
411 });
412 if reported.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
413 reported.remove(0);
414 }
415 }
416 let _ = owner_tx.send(Request::QueryRead {
417 id,
418 tables,
419 failure: Some(failure),
420 });
421}
422
423fn run_reader_thread(path: String, rx: Receiver<ReadRequest>, owner_tx: Sender<Request>) {
424 let mut reader = FileQuerySnapshotReader::new(path);
425 let mut reported: Vec<ReportedQueryFailure> = Vec::new();
427 while let Ok(request) = rx.recv() {
428 match request {
429 ReadRequest::QuerySnapshot {
430 sql,
431 params,
432 coverage,
433 owner,
434 reply,
435 } => {
436 let result = reader.query_snapshot(&sql, ¶ms, &coverage);
437 let value = match &result {
438 Ok(snapshot) => json!({ "result": snapshot }),
439 Err(failure) => read_failure_json(failure.clone()),
440 };
441 if let Some((id, tables)) = owner {
442 report_owned_read(&mut reported, &owner_tx, id, tables, result.err());
443 }
444 let _ = reply.send(value);
445 }
446 ReadRequest::SnapshotRead {
447 statements,
448 coverage,
449 subscriptions,
450 commit_ids,
451 owner,
452 reply,
453 } => {
454 let request = syncular_client::SnapshotReadRequest {
455 statements: statements
456 .iter()
457 .map(|(sql, params)| syncular_client::SnapshotStatement { sql, params })
458 .collect(),
459 coverage: &coverage,
460 subscriptions,
461 commit_ids,
462 };
463 let result = reader.snapshot_read(&request);
464 let value = match &result {
465 Ok(read) => json!({ "result": read }),
466 Err(failure) => read_failure_json(failure.clone()),
467 };
468 if let Some((id, tables)) = owner {
471 report_owned_read(&mut reported, &owner_tx, id, tables, result.err());
472 }
473 let _ = reply.send(value);
474 }
475 ReadRequest::Shutdown => return,
476 }
477 }
478}
479
480enum NetworkWork {
484 Round(
485 Box<syncular_client::PreparedSyncRound>,
486 crate::transport::HostTransport,
487 ),
488 Controls(Vec<String>, crate::transport::HostTransport),
489}
490
491struct RoundRun {
492 reply: Option<Sender<Value>>,
493 until_idle: bool,
494 max_rounds: Option<u32>,
495 spent: u32,
496 aggregate: syncular_client::SyncReport,
497}
498
499fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
500where
501 F: Fn(&Value) + Send + Sync + 'static,
502{
503 let emit = std::sync::Arc::new(emit);
504 let wake_tx = tx.clone();
505 let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
506 let _ = wake_tx.send(Request::TransportWake);
507 });
508 let mut core = match SyncularCore::new_with_notify(&config.to_transport_json(), Some(notify)) {
509 Ok(core) => core,
510 Err(message) => {
511 emit(&json!({ "type": "error", "message": message }));
512 return;
513 }
514 };
515 let progress_emit = emit.clone();
516 core.progress_listener = Some(std::sync::Arc::new(move |progress| {
517 progress_emit(&json!({ "type": "progress", "progress": progress }));
518 }));
519 let (network_tx, network_rx) = std::sync::mpsc::channel();
520 let completed_tx = tx.clone();
521 let network = std::thread::spawn(move || {
522 use syncular_client::Transport;
523 while let Ok(work) = network_rx.recv() {
524 match work {
525 NetworkWork::Round(prepared, mut transport) => {
526 let completed = (*prepared).exchange(&mut transport);
527 if completed_tx
528 .send(Request::RoundFinished(Box::new((completed, transport))))
529 .is_err()
530 {
531 return;
532 }
533 }
534 NetworkWork::Controls(controls, mut transport) => {
535 for text in controls {
536 let _ = transport.realtime_send(&text);
537 }
538 }
539 }
540 }
541 });
542 let mut active: Option<RoundRun> = None;
543 let mut queued = std::collections::VecDeque::new();
544 let mut background_deadline: Option<Instant> = None;
545 loop {
546 if !core.transport_enabled() {
547 background_deadline = None;
548 }
549 if let Some((controls, transport)) = core.take_controls() {
550 if network_tx
551 .send(NetworkWork::Controls(controls, transport))
552 .is_err()
553 {
554 break;
555 }
556 }
557 if active.is_none() {
560 if let Some(run) = queued.pop_front() {
561 active = Some(run);
562 } else if config.auto_sync {
563 match core.take_sync_intent() {
564 syncular_client::SyncIntent::Interactive => {
565 background_deadline = None;
566 active = Some(RoundRun {
567 reply: None,
568 until_idle: true,
569 max_rounds: None,
570 spent: 0,
571 aggregate: Default::default(),
572 });
573 }
574 syncular_client::SyncIntent::Background { delay_ms } => {
575 let candidate = Instant::now()
576 .checked_add(Duration::from_millis(delay_ms))
577 .unwrap_or_else(Instant::now);
578 background_deadline = Some(
579 background_deadline.map_or(candidate, |current| current.min(candidate)),
580 );
581 }
582 syncular_client::SyncIntent::None => {}
583 }
584 }
585 if active.is_some() {
586 match core.prepare_round() {
587 Ok((prepared, transport)) => {
588 active.as_mut().expect("active run").spent += 1;
589 if network_tx
590 .send(NetworkWork::Round(Box::new(prepared), transport))
591 .is_err()
592 {
593 break;
594 }
595 }
596 Err(outcome) => {
597 if let Some(reply) = active.take().and_then(|run| run.reply) {
598 let _ = reply.send(json!({ "result": outcome.to_json() }));
599 }
600 }
601 }
602 pump_events(&mut core, &*emit);
603 if active.is_none() {
604 continue;
605 }
606 }
607 }
608 let request = if active.is_none() {
609 if let Some(deadline) = background_deadline {
610 match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
611 Ok(request) => request,
612 Err(RecvTimeoutError::Timeout) => {
613 background_deadline = None;
614 queued.push_back(RoundRun {
615 reply: None,
616 until_idle: true,
617 max_rounds: None,
618 spent: 0,
619 aggregate: Default::default(),
620 });
621 continue;
622 }
623 Err(RecvTimeoutError::Disconnected) => break,
624 }
625 } else {
626 match rx.recv() {
627 Ok(request) => request,
628 Err(_) => break,
629 }
630 }
631 } else {
632 match rx.recv() {
633 Ok(request) => request,
634 Err(_) => break,
635 }
636 };
637 match request {
638 Request::RoundFinished(completed) => {
639 let (completed, transport) = *completed;
640 let applied = core.apply_round(completed);
641 let (mut outcome, more, bootstrap_advanced) = match applied {
642 syncular_client::AppliedSyncRound::Continue(prepared) => {
643 if network_tx
644 .send(NetworkWork::Round(Box::new(prepared), transport))
645 .is_err()
646 {
647 break;
648 }
649 pump_events(&mut core, &*emit);
650 continue;
651 }
652 syncular_client::AppliedSyncRound::Complete {
653 outcome,
654 more,
655 bootstrap_advanced,
656 ..
657 } => (outcome, more, bootstrap_advanced),
658 };
659 let Some(mut run) = active.take() else {
660 continue;
661 };
662 let mut keep_running = false;
663 if run.until_idle {
664 if let syncular_client::SyncOutcome::Ok(report) = &outcome {
665 run.aggregate.merge(report);
666 if bootstrap_advanced && run.max_rounds.is_none() {
667 run.spent = 0;
668 }
669 keep_running = more && core.transport_enabled();
670 if keep_running && run.spent >= run.max_rounds.unwrap_or(20) {
671 keep_running = false;
672 outcome = syncular_client::SyncOutcome::BudgetExhausted(
673 run.aggregate.clone(),
674 );
675 } else if !keep_running {
676 outcome = syncular_client::SyncOutcome::Ok(run.aggregate.clone());
677 }
678 }
679 }
680 if keep_running {
681 queued.push_front(run);
682 } else if let Some(reply) = run.reply {
683 let _ = reply.send(json!({ "result": outcome.to_json() }));
684 }
685 pump_events(&mut core, &*emit);
686 }
687 Request::Command { command, reply } => {
688 let method = command.get("method").and_then(Value::as_str);
689 if matches!(method, Some("sync" | "syncUntilIdle")) && core.transport_enabled() {
690 let until_idle = method == Some("syncUntilIdle");
691 let max_rounds = if until_idle {
694 match command.pointer("/params/maxRounds") {
695 None | Some(Value::Null) => None,
696 Some(value) => match value
697 .as_u64()
698 .and_then(|n| u32::try_from(n).ok())
699 .filter(|n| *n >= 1)
700 {
701 Some(n) => Some(n),
702 None => {
703 let _ = reply.send(json!({
704 "error": {
705 "code": "sync.invalid_request",
706 "message": "maxRounds must be a positive integer",
707 "retryable": false
708 }
709 }));
710 continue;
711 }
712 },
713 }
714 } else {
715 None
716 };
717 queued.push_back(RoundRun {
718 reply: Some(reply),
719 until_idle,
720 max_rounds,
721 spent: 0,
722 aggregate: Default::default(),
723 });
724 continue;
725 }
726 let result = core.command(&command);
727 let _ = reply.send(result);
728 pump_events(&mut core, &*emit);
729 }
730 Request::Query { sql, params, reply } => {
731 let _ = reply.send(core.query(&sql, params));
732 pump_events(&mut core, &*emit);
733 }
734 Request::SetHeaders { headers, reply } => {
735 core.set_headers(headers);
736 let _ = reply.send(json!({ "result": null }));
737 }
738 Request::TransportWake => {
739 core.poll_transport();
740 pump_events(&mut core, &*emit);
741 }
742 Request::QueryRead {
743 id,
744 tables,
745 failure,
746 } => {
747 core.record_query_read(&id, &tables, failure.as_ref());
748 pump_events(&mut core, &*emit);
749 }
750 #[cfg(test)]
751 Request::Block { duration, entered } => {
752 let _ = entered.send(());
753 std::thread::sleep(duration);
754 }
755 Request::Shutdown => break,
756 }
757 }
758 core.shutdown();
759 drop(network_tx);
760 let _ = network.join();
763 for run in active.into_iter().chain(queued) {
764 if let Some(reply) = run.reply {
765 let _ = reply.send(json!({ "error": { "code": "client.closed", "message": "client owner has closed" } }));
766 }
767 }
768}
769
770fn resolve_database(config: &SyncularConfig, params: &Value) -> Result<Option<String>, Value> {
777 if params.get("dbPath").is_some() {
778 return Err(invalid_request(
779 "sync.invalid_request: create.dbPath is host configuration; name a database with create.database",
780 ));
781 }
782 let Some(database) = params.get("database") else {
783 if config.db_path.is_none() && config.database_dir.is_some() {
784 return Err(invalid_request(
785 "sync.invalid_request: create.database is required when SyncularConfig sets database_dir without db_path",
786 ));
787 }
788 return Ok(config.db_path.clone());
789 };
790 let Some(name) = database.as_str().filter(|name| {
791 (1..=128).contains(&name.len())
792 && name.starts_with(|c: char| c.is_ascii_alphanumeric())
793 && !name.contains("..")
794 && name
795 .chars()
796 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
797 }) else {
798 return Err(invalid_request(
799 "sync.invalid_request: create.database must be 1 to 128 ASCII letters, digits, '-', '_' or '.', start with a letter or digit, and contain no '..'",
800 ));
801 };
802 let Some(dir) = &config.database_dir else {
803 return Err(invalid_request(
804 "sync.invalid_request: create.database needs SyncularConfig.database_dir",
805 ));
806 };
807 std::fs::create_dir_all(dir)
808 .map_err(|error| client_error(format!("cannot create the database directory: {error}")))?;
809 Ok(Some(
810 std::path::Path::new(dir)
811 .join(format!("{name}.db"))
812 .to_string_lossy()
813 .into_owned(),
814 ))
815}
816
817fn invalid_request(message: &str) -> Value {
818 json!({ "error": { "code": "sync.invalid_request", "message": message } })
819}
820
821fn client_error(message: impl Into<String>) -> Value {
822 json!({ "error": { "code": "client.failed", "message": message.into() } })
823}
824
825fn security_preflight_error() -> Value {
826 json!({
827 "error": {
828 "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
829 "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
830 }
831 })
832}
833
834fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
835 let object = value
836 .and_then(Value::as_object)
837 .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
838 let table = object
839 .get("table")
840 .and_then(Value::as_str)
841 .ok_or_else(|| "window base missing table".to_owned())?
842 .to_owned();
843 let variable = object
844 .get("variable")
845 .and_then(Value::as_str)
846 .ok_or_else(|| "window base missing variable".to_owned())?
847 .to_owned();
848 let fixed_scopes = match object.get("fixedScopes") {
849 Some(value) => syncular_client::values::json_to_scope_map(value)?,
850 None => Vec::new(),
851 };
852 let params = object
853 .get("params")
854 .and_then(Value::as_str)
855 .map(str::to_owned);
856 Ok(WindowBase {
857 table,
858 variable,
859 fixed_scopes,
860 params,
861 })
862}
863
864fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
865 let Some(value) = value else {
866 return Ok(Vec::new());
867 };
868 if value.is_null() {
869 return Ok(Vec::new());
870 }
871 let entries = value
872 .as_array()
873 .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
874 entries
875 .iter()
876 .map(|entry| {
877 let units = entry
878 .get("units")
879 .and_then(Value::as_array)
880 .map(|values| {
881 values
882 .iter()
883 .filter_map(|value| value.as_str().map(str::to_owned))
884 .collect()
885 })
886 .unwrap_or_default();
887 Ok(WindowCoverage {
888 base: parse_window_base(entry.get("base"))?,
889 units,
890 })
891 })
892 .collect()
893}
894
895fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
896 for event in core.drain_events() {
897 emit(&event.json);
898 }
899}
900
901#[tauri::command]
904async fn syncular_command<R: Runtime>(
905 app: tauri::AppHandle<R>,
906 command: Value,
907) -> Result<Value, String> {
908 let method = command
909 .get("method")
910 .and_then(Value::as_str)
911 .unwrap_or("")
912 .to_owned();
913 if matches!(method.as_str(), "querySnapshot" | "snapshotRead") {
921 let params = command.get("params").unwrap_or(&Value::Null);
922 if method == "querySnapshot" {
923 let Some(sql) = params.get("sql").and_then(Value::as_str) else {
924 return Ok(client_error("querySnapshot missing sql"));
925 };
926 return syncular_query_snapshot(
927 app,
928 sql.to_owned(),
929 params.get("params").cloned(),
930 params.get("coverage").cloned(),
931 params.get("owner").cloned(),
932 )
933 .await;
934 }
935 let statements = params.get("statements").cloned().unwrap_or(Value::Null);
936 if !statements.is_array() {
937 return Ok(invalid_request(
938 "sync.invalid_request: snapshotRead statements must be a list",
939 ));
940 }
941 return syncular_snapshot_read(
942 app,
943 statements,
944 params.get("coverage").cloned(),
945 params.get("subscriptions").cloned(),
946 params.get("commitIds").cloned(),
947 params.get("owner").cloned(),
948 )
949 .await;
950 }
951 let state = app.state::<SyncularState>();
952 if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
953 state.security_gate.begin_preflight()?;
956 }
957 let mut reader = if method == "create" || method == "shutdown" {
959 Some(
960 state
961 .reader
962 .lock()
963 .map_err(|_| "syncular read mailbox poisoned".to_owned())?,
964 )
965 } else {
966 None
967 };
968 let mut command = command;
969 let mut database = None;
970 if method == "create" {
971 if let Some(reads) = command.pointer("/params/authorityReads") {
972 let declarations: Vec<syncular_client::AuthorityReadDeclaration> =
973 match serde_json::from_value(reads.clone()) {
974 Ok(value) => value,
975 Err(_) => {
976 return Ok(
977 json!({"error": {"code": "client.authority_read_forbidden", "message": "invalid authority declaration"}}),
978 )
979 }
980 };
981 if !declarations.iter().all(|read| {
982 state
983 .config
984 .authority_columns
985 .get(&read.table)
986 .is_some_and(|columns| read.columns.iter().all(|c| columns.contains(c)))
987 }) {
988 return Ok(
989 json!({"error": {"code": "client.authority_read_forbidden", "message": "authority declaration exceeds the native app policy"}}),
990 );
991 }
992 }
993 database =
994 match resolve_database(&state.config, command.get("params").unwrap_or(&Value::Null)) {
995 Ok(path) => path,
996 Err(reply) => return Ok(reply),
997 };
998 if let Some(path) = &database {
999 match command.get_mut("params").and_then(Value::as_object_mut) {
1000 Some(params) => {
1001 params.insert("dbPath".to_owned(), Value::from(path.clone()));
1002 }
1003 None => {
1004 if let Some(object) = command.as_object_mut() {
1005 object.insert("params".to_owned(), json!({ "dbPath": path }));
1006 }
1007 }
1008 }
1009 }
1010 }
1011 let create_preflight = method == "create"
1012 && command
1013 .pointer("/params/securityPreflight")
1014 .and_then(Value::as_bool)
1015 .unwrap_or(false);
1016 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1017 state.send(Request::Command {
1018 command,
1019 reply: reply_tx,
1020 })?;
1021 let reply = reply_rx
1022 .recv()
1023 .map_err(|_| "the syncular core dropped the reply".to_owned())?;
1024 let succeeded = reply.get("error").is_none();
1025 if let (true, Some(slot)) = (succeeded, reader.as_mut()) {
1026 let next = match database.filter(|path| path.as_str() != ":memory:") {
1027 Some(path) => Some(spawn_reader(
1028 path,
1029 state
1030 .sender
1031 .lock()
1032 .map_err(|_| "syncular mailbox poisoned".to_owned())?
1033 .clone(),
1034 )?),
1035 None => None,
1036 };
1037 if let Some(previous) = std::mem::replace(&mut **slot, next) {
1038 let _ = previous.send(ReadRequest::Shutdown);
1039 }
1040 }
1041 drop(reader);
1042 if succeeded && method == "create" {
1043 if !create_preflight {
1044 state.security_gate.activate()?;
1045 }
1046 } else if succeeded && method == "activateSecurity" {
1047 state.security_gate.activate()?;
1048 }
1049 Ok(reply)
1050}
1051
1052#[tauri::command]
1057async fn syncular_set_headers<R: Runtime>(
1058 app: tauri::AppHandle<R>,
1059 headers: std::collections::BTreeMap<String, String>,
1060) -> Result<Value, String> {
1061 let state = app.state::<SyncularState>();
1062 let _active_guard = match state.security_gate.enter_read() {
1066 Ok(guard) => guard,
1067 Err(reply) => return Ok(reply),
1068 };
1069 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1070 state.send(Request::SetHeaders {
1071 headers: headers.into_iter().collect(),
1072 reply: reply_tx,
1073 })?;
1074 reply_rx
1075 .recv()
1076 .map_err(|_| "the syncular core dropped the reply".to_owned())
1077}
1078
1079#[tauri::command]
1080async fn syncular_query<R: Runtime>(
1081 app: tauri::AppHandle<R>,
1082 sql: String,
1083 params: Option<Value>,
1084) -> Result<Value, String> {
1085 let state = app.state::<SyncularState>();
1086 let _read_guard = match state.security_gate.enter_read() {
1087 Ok(guard) => guard,
1088 Err(reply) => return Ok(reply),
1089 };
1090 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1091 state.send(Request::Query {
1092 sql,
1093 params: params.unwrap_or(Value::Null),
1094 reply: reply_tx,
1095 })?;
1096 reply_rx
1097 .recv()
1098 .map_err(|_| "the syncular core dropped the reply".to_owned())
1099}
1100
1101#[tauri::command]
1105async fn syncular_query_snapshot<R: Runtime>(
1106 app: tauri::AppHandle<R>,
1107 sql: String,
1108 params: Option<Value>,
1109 coverage: Option<Value>,
1110 owner: Option<Value>,
1111) -> Result<Value, String> {
1112 let state = app.state::<SyncularState>();
1113 let _read_guard = match state.security_gate.enter_read() {
1114 Ok(guard) => guard,
1115 Err(reply) => return Ok(reply),
1116 };
1117 let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
1118 let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
1119
1120 if let Some(reader) = state.reader()? {
1121 let bind = match params_value.as_array() {
1122 Some(values) => values.clone(),
1123 None => return Ok(client_error("querySnapshot params must be a list")),
1124 };
1125 let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
1126 Ok(value) => value,
1127 Err(message) => return Ok(client_error(message)),
1128 };
1129 let parsed_owner = match parse_owner(owner.as_ref()) {
1131 Ok(value) => value,
1132 Err(reply) => return Ok(reply),
1133 };
1134 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1135 reader
1136 .send(ReadRequest::QuerySnapshot {
1137 sql,
1138 params: bind,
1139 coverage: parsed_coverage,
1140 owner: parsed_owner,
1141 reply: reply_tx,
1142 })
1143 .map_err(|_| "the syncular read thread has stopped".to_owned())?;
1144 return reply_rx
1145 .recv()
1146 .map_err(|_| "the syncular read thread dropped the reply".to_owned());
1147 }
1148
1149 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1150 state.send(Request::Command {
1151 command: json!({
1152 "method": "querySnapshot",
1153 "params": {
1154 "sql": sql,
1155 "params": params_value,
1156 "coverage": coverage_value,
1157 "owner": owner.unwrap_or(Value::Null),
1158 }
1159 }),
1160 reply: reply_tx,
1161 })?;
1162 reply_rx
1163 .recv()
1164 .map_err(|_| "the syncular core dropped the reply".to_owned())
1165}
1166
1167fn parse_owner(value: Option<&Value>) -> Result<Option<(String, Vec<String>)>, Value> {
1170 match value {
1171 None | Some(Value::Null) => Ok(None),
1172 Some(value) => {
1173 let id = value
1174 .get("id")
1175 .and_then(Value::as_str)
1176 .filter(|id| !id.is_empty());
1177 let tables = value
1178 .get("tables")
1179 .and_then(Value::as_array)
1180 .and_then(|list| {
1181 list.iter()
1182 .map(|table| table.as_str().map(str::to_owned))
1183 .collect::<Option<Vec<String>>>()
1184 });
1185 match (id, tables) {
1186 (Some(id), Some(tables)) => Ok(Some((id.to_owned(), tables))),
1187 _ => Err(json!({ "error": {
1188 "code": "sync.invalid_request",
1189 "message": "sync.invalid_request: owner must be {id: non-empty string, tables: string[]}",
1190 } })),
1191 }
1192 }
1193 }
1194}
1195
1196fn parse_string_list(value: Option<&Value>) -> Result<Vec<String>, String> {
1197 match value {
1198 None | Some(Value::Null) => Ok(Vec::new()),
1199 Some(Value::Array(items)) => items
1200 .iter()
1201 .map(|item| item.as_str().map(str::to_owned))
1202 .collect::<Option<Vec<String>>>()
1203 .ok_or_else(|| "expected a list of strings".to_owned()),
1204 Some(_) => Err("expected a list of strings".to_owned()),
1205 }
1206}
1207
1208#[tauri::command]
1214async fn syncular_snapshot_read<R: Runtime>(
1215 app: tauri::AppHandle<R>,
1216 statements: Value,
1217 coverage: Option<Value>,
1218 subscriptions: Option<Value>,
1219 commit_ids: Option<Value>,
1220 owner: Option<Value>,
1221) -> Result<Value, String> {
1222 let state = app.state::<SyncularState>();
1223 let _read_guard = match state.security_gate.enter_read() {
1224 Ok(guard) => guard,
1225 Err(reply) => return Ok(reply),
1226 };
1227 let Some(items) = statements.as_array() else {
1228 return Ok(invalid_request(
1229 "sync.invalid_request: snapshotRead statements must be a list",
1230 ));
1231 };
1232 let mut parsed_statements = Vec::with_capacity(items.len());
1233 for item in items {
1234 let Some(sql) = item.get("sql").and_then(Value::as_str) else {
1235 return Ok(invalid_request(
1236 "sync.invalid_request: each snapshotRead statement needs a sql string",
1237 ));
1238 };
1239 let params = match item.get("params") {
1241 None | Some(Value::Null) => Vec::new(),
1242 Some(Value::Array(values)) => values.clone(),
1243 Some(_) => {
1244 return Ok(invalid_request(
1245 "sync.invalid_request: snapshotRead statement params must be a list",
1246 ))
1247 }
1248 };
1249 parsed_statements.push((sql.to_owned(), params));
1250 }
1251 let coverage_value = coverage.clone().unwrap_or(Value::Null);
1252 let parsed_coverage = match parse_coverage(coverage.as_ref()) {
1253 Ok(value) => value,
1254 Err(message) => return Ok(invalid_request(&format!("sync.invalid_request: {message}"))),
1255 };
1256 let parsed_subscriptions = match parse_string_list(subscriptions.as_ref()) {
1257 Ok(value) => value,
1258 Err(message) => return Ok(invalid_request(&format!("sync.invalid_request: {message}"))),
1259 };
1260 let parsed_commit_ids = match parse_string_list(commit_ids.as_ref()) {
1261 Ok(value) => value,
1262 Err(message) => return Ok(invalid_request(&format!("sync.invalid_request: {message}"))),
1263 };
1264 let parsed_owner = match parse_owner(owner.as_ref()) {
1265 Ok(value) => value,
1266 Err(reply) => return Ok(reply),
1267 };
1268
1269 if let Some(reader) = state.reader()? {
1270 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1271 reader
1272 .send(ReadRequest::SnapshotRead {
1273 statements: parsed_statements,
1274 coverage: parsed_coverage,
1275 subscriptions: parsed_subscriptions,
1276 commit_ids: parsed_commit_ids,
1277 owner: parsed_owner,
1278 reply: reply_tx,
1279 })
1280 .map_err(|_| "the syncular read thread has stopped".to_owned())?;
1281 return reply_rx
1282 .recv()
1283 .map_err(|_| "the syncular read thread dropped the reply".to_owned());
1284 }
1285
1286 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1287 state.send(Request::Command {
1288 command: json!({
1289 "method": "snapshotRead",
1290 "params": {
1291 "statements": parsed_statements
1292 .into_iter()
1293 .map(|(sql, params)| json!({ "sql": sql, "params": params }))
1294 .collect::<Vec<_>>(),
1295 "coverage": coverage_value,
1296 "subscriptions": parsed_subscriptions,
1297 "commitIds": parsed_commit_ids,
1298 "owner": parsed_owner.map_or(Value::Null, |(id, tables)| json!({
1299 "id": id,
1300 "tables": tables,
1301 })),
1302 }
1303 }),
1304 reply: reply_tx,
1305 })?;
1306 reply_rx
1307 .recv()
1308 .map_err(|_| "the syncular core dropped the reply".to_owned())
1309}
1310
1311pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
1319 Builder::<R>::new("syncular")
1320 .invoke_handler(tauri::generate_handler![
1321 syncular_command,
1322 syncular_query,
1323 syncular_query_snapshot,
1324 syncular_snapshot_read,
1325 syncular_set_headers
1326 ])
1327 .setup(move |app, _api| {
1328 let (tx, rx) = std::sync::mpsc::channel::<Request>();
1329 app.manage(SyncularState {
1330 sender: Mutex::new(tx.clone()),
1331 reader: Mutex::new(None),
1332 config: config.clone(),
1333 security_gate: SecurityGate::new_preflight(),
1336 });
1337 let app_handle = app.clone();
1338 let emit = move |value: &Value| {
1339 let _ = app_handle.emit(EVENT_NAME, value.clone());
1342 };
1343 std::thread::Builder::new()
1344 .name("syncular-core".to_owned())
1345 .spawn(move || run_owner_thread(config, tx, rx, emit))
1346 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
1347 Ok(())
1348 })
1349 .on_event(|app, event| {
1350 if let RunEvent::Exit = event {
1351 if let Some(state) = app.try_state::<SyncularState>() {
1352 let _ = state.send(Request::Shutdown);
1353 if let Ok(Some(reader)) = state.reader() {
1354 let _ = reader.send(ReadRequest::Shutdown);
1355 }
1356 }
1357 }
1358 })
1359 .build()
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364 use super::*;
1365
1366 #[test]
1367 fn config_to_transport_json_shapes_fields() {
1368 let config = SyncularConfig {
1369 base_url: Some("https://api.example.com".to_owned()),
1370 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
1371 request_timeout_ms: Some(1000),
1372 round_deadline_ms: Some(2000),
1373 max_request_bytes: Some(1024),
1374 max_response_bytes: Some(2048),
1375 redirects: Some("deny".to_owned()),
1376 ..Default::default()
1377 };
1378 let json = config.to_transport_json();
1379 assert_eq!(json["baseUrl"], "https://api.example.com");
1380 assert_eq!(json["headers"]["authorization"], "Bearer x");
1381 assert_eq!(json["requestTimeoutMs"], 1000);
1382 assert_eq!(json["roundDeadlineMs"], 2000);
1383 assert_eq!(json["maxRequestBytes"], 1024);
1384 assert_eq!(json["maxResponseBytes"], 2048);
1385 assert_eq!(json["redirects"], "deny");
1386 }
1387
1388 #[test]
1389 fn transport_policy_config_is_validated_at_the_core_boundary() {
1390 let core = SyncularCore::new(&serde_json::json!({"requestTimeoutMs": 1000}));
1392 assert!(core.is_ok());
1393 let error = SyncularCore::new(&serde_json::json!({"requestTimeoutMs": 0}))
1394 .err()
1395 .unwrap();
1396 assert_eq!(
1397 error,
1398 "sync.invalid_request: transport config requestTimeoutMs must be a positive integer"
1399 );
1400 let error = SyncularCore::new(&serde_json::json!({"redirects": "sometimes"}))
1401 .err()
1402 .unwrap();
1403 assert_eq!(
1404 error,
1405 "sync.invalid_request: transport config redirects must be \"deny\" or \"follow\""
1406 );
1407 let mut core = SyncularCore::new(&serde_json::json!({})).unwrap();
1408 assert_eq!(
1409 core.set_transport_policy(crate::transport::HostTransportPolicy {
1410 max_response_bytes: Some(0),
1411 ..Default::default()
1412 })
1413 .unwrap_err(),
1414 "sync.invalid_request: transport config maxResponseBytes must be a positive integer"
1415 );
1416 }
1417
1418 #[test]
1419 fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
1420 let gate = std::sync::Arc::new(SecurityGate::new_preflight());
1421 gate.activate().expect("activate test gate");
1422 let read = gate.enter_read().expect("active read");
1423 let gate_for_barrier = std::sync::Arc::clone(&gate);
1424 let (done_tx, done_rx) = std::sync::mpsc::channel();
1425 let barrier = std::thread::spawn(move || {
1426 gate_for_barrier.begin_preflight().expect("enter preflight");
1427 done_tx.send(()).expect("barrier reply");
1428 });
1429
1430 assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
1431 drop(read);
1432 done_rx
1433 .recv_timeout(Duration::from_secs(1))
1434 .expect("barrier drains after the read");
1435 barrier.join().expect("barrier thread");
1436 assert!(gate.enter_read().is_err());
1437 }
1438
1439 #[test]
1440 fn direct_set_headers_command_respects_the_native_preflight_gate() {
1441 use std::collections::BTreeMap;
1442 use tauri::test::{mock_builder, mock_context, noop_assets};
1443
1444 let app = mock_builder()
1445 .plugin(init(SyncularConfig {
1446 auto_sync: false,
1447 ..Default::default()
1448 }))
1449 .build(mock_context(noop_assets()))
1450 .expect("build mock app");
1451
1452 let blocked = tauri::async_runtime::block_on(syncular_set_headers(
1453 app.handle().clone(),
1454 BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
1455 ))
1456 .expect("preflight reply");
1457 assert_eq!(
1458 blocked["error"]["code"],
1459 Value::from("client.security_preflight_required")
1460 );
1461
1462 let created = tauri::async_runtime::block_on(syncular_command(
1463 app.handle().clone(),
1464 json!({
1465 "method": "create",
1466 "params": {
1467 "clientId": "native-header-gate",
1468 "schema": { "version": 1, "tables": [] }
1469 }
1470 }),
1471 ))
1472 .expect("create reply");
1473 assert!(created.get("error").is_none(), "{created}");
1474
1475 let active = tauri::async_runtime::block_on(syncular_set_headers(
1476 app.handle().clone(),
1477 BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
1478 ))
1479 .expect("active reply");
1480 assert_eq!(active["result"], Value::Null);
1481 }
1482
1483 #[test]
1484 fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
1485 use tauri::test::{mock_builder, mock_context, noop_assets};
1486
1487 let app = mock_builder()
1488 .plugin(init(SyncularConfig {
1489 auto_sync: false,
1490 ..Default::default()
1491 }))
1492 .build(mock_context(noop_assets()))
1493 .expect("build mock app");
1494 let command = |value: Value| {
1495 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1496 .expect("command reply")
1497 };
1498
1499 let created = command(json!({
1500 "method": "create",
1501 "params": {
1502 "clientId": "native-preflight-escape",
1503 "schema": { "version": 1, "tables": [] },
1504 "securityPreflight": true
1505 }
1506 }));
1507 assert!(created.get("error").is_none(), "{created}");
1508
1509 let escape = command(json!({
1512 "method": "create",
1513 "params": {
1514 "clientId": "native-preflight-escape",
1515 "schema": { "version": 1, "tables": [] }
1516 }
1517 }));
1518 assert_eq!(
1519 escape["error"]["code"],
1520 Value::from("client.security_preflight_required"),
1521 "{escape}"
1522 );
1523 let read = tauri::async_runtime::block_on(syncular_query(
1524 app.handle().clone(),
1525 "SELECT 1".to_owned(),
1526 None,
1527 ))
1528 .expect("query reply");
1529 assert_eq!(
1530 read["error"]["code"],
1531 Value::from("client.security_preflight_required")
1532 );
1533
1534 let activated = command(json!({
1536 "method": "activateSecurity",
1537 "params": { "headers": { "authorization": "Bearer fresh" } }
1538 }));
1539 assert!(activated.get("error").is_none(), "{activated}");
1540 let read = tauri::async_runtime::block_on(syncular_query(
1541 app.handle().clone(),
1542 "SELECT 1 AS value".to_owned(),
1543 None,
1544 ))
1545 .expect("query reply");
1546 assert_eq!(read["result"]["rows"][0]["value"], 1);
1547 let recreated = command(json!({
1548 "method": "create",
1549 "params": {
1550 "clientId": "native-preflight-escape",
1551 "schema": { "version": 1, "tables": [] }
1552 }
1553 }));
1554 assert!(recreated.get("error").is_none(), "{recreated}");
1555 }
1556
1557 #[test]
1558 fn authority_snapshot_raw_ipc_obeys_native_columns_and_never_opens_protected_reads() {
1559 use tauri::test::{mock_builder, mock_context, noop_assets};
1560 let path =
1561 std::env::temp_dir().join(format!("syncular-authority-ipc-{}.db", std::process::id()));
1562 let app = mock_builder()
1563 .plugin(init(SyncularConfig {
1564 auto_sync: false,
1565 db_path: Some(path.to_string_lossy().into_owned()),
1566 authority_columns: [(
1567 "authority".into(),
1568 vec!["id".into(), "actor_id".into(), "role".into()],
1569 )]
1570 .into(),
1571 ..Default::default()
1572 }))
1573 .build(mock_context(noop_assets()))
1574 .expect("mock app");
1575 let handle = app.handle().clone();
1576 let command = |value: Value| {
1577 tauri::async_runtime::block_on(syncular_command(handle.clone(), value))
1578 .expect("IPC reply")
1579 };
1580 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
1581 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "role", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
1582 ]}]});
1583 let policy = json!([{"table": "authority", "columns": ["id", "actor_id", "role"], "scopes": {"actor_id": ["a", "b"]}}]);
1584 let create = |reads: Value| json!({"method": "create", "params": {"schema": schema, "securityPreflight": true, "transportEnabled": false, "authorityReads": reads}});
1585 let created = command(create(policy.clone()));
1586 assert!(created.get("error").is_none(), "{created}");
1587 let conn = rusqlite::Connection::open(&path).expect("fixture db");
1588 conn.execute_batch("INSERT INTO _syncular_base_authority(id,actor_id,role,secret,_syncular_version)VALUES('one','a','accepted','clinical plaintext',7); INSERT INTO authority SELECT * FROM _syncular_base_authority;").expect("fixture base");
1589 for actor in ["a", "b"] {
1590 let state = json!({"requested": {"actor_id": [actor]}, "effectiveScopes": {"actor_id": [actor]}, "status": "active", "cursor": 7});
1591 conn.execute(
1592 "INSERT INTO _syncular_subscriptions(id,tbl,state_json)VALUES(?1,'authority',?2)",
1593 rusqlite::params![actor, state.to_string()],
1594 )
1595 .expect("fixture subscription");
1596 }
1597 let snapshot = command(json!({"method": "authoritySnapshot", "params": {}}));
1598 assert_eq!(snapshot["result"]["complete"], true, "{snapshot}");
1599 assert_eq!(
1600 snapshot["result"]["tables"][0]["rows"][0]["values"],
1601 json!({"id": "one", "actor_id": "a", "role": "accepted"})
1602 );
1603 assert!(!snapshot.to_string().contains("clinical plaintext"));
1604 assert_eq!(
1605 command(json!({"method": "securityLifecycle", "params": {}}))["result"]["state"],
1606 "preflight"
1607 );
1608 for params in [
1609 json!({"sql": "SELECT secret FROM authority"}),
1610 json!({"columns": ["secret"]}),
1611 json!({"table": "_syncular_meta"}),
1612 json!({"scopes": {"actor_id": ["outside"]}}),
1613 json!({"authorityReads": policy}),
1614 ] {
1615 assert_eq!(
1616 command(json!({"method": "authoritySnapshot", "params": params}))["error"]["code"],
1617 "client.authority_read_forbidden"
1618 );
1619 }
1620 for columns in [
1621 json!(["id", "actor_id", "secret"]),
1622 json!([
1623 "id",
1624 "actor_id",
1625 "role FROM authority; DELETE FROM authority"
1626 ]),
1627 ] {
1628 assert_eq!(
1629 command(create(
1630 json!([{"table": "authority", "columns": columns, "scopes": {"actor_id": ["a"]}}])
1631 ))["error"]["code"],
1632 "client.authority_read_forbidden"
1633 );
1634 }
1635 for method in [
1636 "query",
1637 "querySnapshot",
1638 "mutate",
1639 "setHeaders",
1640 "subscribe",
1641 "sync",
1642 ] {
1643 assert_eq!(
1644 command(json!({"method": method, "params": {"sql": "SELECT id FROM authority"}}))
1645 ["error"]["code"],
1646 "client.security_preflight_required"
1647 );
1648 }
1649 assert_eq!(
1650 tauri::async_runtime::block_on(syncular_query(
1651 app.handle().clone(),
1652 "SELECT id FROM authority".into(),
1653 None
1654 ))
1655 .unwrap()["error"]["code"],
1656 "client.security_preflight_required"
1657 );
1658 assert_eq!(
1659 tauri::async_runtime::block_on(syncular_query_snapshot(
1660 app.handle().clone(),
1661 "SELECT id FROM authority".into(),
1662 None,
1663 None,
1664 None
1665 ))
1666 .unwrap()["error"]["code"],
1667 "client.security_preflight_required"
1668 );
1669 let before = snapshot["result"]["revision"].clone();
1670 assert_eq!(
1671 command(json!({"method": "authoritySnapshot", "params": {}}))["result"]["revision"],
1672 before
1673 );
1674 conn.execute("UPDATE _syncular_subscriptions SET state_json=?1 WHERE id='b'", [json!({"requested": {"actor_id": ["b"]}, "effectiveScopes": {"actor_id": ["b"]}, "status": "active", "cursor": 7, "bootstrapState": "credential-do-not-expose"}).to_string()]).unwrap();
1675 let partial = command(json!({"method": "authoritySnapshot", "params": {}}));
1676 assert_eq!(partial["result"]["complete"], false);
1677 assert!(!partial.to_string().contains("credential-do-not-expose"));
1678 conn.execute(
1679 "UPDATE _syncular_subscriptions SET state_json='credential-do-not-expose' WHERE id='b'",
1680 [],
1681 )
1682 .unwrap();
1683 let corrupt = command(json!({"method": "authoritySnapshot", "params": {}}));
1684 assert_eq!(corrupt["error"]["code"], "sync.local_corrupt");
1685 assert!(!corrupt.to_string().contains("credential-do-not-expose"));
1686 conn.execute("DELETE FROM _syncular_subscriptions WHERE id='b'", [])
1687 .unwrap();
1688 command(json!({"method": "beginSecurityPreflight", "params": {}}));
1689 assert!(
1690 command(json!({"method": "authoritySnapshot", "params": {}}))
1691 .get("error")
1692 .is_none()
1693 );
1694 let barrier = std::sync::Barrier::new(2);
1696 std::thread::scope(|scope| {
1697 let locking = scope.spawn(|| {
1698 barrier.wait();
1699 command(json!({"method": "beginSecurityPreflight", "params": {}}))
1700 });
1701 barrier.wait();
1702 let reading = command(json!({"method": "authoritySnapshot", "params": {}}));
1703 assert_eq!(reading["result"]["revision"], before);
1704 assert!(locking.join().unwrap().get("error").is_none());
1705 });
1706 let barrier = std::sync::Barrier::new(2);
1708 std::thread::scope(|scope| {
1709 let closing = scope.spawn(|| {
1710 barrier.wait();
1711 command(json!({"method": "shutdown", "params": {}}))
1712 });
1713 barrier.wait();
1714 let reading = command(json!({"method": "authoritySnapshot", "params": {}}));
1715 assert!(
1716 reading["result"]["revision"] == before
1717 || reading["error"]["code"] == "client.closed",
1718 "{reading}"
1719 );
1720 assert!(closing.join().unwrap().get("error").is_none());
1721 });
1722 assert_eq!(
1723 command(json!({"method": "authoritySnapshot", "params": {}}))["error"]["code"],
1724 "client.closed"
1725 );
1726 drop(conn);
1727 let _ = std::fs::remove_file(path);
1728 }
1729
1730 #[test]
1731 fn resolve_database_names_a_file_inside_the_configured_directory() {
1732 let dir =
1733 std::env::temp_dir().join(format!("syncular-tauri-resolve-{}", std::process::id()));
1734 let config = SyncularConfig {
1735 db_path: Some("/tmp/default.db".to_owned()),
1736 database_dir: Some(dir.to_string_lossy().into_owned()),
1737 ..Default::default()
1738 };
1739 assert_eq!(
1741 resolve_database(&config, &json!({ "clientId": "c1" })),
1742 Ok(Some("/tmp/default.db".to_owned()))
1743 );
1744 assert_eq!(
1746 resolve_database(&config, &json!({ "database": "app-actor-0f.v2" })),
1747 Ok(Some(
1748 dir.join("app-actor-0f.v2.db")
1749 .to_string_lossy()
1750 .into_owned()
1751 ))
1752 );
1753 assert!(dir.is_dir());
1754 for params in [
1757 json!({ "dbPath": "/etc/other.db" }),
1758 json!({ "database": "../escape" }),
1759 json!({ "database": "a/b" }),
1760 json!({ "database": "a\\b" }),
1761 json!({ "database": "/abs" }),
1762 json!({ "database": ".hidden" }),
1763 json!({ "database": "a..b" }),
1764 json!({ "database": "" }),
1765 json!({ "database": "x".repeat(129) }),
1766 json!({ "database": 7 }),
1767 ] {
1768 let refused = resolve_database(&config, ¶ms).expect_err("refused");
1769 assert_eq!(refused["error"]["code"], "sync.invalid_request", "{params}");
1770 }
1771 let refused = resolve_database(
1774 &SyncularConfig {
1775 database_dir: Some(dir.to_string_lossy().into_owned()),
1776 ..Default::default()
1777 },
1778 &json!({ "clientId": "c1" }),
1779 )
1780 .expect_err("name required");
1781 assert_eq!(refused["error"]["code"], "sync.invalid_request");
1782 let refused = resolve_database(&SyncularConfig::default(), &json!({ "database": "actor" }))
1785 .expect_err("no directory");
1786 assert_eq!(refused["error"]["code"], "sync.invalid_request");
1787 std::fs::remove_dir_all(dir).expect("remove temp directory");
1788 }
1789
1790 #[test]
1793 fn named_databases_keep_one_replica_and_client_id_per_actor() {
1794 use tauri::test::{mock_builder, mock_context, noop_assets};
1795
1796 let dir =
1797 std::env::temp_dir().join(format!("syncular-tauri-actors-{}", std::process::id()));
1798 let _ = std::fs::remove_dir_all(&dir);
1799 let app = mock_builder()
1800 .plugin(init(SyncularConfig {
1801 database_dir: Some(dir.to_string_lossy().into_owned()),
1802 auto_sync: false,
1803 ..Default::default()
1804 }))
1805 .build(mock_context(noop_assets()))
1806 .expect("build mock app");
1807 let command = |value: Value| {
1808 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1809 .expect("command reply")
1810 };
1811 let snapshot = |sql: &str| {
1812 tauri::async_runtime::block_on(syncular_query_snapshot(
1813 app.handle().clone(),
1814 sql.to_owned(),
1815 None,
1816 None,
1817 None,
1818 ))
1819 .expect("snapshot reply")
1820 };
1821 let schema = json!({
1822 "version": 1,
1823 "tables": [{
1824 "name": "todo", "primaryKey": "id",
1825 "columns": [
1826 { "name": "id", "type": "string", "nullable": false },
1827 { "name": "title", "type": "string", "nullable": false }
1828 ],
1829 "scopes": []
1830 }]
1831 });
1832 let create = |database: &str, client_id: &str| {
1833 command(json!({ "method": "create", "params": {
1834 "database": database, "clientId": client_id, "schema": schema
1835 } }))
1836 };
1837
1838 assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1839 command(json!({ "method": "mutate", "params": { "mutations": [{
1840 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "a" }
1841 }] } }));
1842 assert_eq!(
1843 snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1844 "a"
1845 );
1846 assert_eq!(
1847 command(json!({ "method": "shutdown" }))["result"],
1848 json!({})
1849 );
1850
1851 assert_eq!(create("actor-b", "client-b")["result"], json!({}));
1852 assert_eq!(
1853 snapshot("SELECT title FROM todo")["result"]["rows"],
1854 json!([]),
1855 "the reader follows the second actor's database"
1856 );
1857 assert!(dir.join("actor-a.db").is_file());
1858 assert!(dir.join("actor-b.db").is_file());
1859 assert_eq!(
1860 command(json!({ "method": "shutdown" }))["result"],
1861 json!({})
1862 );
1863
1864 assert_eq!(
1866 create("actor-a", "client-b")["error"]["code"],
1867 "client.identity_mismatch"
1868 );
1869 assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1870 assert_eq!(
1871 snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1872 "a"
1873 );
1874 assert_eq!(
1875 create("../actor-a", "client-a")["error"]["code"],
1876 "sync.invalid_request"
1877 );
1878 command(json!({ "method": "shutdown" }));
1879 std::fs::remove_dir_all(dir).expect("remove temp directory");
1880 }
1881
1882 #[test]
1883 fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
1884 let parsed = parse_coverage(Some(&json!([{
1885 "base": {
1886 "table": "tasks",
1887 "variable": "project_id",
1888 "fixedScopes": { "tenant_id": ["one", "two"] },
1889 "params": "opaque"
1890 },
1891 "units": ["a", "b"]
1892 }])))
1893 .expect("parse coverage");
1894 assert_eq!(parsed.len(), 1);
1895 let entry = &parsed[0];
1896 assert_eq!(entry.base.table, "tasks");
1897 assert_eq!(entry.base.variable, "project_id");
1898 assert_eq!(
1899 entry.base.fixed_scopes,
1900 vec![(
1901 "tenant_id".to_owned(),
1902 vec!["one".to_owned(), "two".to_owned()]
1903 )]
1904 );
1905 assert_eq!(entry.base.params.as_deref(), Some("opaque"));
1906 assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
1907 }
1908
1909 #[test]
1913 fn owner_thread_round_trips_over_mailbox() {
1914 use std::sync::mpsc::channel;
1915 use std::sync::{Arc, Mutex as StdMutex};
1916
1917 let (tx, rx) = channel::<Request>();
1918 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
1919 let events_for_thread = Arc::clone(&events);
1920 let config = SyncularConfig {
1921 auto_sync: false,
1922 ..Default::default()
1923 };
1924 let owner_tx = tx.clone();
1925 let handle = std::thread::spawn(move || {
1926 run_owner_thread(config, owner_tx, rx, move |v| {
1927 events_for_thread.lock().unwrap().push(v.clone());
1928 });
1929 });
1930
1931 let call = |command: Value| -> Value {
1932 let (rtx, rrx) = channel();
1933 tx.send(Request::Command {
1934 command,
1935 reply: rtx,
1936 })
1937 .unwrap();
1938 rrx.recv().unwrap()
1939 };
1940
1941 let schema = json!({
1942 "version": 1,
1943 "tables": [{
1944 "name": "todo", "primaryKey": "id",
1945 "columns": [
1946 { "name": "id", "type": "string", "nullable": false },
1947 { "name": "title", "type": "string", "nullable": false }
1948 ],
1949 "scopes": []
1950 }]
1951 });
1952 assert_eq!(
1953 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1954 ["result"],
1955 json!({})
1956 );
1957 call(json!({ "method": "mutate", "params": { "mutations": [{
1958 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1959 }] } }));
1960
1961 let (qtx, qrx) = channel();
1963 tx.send(Request::Query {
1964 sql: "SELECT title FROM todo".to_owned(),
1965 params: Value::Null,
1966 reply: qtx,
1967 })
1968 .unwrap();
1969 let rows = qrx.recv().unwrap();
1970 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1971
1972 let (htx, hrx) = channel();
1975 tx.send(Request::SetHeaders {
1976 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1977 reply: htx,
1978 })
1979 .unwrap();
1980 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1981
1982 tx.send(Request::Shutdown).unwrap();
1983 handle.join().unwrap();
1984
1985 let seen = events.lock().unwrap();
1986 let kinds: Vec<String> = seen
1987 .iter()
1988 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1989 .collect();
1990 assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1992 }
1993
1994 #[cfg(feature = "native-transport")]
1995 #[test]
1996 fn pending_network_round_keeps_local_mutations_and_queries_responsive() {
1997 use ssp2::model::{Change, Frame, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
1998 use ssp2::segment::{encode_row, Column, ColumnType, ColumnValue};
1999 use std::io::{Read, Write};
2000 use std::net::TcpListener;
2001 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2002 use std::sync::mpsc::channel;
2003 use std::sync::Arc;
2004
2005 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2006 let address = listener.local_addr().unwrap();
2007 let (entered_tx, entered_rx) = channel();
2008 let (release_tx, release_rx) = channel();
2009 let (delivered_tx, delivered_rx) = channel();
2010 let (ack_tx, ack_rx) = channel();
2011 let image_sent = Arc::new(AtomicBool::new(false));
2012 let server_image_sent = image_sent.clone();
2013 let requests = Arc::new(AtomicUsize::new(0));
2014 let server_requests = requests.clone();
2015 let server = std::thread::spawn(move || {
2016 let mut pushed = Vec::new();
2017 loop {
2018 let (mut socket, _) = listener.accept().unwrap();
2019 server_requests.fetch_add(1, Ordering::SeqCst);
2020 let mut header = Vec::new();
2021 while !header.ends_with(b"\r\n\r\n") {
2022 let mut byte = [0];
2023 socket.read_exact(&mut byte).unwrap();
2024 header.push(byte[0]);
2025 }
2026 let header = String::from_utf8(header).unwrap();
2027 assert!(header
2028 .to_ascii_lowercase()
2029 .contains("authorization: bearer fresh"));
2030 let length: usize = header
2031 .lines()
2032 .find_map(|line| {
2033 let (name, value) = line.split_once(':')?;
2034 name.eq_ignore_ascii_case("content-length")
2035 .then(|| value.trim().parse().unwrap())
2036 })
2037 .unwrap();
2038 let mut bytes = vec![0; length];
2039 socket.read_exact(&mut bytes).unwrap();
2040 let request = ssp2::decode_message(&bytes).unwrap();
2041 let reset = matches!(
2042 &request.frames[0],
2043 Frame::ReqHeader {
2044 log_epoch: None,
2045 ..
2046 }
2047 );
2048 let deliver = !reset && pushed.len() == 3;
2049 let mut frames = vec![Frame::RespHeader {
2050 required_schema_version: None,
2051 latest_schema_version: None,
2052 log_epoch: Some("epoch".into()),
2053 reset_required: Some(reset),
2054 }];
2055 if !reset {
2056 let commits: Vec<_> = request
2057 .frames
2058 .iter()
2059 .filter_map(|frame| {
2060 if let Frame::PushCommit {
2061 client_commit_id,
2062 operations,
2063 } = frame
2064 {
2065 Some((client_commit_id.clone(), operations.len()))
2066 } else {
2067 None
2068 }
2069 })
2070 .collect();
2071 if !commits.is_empty() && pushed.is_empty() {
2072 entered_tx.send(()).unwrap();
2073 release_rx.recv().unwrap();
2074 }
2075 for (id, count) in commits {
2076 pushed.push(id.clone());
2077 frames.push(Frame::PushResult {
2078 client_commit_id: id,
2079 status: PushStatus::Applied,
2080 commit_seq: Some(pushed.len() as i64),
2081 results: (0..count)
2082 .map(|i| OpResult::Applied { op_index: i as i32 })
2083 .collect(),
2084 });
2085 }
2086 }
2087 if !reset
2088 && request
2089 .frames
2090 .iter()
2091 .any(|frame| matches!(frame, Frame::Subscription { id, .. } if id == "own"))
2092 {
2093 frames.push(Frame::SubStart {
2094 id: "own".into(),
2095 status: SubStatus::Active,
2096 reason_code: String::new(),
2097 effective_scopes: vec![("id".into(), vec!["one".into()])],
2098 bootstrap: false,
2099 });
2100 if deliver {
2101 assert!(request
2102 .frames
2103 .iter()
2104 .any(|frame| matches!(frame, Frame::PullHeader { .. })));
2105 assert!(!request
2106 .frames
2107 .iter()
2108 .any(|frame| matches!(frame, Frame::PushCommit { .. })));
2109 let columns = ["id", "title"].map(|name| Column {
2110 name: name.into(),
2111 ty: ColumnType::String,
2112 nullable: false,
2113 });
2114 let mut row = ssp2::primitives::Writer::new();
2115 encode_row(
2116 &mut row,
2117 &columns,
2118 &vec![
2119 Some(ColumnValue::String("one".into())),
2120 Some(ColumnValue::String("300/v3".into())),
2121 ],
2122 );
2123 frames.push(Frame::Commit {
2124 commit_seq: 3,
2125 created_at_ms: 0,
2126 actor_id: "actor".into(),
2127 tables: vec!["todo".into()],
2128 changes: vec![Change {
2129 table_index: 0,
2130 row_id: "one".into(),
2131 op: Op::Upsert,
2132 row_version: Some(3),
2133 scopes: vec![("id".into(), "one".into())],
2134 row: Some(row.into_bytes()),
2135 }],
2136 });
2137 server_image_sent.store(true, Ordering::SeqCst);
2138 }
2139 frames.push(Frame::SubEnd {
2140 next_cursor: if deliver { 3 } else { 0 },
2141 bootstrap_state: None,
2142 });
2143 }
2144 let response = ssp2::encode_message(&Message {
2145 wire_version: ssp2::decode::WIRE_VERSION,
2146 msg_kind: MsgKind::Response,
2147 frames,
2148 });
2149 write!(
2150 socket,
2151 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2152 response.len()
2153 )
2154 .unwrap();
2155 socket.write_all(&response).unwrap();
2156 if deliver {
2157 break;
2158 }
2159 }
2160 pushed
2161 });
2162 let (tx, rx) = channel();
2163 let owner_tx = tx.clone();
2164 let owner = std::thread::spawn(move || {
2165 run_owner_thread(
2166 SyncularConfig {
2167 base_url: Some(format!("http://{address}")),
2168 auto_sync: true,
2169 ..Default::default()
2170 },
2171 owner_tx,
2172 rx,
2173 move |event| {
2174 if event["type"] == "change" && event["batch"]["outcomesChanged"] == true {
2175 let _ = ack_tx.send(());
2176 }
2177 if image_sent.load(Ordering::SeqCst) && event["type"] == "change" {
2178 let _ = delivered_tx.send(());
2179 }
2180 },
2181 )
2182 });
2183 let call = |command: Value| {
2184 let (reply, result) = channel();
2185 tx.send(Request::Command { command, reply }).unwrap();
2186 result
2187 };
2188 let created = call(json!({ "method": "create", "params": {
2189 "clientId": "pending-round", "transportEnabled": false, "securityPreflight": true, "schema": { "version": 1, "tables": [{
2190 "name": "todo", "primaryKey": "id", "columns": [
2191 { "name": "id", "type": "string", "nullable": false },
2192 { "name": "title", "type": "string", "nullable": false }
2193 ], "scopes": [{ "pattern": "todo:{id}" }]
2194 }] }
2195 } }));
2196 assert!(created.recv().unwrap().get("error").is_none());
2197 assert!(call(json!({ "method": "activateSecurity", "params": {} }))
2198 .recv()
2199 .unwrap()
2200 .get("error")
2201 .is_none());
2202 assert!(call(json!({ "method": "subscribe", "params": { "id": "own", "table": "todo", "scopes": { "id": ["one"] } } })).recv().unwrap().get("error").is_none());
2203 let first = call(json!({ "method": "mutate", "params": { "mutations": [{
2204 "op": "upsert", "table": "todo", "values": { "id": "one", "title": "100/v1" }
2205 }] } }))
2206 .recv()
2207 .unwrap();
2208 let queued_second = call(json!({ "method": "mutate", "params": { "mutations": [{
2209 "op": "patch", "table": "todo", "values": { "id": "one", "title": "200/v2" }
2210 }] } }))
2211 .recv()
2212 .unwrap();
2213 assert_eq!(
2214 call(json!({ "method": "sync" })).recv().unwrap()["result"]["errorCode"],
2215 "sync.offline"
2216 );
2217 assert_eq!(
2218 call(json!({ "method": "connectRealtime" })).recv().unwrap()["error"]["code"],
2219 "sync.offline"
2220 );
2221 assert_eq!(requests.load(Ordering::SeqCst), 0);
2222 let (reply, result) = channel();
2223 tx.send(Request::SetHeaders {
2224 headers: vec![("authorization".into(), "Bearer fresh".into())],
2225 reply,
2226 })
2227 .unwrap();
2228 assert_eq!(result.recv().unwrap()["result"], Value::Null);
2229 assert!(
2230 call(json!({ "method": "setTransportEnabled", "params": { "enabled": true } }))
2231 .recv()
2232 .unwrap()
2233 .get("error")
2234 .is_none()
2235 );
2236 entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
2237 assert!(
2238 call(json!({ "method": "setTransportEnabled", "params": { "enabled": false } }))
2239 .recv()
2240 .unwrap()
2241 .get("error")
2242 .is_none()
2243 );
2244 let started = Instant::now();
2245 let second = call(json!({ "method": "mutate", "params": { "mutations": [{
2246 "op": "patch", "table": "todo", "values": { "id": "one", "title": "300/v3" }
2247 }] } }));
2248 let local_reply = second.recv_timeout(Duration::from_millis(100));
2249 let elapsed = started.elapsed();
2250 let (query_tx, query_rx) = channel();
2251 tx.send(Request::Query {
2252 sql: "SELECT title FROM todo ORDER BY id".into(),
2253 params: Value::Null,
2254 reply: query_tx,
2255 })
2256 .unwrap();
2257 let local_rows = query_rx.recv_timeout(Duration::from_millis(100));
2258 release_tx.send(()).unwrap();
2261 let second_reply = local_reply
2262 .clone()
2263 .unwrap_or_else(|_| second.recv().unwrap());
2264 let rows = local_rows
2265 .clone()
2266 .unwrap_or_else(|_| query_rx.recv().unwrap());
2267 loop {
2269 ack_rx.recv_timeout(Duration::from_secs(10)).unwrap();
2270 let outcomes = call(json!({ "method": "commitOutcomes" })).recv().unwrap();
2271 if outcomes["result"]["outcomes"]
2272 .as_array()
2273 .unwrap()
2274 .iter()
2275 .filter(|o| o["status"] == "applied")
2276 .count()
2277 == 2
2278 {
2279 break;
2280 }
2281 }
2282 let paused_count = requests.load(Ordering::SeqCst);
2283 assert_eq!(
2284 call(json!({ "method": "sync" })).recv().unwrap()["result"]["errorCode"],
2285 "sync.offline"
2286 );
2287 assert_eq!(requests.load(Ordering::SeqCst), paused_count);
2288 assert!(
2289 call(json!({ "method": "setTransportEnabled", "params": { "enabled": true } }))
2290 .recv()
2291 .unwrap()
2292 .get("error")
2293 .is_none()
2294 );
2295 delivered_rx
2296 .recv_timeout(Duration::from_secs(10))
2297 .expect("ACK schedules the own-image pull without realtime or a manual round");
2298 let (query_tx, query_rx) = channel();
2299 tx.send(Request::Query {
2300 sql: "SELECT title,_sync_version AS version FROM todo WHERE id='one'".into(),
2301 params: Value::Null,
2302 reply: query_tx,
2303 })
2304 .unwrap();
2305 let delivered = query_rx.recv().unwrap();
2306 assert_eq!(delivered["result"]["rows"][0]["title"], "300/v3");
2307 assert_eq!(delivered["result"]["rows"][0]["version"], 3);
2308 let pushed = server.join().unwrap();
2309 tx.send(Request::Shutdown).unwrap();
2310 owner.join().unwrap();
2311 assert!(
2312 local_reply.is_ok(),
2313 "local mutation waited for network reply"
2314 );
2315 assert!(local_rows.is_ok(), "local query waited for network reply");
2316 eprintln!("second local mutation while network pending: {elapsed:?}");
2317 assert_eq!(rows["result"]["rows"].as_array().unwrap().len(), 1);
2318 assert_eq!(
2319 pushed,
2320 vec![
2321 first["result"]["clientCommitId"].as_str().unwrap(),
2322 queued_second["result"]["clientCommitId"].as_str().unwrap(),
2323 second_reply["result"]["clientCommitId"].as_str().unwrap()
2324 ]
2325 );
2326 }
2327
2328 #[cfg(feature = "native-transport")]
2329 #[test]
2330 fn owner_thread_sync_until_idle_reports_budget_exhaustion_with_the_report() {
2331 use ssp2::model::{Change, Frame, Message, MsgKind, Op, SubStatus};
2332 use ssp2::segment::{encode_row, Column, ColumnType, ColumnValue};
2333 use std::io::{ErrorKind, Read, Write};
2334 use std::net::TcpListener;
2335 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2336 use std::sync::mpsc::channel;
2337 use std::sync::Arc;
2338
2339 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2340 listener.set_nonblocking(true).unwrap();
2341 let address = listener.local_addr().unwrap();
2342 let requests = Arc::new(AtomicUsize::new(0));
2343 let stop = Arc::new(AtomicBool::new(false));
2344 let server_requests = Arc::clone(&requests);
2345 let server_stop = Arc::clone(&stop);
2346 let server = std::thread::spawn(move || {
2347 let columns = ["id", "title"].map(|name| Column {
2348 name: name.into(),
2349 ty: ColumnType::String,
2350 nullable: false,
2351 });
2352 while !server_stop.load(Ordering::SeqCst) {
2353 let (mut socket, _) = match listener.accept() {
2354 Ok(accepted) => accepted,
2355 Err(error) if error.kind() == ErrorKind::WouldBlock => {
2356 std::thread::yield_now();
2357 continue;
2358 }
2359 Err(error) => panic!("accept: {error}"),
2360 };
2361 socket.set_nonblocking(false).unwrap();
2362 let round = server_requests.fetch_add(1, Ordering::SeqCst) + 1;
2363 let mut header = Vec::new();
2364 while !header.ends_with(b"\r\n\r\n") {
2365 let mut byte = [0];
2366 socket.read_exact(&mut byte).unwrap();
2367 header.push(byte[0]);
2368 }
2369 let header = String::from_utf8(header).unwrap();
2370 let length: usize = header
2371 .lines()
2372 .find_map(|line| {
2373 let (name, value) = line.split_once(':')?;
2374 name.eq_ignore_ascii_case("content-length")
2375 .then(|| value.trim().parse().unwrap())
2376 })
2377 .unwrap();
2378 let mut bytes = vec![0; length];
2379 socket.read_exact(&mut bytes).unwrap();
2380 let request = ssp2::decode_message(&bytes).unwrap();
2381 let reset = matches!(
2382 &request.frames[0],
2383 Frame::ReqHeader {
2384 log_epoch: None,
2385 ..
2386 }
2387 );
2388 let mut frames = vec![Frame::RespHeader {
2389 required_schema_version: None,
2390 latest_schema_version: None,
2391 log_epoch: Some("epoch".into()),
2392 reset_required: Some(reset),
2393 }];
2394 if !reset
2395 && request
2396 .frames
2397 .iter()
2398 .any(|frame| matches!(frame, Frame::Subscription { id, .. } if id == "own"))
2399 {
2400 frames.push(Frame::SubStart {
2401 id: "own".into(),
2402 status: SubStatus::Active,
2403 reason_code: String::new(),
2404 effective_scopes: vec![("id".into(), vec!["one".into()])],
2405 bootstrap: false,
2406 });
2407 let mut row = ssp2::primitives::Writer::new();
2408 encode_row(
2409 &mut row,
2410 &columns,
2411 &vec![
2412 Some(ColumnValue::String("one".into())),
2413 Some(ColumnValue::String(format!("v{round}"))),
2414 ],
2415 );
2416 frames.push(Frame::Commit {
2417 commit_seq: round as i64,
2418 created_at_ms: 0,
2419 actor_id: "actor".into(),
2420 tables: vec!["todo".into()],
2421 changes: vec![Change {
2422 table_index: 0,
2423 row_id: "one".into(),
2424 op: Op::Upsert,
2425 row_version: Some(round as i64),
2426 scopes: vec![("id".into(), "one".into())],
2427 row: Some(row.into_bytes()),
2428 }],
2429 });
2430 frames.push(Frame::SubEnd {
2431 next_cursor: round as i64,
2432 bootstrap_state: None,
2433 });
2434 }
2435 let response = ssp2::encode_message(&Message {
2436 wire_version: ssp2::decode::WIRE_VERSION,
2437 msg_kind: MsgKind::Response,
2438 frames,
2439 });
2440 write!(
2441 socket,
2442 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2443 response.len()
2444 )
2445 .unwrap();
2446 socket.write_all(&response).unwrap();
2447 }
2448 });
2449
2450 let (tx, rx) = channel::<Request>();
2451 let owner_tx = tx.clone();
2452 let owner = std::thread::spawn(move || {
2453 run_owner_thread(
2454 SyncularConfig {
2455 base_url: Some(format!("http://{address}")),
2456 auto_sync: false,
2457 ..Default::default()
2458 },
2459 owner_tx,
2460 rx,
2461 |_| {},
2462 )
2463 });
2464 let call = |command: Value| -> Value {
2465 let (reply, result) = channel();
2466 tx.send(Request::Command { command, reply }).unwrap();
2467 result.recv().unwrap()
2468 };
2469
2470 let created = call(json!({ "method": "create", "params": {
2471 "clientId": "budget-tauri", "transportEnabled": true, "schema": { "version": 1, "tables": [{
2472 "name": "todo", "primaryKey": "id",
2473 "columns": [
2474 { "name": "id", "type": "string", "nullable": false },
2475 { "name": "title", "type": "string", "nullable": false }
2476 ],
2477 "scopes": [{ "pattern": "todo:{id}" }]
2478 }] }
2479 } }));
2480 assert!(created.get("error").is_none(), "{created}");
2481 assert!(
2482 call(json!({ "method": "subscribe", "params": { "id": "own", "table": "todo", "scopes": { "id": ["one"] } } }))
2483 .get("error")
2484 .is_none()
2485 );
2486
2487 let before = requests.load(Ordering::SeqCst);
2489 let invalid = call(json!({ "method": "syncUntilIdle", "params": { "maxRounds": 0 } }));
2490 assert_eq!(
2491 invalid["error"]["code"], "sync.invalid_request",
2492 "{invalid}"
2493 );
2494 assert_eq!(
2495 requests.load(Ordering::SeqCst),
2496 before,
2497 "an invalid budget performs no round"
2498 );
2499
2500 let single = call(json!({ "method": "sync", "params": { "maxRounds": 0 } }));
2503 assert!(single["result"].get("errorCode").is_none(), "{single}");
2504
2505 let before_capped = requests.load(Ordering::SeqCst);
2506 let exhausted = call(json!({ "method": "syncUntilIdle", "params": { "maxRounds": 2 } }));
2507 assert!(
2508 exhausted["result"].get("errorCode").is_none(),
2509 "{exhausted}"
2510 );
2511 assert_eq!(exhausted["result"]["ok"], json!(true), "{exhausted}");
2512 assert_eq!(
2513 exhausted["result"]["budgetExhausted"],
2514 json!(true),
2515 "{exhausted}"
2516 );
2517 assert_eq!(
2518 requests.load(Ordering::SeqCst) - before_capped,
2519 2,
2520 "the capped run made exactly two rounds: {exhausted}"
2521 );
2522 assert_eq!(
2523 exhausted["result"]["report"]["commitsApplied"],
2524 json!(2),
2525 "the aggregate retains every round's work: {exhausted}"
2526 );
2527
2528 tx.send(Request::Shutdown).unwrap();
2529 owner.join().unwrap();
2530 stop.store(true, Ordering::SeqCst);
2531 server.join().unwrap();
2532 }
2533
2534 #[test]
2535 fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
2536 use std::sync::mpsc::channel;
2537
2538 let path =
2539 std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
2540 let config = SyncularConfig {
2541 db_path: Some(path.to_string_lossy().into_owned()),
2542 auto_sync: false,
2543 ..Default::default()
2544 };
2545 let (tx, rx) = channel::<Request>();
2546 let owner_tx = tx.clone();
2547 let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
2548
2549 let (create_tx, create_rx) = channel();
2550 tx.send(Request::Command {
2551 command: json!({
2552 "method": "create",
2553 "params": {
2554 "clientId": "sidecar-client",
2555 "schema": { "version": 1, "tables": [] },
2556 "dbPath": path.to_string_lossy()
2557 }
2558 }),
2559 reply: create_tx,
2560 })
2561 .expect("post create");
2562 assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
2563
2564 let (read_tx, read_rx) = channel::<ReadRequest>();
2565 let read_path = path.to_string_lossy().into_owned();
2566 let owner_tx = tx.clone();
2567 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
2568
2569 let (entered_tx, entered_rx) = channel();
2572 tx.send(Request::Block {
2573 duration: Duration::from_millis(200),
2574 entered: entered_tx,
2575 })
2576 .expect("block owner");
2577 entered_rx.recv().expect("owner entered blocking round");
2578 let (snapshot_tx, snapshot_rx) = channel();
2579 read_tx
2580 .send(ReadRequest::QuerySnapshot {
2581 sql: "SELECT 1 AS value".to_owned(),
2582 params: Vec::new(),
2583 coverage: Vec::new(),
2584 owner: None,
2585 reply: snapshot_tx,
2586 })
2587 .expect("post snapshot");
2588 let snapshot = snapshot_rx
2589 .recv_timeout(Duration::from_millis(50))
2590 .expect("local snapshot must not wait for the owner");
2591 assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
2592
2593 let (failed_tx, failed_rx) = channel();
2594 read_tx
2595 .send(ReadRequest::QuerySnapshot {
2596 sql: "SELECT private_value FROM missing_private_table".to_owned(),
2597 params: Vec::new(),
2598 coverage: Vec::new(),
2599 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
2600 reply: failed_tx,
2601 })
2602 .expect("post failed snapshot");
2603 assert_eq!(
2604 failed_rx.recv().expect("failed snapshot reply")["error"]["code"],
2605 "client.failed"
2606 );
2607 let (diagnostics_tx, diagnostics_rx) = channel();
2608 tx.send(Request::Command {
2609 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
2610 reply: diagnostics_tx,
2611 })
2612 .expect("post diagnostics");
2613 assert_eq!(
2614 diagnostics_rx.recv().expect("diagnostics reply")["result"]["queryFailures"][0]["id"],
2615 "queries:missing"
2616 );
2617
2618 let (recovered_tx, recovered_rx) = channel();
2619 read_tx
2620 .send(ReadRequest::QuerySnapshot {
2621 sql: "SELECT 1 AS value".to_owned(),
2622 params: Vec::new(),
2623 coverage: Vec::new(),
2624 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
2625 reply: recovered_tx,
2626 })
2627 .expect("post recovered snapshot");
2628 assert!(recovered_rx.recv().expect("recovered snapshot reply")["result"].is_object());
2629 let (cleared_tx, cleared_rx) = channel();
2630 tx.send(Request::Command {
2631 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
2632 reply: cleared_tx,
2633 })
2634 .expect("post cleared diagnostics");
2635 assert_eq!(
2636 cleared_rx.recv().expect("cleared diagnostics reply")["result"]["queryFailures"],
2637 json!([])
2638 );
2639
2640 for index in 0..300 {
2643 let (reply, result) = channel();
2644 read_tx
2645 .send(ReadRequest::QuerySnapshot {
2646 sql: "SELECT value FROM absent_owner_table".to_owned(),
2647 params: Vec::new(),
2648 coverage: Vec::new(),
2649 owner: Some((format!("owner-{index}"), vec!["tasks".to_owned()])),
2650 reply,
2651 })
2652 .expect("post owner failure");
2653 assert!(result.recv().expect("failure reply")["error"].is_object());
2654 }
2655 let (reply, result) = channel();
2656 tx.send(Request::Command {
2657 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
2658 reply,
2659 })
2660 .expect("post bounded diagnostics");
2661 let value = result.recv().expect("bounded diagnostics");
2662 let failures = value["result"]["queryFailures"].as_array().unwrap();
2663 assert_eq!(failures.len(), 256);
2664 assert_eq!(failures.last().unwrap()["id"], "owner-299");
2665
2666 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
2667 reader.join().expect("join reader");
2668 tx.send(Request::Shutdown).expect("stop owner");
2669 owner.join().expect("join owner");
2670 std::fs::remove_file(path).expect("remove temp database");
2671 }
2672 #[test]
2673 fn read_failure_json_preserves_retryability_and_storage_details() {
2674 let busy = QueryReadFailure {
2675 code: Some("client.storage_busy"),
2676 sqlite_code: Some(5),
2677 sqlite_message: Some("database is locked".to_owned()),
2678 rollback_failure: Some(Box::new(QueryReadFailure {
2679 code: Some("client.storage_busy"),
2680 sqlite_code: Some(6),
2681 sqlite_message: Some("rollback could not lock".to_owned()),
2682 rollback_failure: None,
2683 message: "local SQLite storage is busy".to_owned(),
2684 })),
2685 message: "local SQLite storage is busy".to_owned(),
2686 };
2687 let value = read_failure_json(busy);
2688 assert_eq!(value["error"]["code"], "client.storage_busy");
2689 assert_eq!(value["error"]["retryable"], true);
2690 assert_eq!(value["error"]["details"]["sqliteCode"], 5);
2691 assert_eq!(
2692 value["error"]["details"]["sqliteMessage"],
2693 "database is locked"
2694 );
2695 assert_eq!(
2696 value["error"]["details"]["rollbackFailure"]["sqliteCode"],
2697 6
2698 );
2699 let corrupt = read_failure_json(QueryReadFailure {
2700 code: Some("sync.local_corrupt"),
2701 sqlite_code: None,
2702 sqlite_message: None,
2703 rollback_failure: None,
2704 message: "persisted local revision is invalid".to_owned(),
2705 });
2706 assert_eq!(corrupt["error"]["code"], "sync.local_corrupt");
2707 assert_eq!(corrupt["error"]["retryable"], false);
2708 assert!(corrupt["error"]["details"].is_null());
2709 }
2710
2711 #[test]
2712 fn snapshot_read_command_is_strict_and_dispatches_by_storage_mode() {
2713 use tauri::test::{mock_builder, mock_context, noop_assets};
2714
2715 let path =
2716 std::env::temp_dir().join(format!("syncular-tauri-snapshot-{}.db", std::process::id()));
2717 let _ = std::fs::remove_file(&path);
2718 let file_app = mock_builder()
2719 .plugin(init(SyncularConfig {
2720 db_path: Some(path.to_string_lossy().into_owned()),
2721 auto_sync: false,
2722 ..Default::default()
2723 }))
2724 .build(mock_context(noop_assets()))
2725 .expect("build file app");
2726 let command = |value: Value| {
2727 tauri::async_runtime::block_on(syncular_command(file_app.handle().clone(), value))
2728 .expect("command reply")
2729 };
2730 let read = |statements: Value, owner: Option<Value>| {
2731 tauri::async_runtime::block_on(syncular_snapshot_read(
2732 file_app.handle().clone(),
2733 statements,
2734 None,
2735 None,
2736 None,
2737 owner,
2738 ))
2739 .expect("read reply")
2740 };
2741 let schema = json!({
2742 "version": 1,
2743 "tables": [{
2744 "name": "todo", "primaryKey": "id",
2745 "columns": [
2746 { "name": "id", "type": "string", "nullable": false },
2747 { "name": "n", "type": "integer", "nullable": true }
2748 ],
2749 "scopes": []
2750 }]
2751 });
2752 assert_eq!(
2753 command(json!({ "method": "create", "params": {
2754 "clientId": "c1", "schema": schema.clone()
2755 } }))["result"],
2756 json!({})
2757 );
2758 let mutation = command(json!({ "method": "mutate", "params": { "mutations": [{
2761 "op": "upsert", "table": "todo",
2762 "values": { "id": "r1", "n": 9_007_199_254_740_991u64 }
2763 }] } }));
2764 assert!(
2765 mutation["result"]["clientCommitId"].as_str().is_some(),
2766 "safe authoring must queue a commit: {mutation}"
2767 );
2768
2769 let ok = read(
2774 json!([
2775 { "sql": "SELECT id, n FROM todo" },
2776 { "sql": "SELECT CAST('9007199254740993' AS INTEGER) AS n FROM todo" }
2777 ]),
2778 None,
2779 );
2780 assert!(
2781 ok["result"]["revision"].as_str().is_some(),
2782 "expected a decimal revision: {ok}"
2783 );
2784 assert_eq!(ok["result"]["queries"][0][0]["id"], "r1");
2785 assert_eq!(
2786 ok["result"]["queries"][0][0]["n"],
2787 json!(9_007_199_254_740_991u64)
2788 );
2789 assert_eq!(
2790 ok["result"]["queries"][1][0]["n"]["$bigint"],
2791 "9007199254740993"
2792 );
2793
2794 let malformed = read(json!([{ "sql": "SELECT ?", "params": "oops" }]), None);
2797 assert_eq!(
2798 malformed["error"]["code"], "sync.invalid_request",
2799 "{malformed}"
2800 );
2801 let nonarray = read(json!("oops"), None);
2802 assert_eq!(
2803 nonarray["error"]["code"], "sync.invalid_request",
2804 "{nonarray}"
2805 );
2806
2807 let failed = read(
2809 json!([{ "sql": "SELECT * FROM does_not_exist" }]),
2810 Some(json!({ "id": "queries:snapshot", "tables": ["todo"] })),
2811 );
2812 assert!(failed["error"].is_object(), "{failed}");
2813 let diagnostics = command(json!({ "method": "diagnosticsSnapshot", "params": {} }));
2814 assert_eq!(
2815 diagnostics["result"]["queryFailures"][0]["id"], "queries:snapshot",
2816 "{diagnostics}"
2817 );
2818 let recovered = read(
2820 json!([{ "sql": "SELECT 1 AS n" }]),
2821 Some(json!({ "id": "queries:snapshot", "tables": ["todo"] })),
2822 );
2823 assert!(recovered["result"].is_object(), "{recovered}");
2824 let cleared = command(json!({ "method": "diagnosticsSnapshot", "params": {} }));
2825 assert_eq!(
2826 cleared["result"]["queryFailures"],
2827 json!([]),
2828 "a successful owned file read clears the failure: {cleared}"
2829 );
2830
2831 command(json!({ "method": "shutdown" }));
2832
2833 let memory_app = mock_builder()
2835 .plugin(init(SyncularConfig {
2836 auto_sync: false,
2837 ..Default::default()
2838 }))
2839 .build(mock_context(noop_assets()))
2840 .expect("build memory app");
2841 let memory_command = |value: Value| {
2842 tauri::async_runtime::block_on(syncular_command(memory_app.handle().clone(), value))
2843 .expect("command reply")
2844 };
2845 let memory_read = |statements: Value| {
2846 tauri::async_runtime::block_on(syncular_snapshot_read(
2847 memory_app.handle().clone(),
2848 statements,
2849 None,
2850 None,
2851 None,
2852 None,
2853 ))
2854 .expect("read reply")
2855 };
2856 assert_eq!(
2857 memory_command(json!({ "method": "create", "params": {
2858 "clientId": "c2", "schema": schema
2859 } }))["result"],
2860 json!({})
2861 );
2862 let memory_ok = memory_read(json!([{ "sql": "SELECT 7 AS n" }]));
2863 assert_eq!(memory_ok["result"]["queries"][0][0]["n"], 7, "{memory_ok}");
2864
2865 let memory_failed = tauri::async_runtime::block_on(syncular_snapshot_read(
2868 memory_app.handle().clone(),
2869 json!([{ "sql": "SELECT * FROM does_not_exist" }]),
2870 None,
2871 None,
2872 None,
2873 Some(json!({ "id": "queries:memory", "tables": ["todo"] })),
2874 ))
2875 .expect("read reply");
2876 assert!(memory_failed["error"].is_object(), "{memory_failed}");
2877 let memory_diagnostics =
2878 memory_command(json!({ "method": "diagnosticsSnapshot", "params": {} }));
2879 assert_eq!(
2880 memory_diagnostics["result"]["queryFailures"][0]["id"], "queries:memory",
2881 "{memory_diagnostics}"
2882 );
2883 let memory_recovered = tauri::async_runtime::block_on(syncular_snapshot_read(
2884 memory_app.handle().clone(),
2885 json!([{ "sql": "SELECT 1 AS n" }]),
2886 None,
2887 None,
2888 None,
2889 Some(json!({ "id": "queries:memory", "tables": ["todo"] })),
2890 ))
2891 .expect("read reply");
2892 assert!(memory_recovered["result"].is_object(), "{memory_recovered}");
2893 let memory_cleared =
2894 memory_command(json!({ "method": "diagnosticsSnapshot", "params": {} }));
2895 assert_eq!(
2896 memory_cleared["result"]["queryFailures"],
2897 json!([]),
2898 "a successful owned in-memory read clears the failure: {memory_cleared}"
2899 );
2900
2901 let _ = std::fs::remove_file(&path);
2902 }
2903
2904 #[test]
2905 fn a_file_backed_owned_read_serves_the_same_reply_on_both_public_routes() {
2906 use tauri::test::{mock_builder, mock_context, noop_assets};
2907
2908 let path = std::env::temp_dir().join(format!(
2909 "syncular-tauri-read-routes-{}.db",
2910 std::process::id()
2911 ));
2912 let _ = std::fs::remove_file(&path);
2913 let app = mock_builder()
2914 .plugin(init(SyncularConfig {
2915 db_path: Some(path.to_string_lossy().into_owned()),
2916 auto_sync: false,
2917 ..Default::default()
2918 }))
2919 .build(mock_context(noop_assets()))
2920 .expect("build file app");
2921 let command = |value: Value| {
2922 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
2923 .expect("command reply")
2924 };
2925 let dedicated_snapshot = |sql: &str, owner: Option<Value>| {
2926 tauri::async_runtime::block_on(syncular_query_snapshot(
2927 app.handle().clone(),
2928 sql.to_owned(),
2929 None,
2930 None,
2931 owner,
2932 ))
2933 .expect("dedicated snapshot reply")
2934 };
2935 let dedicated_read = |statements: Value, owner: Option<Value>| {
2936 tauri::async_runtime::block_on(syncular_snapshot_read(
2937 app.handle().clone(),
2938 statements,
2939 None,
2940 None,
2941 None,
2942 owner,
2943 ))
2944 .expect("dedicated read reply")
2945 };
2946 let ids = || -> Vec<String> {
2947 command(json!({ "method": "diagnosticsSnapshot", "params": {} }))["result"]
2948 ["queryFailures"]
2949 .as_array()
2950 .expect("query failures array")
2951 .iter()
2952 .map(|failure| failure["id"].as_str().expect("failure id").to_owned())
2953 .collect()
2954 };
2955 assert_eq!(
2956 command(json!({ "method": "create", "params": {
2957 "clientId": "read-routes",
2958 "schema": { "version": 1, "tables": [] },
2959 } }))["result"],
2960 json!({})
2961 );
2962 let owner = json!({ "id": "queries:routes", "tables": ["tasks"] });
2963
2964 assert_eq!(
2967 command(json!({ "method": "querySnapshot", "params": {
2968 "sql": "SELECT 1 AS value",
2969 "owner": owner,
2970 } })),
2971 dedicated_snapshot("SELECT 1 AS value", Some(owner.clone())),
2972 "querySnapshot serves one reply on both routes"
2973 );
2974 assert_eq!(
2975 command(json!({ "method": "snapshotRead", "params": {
2976 "statements": [{ "sql": "SELECT 1 AS value" }],
2977 "owner": owner,
2978 } })),
2979 dedicated_read(json!([{ "sql": "SELECT 1 AS value" }]), Some(owner.clone()),),
2980 "snapshotRead serves one reply on both routes"
2981 );
2982 assert_eq!(ids(), Vec::<String>::new(), "clean reads record nothing");
2983
2984 assert!(command(json!({ "method": "querySnapshot", "params": {
2986 "sql": "SELECT value FROM absent_owner_table",
2987 "owner": owner,
2988 } }))["error"]
2989 .is_object());
2990 assert_eq!(ids(), ["queries:routes"]);
2991 assert!(
2992 dedicated_read(json!([{ "sql": "SELECT 1 AS value" }]), Some(owner.clone()),)["result"]
2993 .is_object()
2994 );
2995 assert_eq!(
2996 ids(),
2997 Vec::<String>::new(),
2998 "the dedicated route cleared the generic route's failure"
2999 );
3000 assert!(
3001 dedicated_snapshot("SELECT value FROM absent_owner_table", Some(owner.clone()))
3002 ["error"]
3003 .is_object()
3004 );
3005 assert_eq!(ids(), ["queries:routes"]);
3006 assert!(command(json!({ "method": "snapshotRead", "params": {
3007 "statements": [{ "sql": "SELECT 1 AS value" }],
3008 "owner": owner,
3009 } }))["result"]
3010 .is_object());
3011 assert_eq!(
3012 ids(),
3013 Vec::<String>::new(),
3014 "the generic route cleared the dedicated route's failure"
3015 );
3016
3017 let missing_sql = command(json!({ "method": "querySnapshot", "params": {} }));
3020 assert_eq!(missing_sql["error"]["code"], "client.failed");
3021 assert_eq!(missing_sql["error"]["message"], "querySnapshot missing sql");
3022 let malformed = command(json!({ "method": "querySnapshot", "params": {
3023 "sql": "SELECT 1",
3024 "params": "oops",
3025 "owner": owner,
3026 } }));
3027 assert_eq!(malformed["error"]["code"], "client.failed");
3028 assert_eq!(
3029 malformed["error"]["message"],
3030 "querySnapshot params must be a list"
3031 );
3032 let malformed = command(json!({ "method": "snapshotRead", "params": {
3033 "statements": "oops",
3034 "owner": owner,
3035 } }));
3036 assert_eq!(malformed["error"]["code"], "sync.invalid_request");
3037 assert_eq!(
3038 malformed["error"]["message"],
3039 "sync.invalid_request: snapshotRead statements must be a list"
3040 );
3041
3042 let _ = std::fs::remove_file(&path);
3043 }
3044
3045 #[test]
3046 fn a_delayed_owner_receives_only_reader_failure_transitions() {
3047 use std::sync::mpsc::channel;
3048
3049 let path = std::env::temp_dir().join(format!(
3050 "syncular-tauri-read-transitions-{}.db",
3051 std::process::id()
3052 ));
3053 let _ = std::fs::remove_file(&path);
3054 drop(
3058 syncular_client::SyncClient::open_path(
3059 "read-transitions".to_owned(),
3060 &json!({ "version": 1, "tables": [] }),
3061 syncular_client::ClientLimits::default(),
3062 &path.to_string_lossy(),
3063 )
3064 .expect("create replica"),
3065 );
3066
3067 let (owner_tx, owner_rx) = channel::<Request>();
3068 let (read_tx, read_rx) = channel::<ReadRequest>();
3069 let read_path = path.to_string_lossy().into_owned();
3070 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
3074
3075 let clean = "SELECT 1 AS value";
3076 let failing = "SELECT value FROM absent_owner_table";
3077 let owner = |id: &str, tables: &[&str]| {
3078 Some((
3079 id.to_owned(),
3080 tables.iter().map(|table| (*table).to_owned()).collect(),
3081 ))
3082 };
3083 let query_snapshot = |sql: &str, owner: Option<(String, Vec<String>)>| {
3084 let (reply, result) = channel();
3085 read_tx
3086 .send(ReadRequest::QuerySnapshot {
3087 sql: sql.to_owned(),
3088 params: Vec::new(),
3089 coverage: Vec::new(),
3090 owner,
3091 reply,
3092 })
3093 .expect("post snapshot read");
3094 result.recv().expect("snapshot reply")
3095 };
3096 let snapshot_read = |sql: &str, owner: Option<(String, Vec<String>)>| {
3097 let (reply, result) = channel();
3098 read_tx
3099 .send(ReadRequest::SnapshotRead {
3100 statements: vec![(sql.to_owned(), Vec::<syncular_client::QueryValue>::new())],
3101 coverage: Vec::new(),
3102 subscriptions: Vec::new(),
3103 commit_ids: Vec::new(),
3104 owner,
3105 reply,
3106 })
3107 .expect("post multi-statement read");
3108 result.recv().expect("multi-statement read reply")
3109 };
3110 fn drain(owner_rx: &Receiver<Request>) -> Vec<Option<QueryReadFailure>> {
3113 let mut messages = Vec::new();
3114 while let Ok(Request::QueryRead { failure, .. }) = owner_rx.try_recv() {
3115 messages.push(failure);
3116 }
3117 messages
3118 }
3119
3120 assert!(
3123 query_snapshot(failing, owner("queries:transition", &["tasks"]))["error"].is_object()
3124 );
3125 assert!(
3126 query_snapshot(clean, owner("queries:transition", &["tasks"]))["result"].is_object()
3127 );
3128 for round in 0..8 {
3129 assert!(
3130 query_snapshot(clean, owner("queries:transition", &["tasks"]))["result"]
3131 .is_object(),
3132 "{round}"
3133 );
3134 }
3135 let transitions = drain(&owner_rx);
3136 assert_eq!(
3137 transitions.len(),
3138 2,
3139 "a delayed owner sees the failure and one clear, not one message per read"
3140 );
3141 assert!(transitions[0].is_some(), "the failure is reported");
3142 assert!(transitions[1].is_none(), "the clearing success is reported");
3143
3144 assert!(
3146 snapshot_read(failing, owner("queries:transition", &["tasks"]))["error"].is_object()
3147 );
3148 assert!(
3149 snapshot_read(clean, owner("queries:transition", &["tasks"]))["result"].is_object()
3150 );
3151 for round in 0..8 {
3152 assert!(
3153 snapshot_read(clean, owner("queries:transition", &["tasks"]))["result"].is_object(),
3154 "{round}"
3155 );
3156 }
3157 let transitions = drain(&owner_rx);
3158 assert_eq!(transitions.len(), 2);
3159 assert!(transitions[0].is_some());
3160 assert!(transitions[1].is_none());
3161
3162 for round in 0..16 {
3164 assert!(
3165 query_snapshot(clean, owner("queries:transition", &["tasks"]))["result"]
3166 .is_object(),
3167 "{round}"
3168 );
3169 assert!(
3170 snapshot_read(clean, owner("queries:transition", &["tasks"]))["result"].is_object(),
3171 "{round}"
3172 );
3173 }
3174 assert!(
3175 drain(&owner_rx).is_empty(),
3176 "a clean success loop enqueues nothing"
3177 );
3178
3179 assert!(
3181 query_snapshot(failing, owner("queries:signature", &["tasks"]))["error"].is_object()
3182 );
3183 assert!(
3184 query_snapshot(failing, owner("queries:signature", &["docs"]))["error"].is_object()
3185 );
3186 assert!(!drain(&owner_rx).is_empty(), "both failures are reported");
3187 assert!(query_snapshot(clean, owner("queries:signature", &["docs"]))["result"].is_object());
3188 let transitions = drain(&owner_rx);
3189 assert_eq!(transitions.len(), 1);
3190 assert!(
3191 transitions[0].is_none(),
3192 "a changed signature keeps the recovery report"
3193 );
3194 assert!(query_snapshot(clean, owner("queries:signature", &["docs"]))["result"].is_object());
3195 assert!(drain(&owner_rx).is_empty());
3196
3197 assert!(
3199 query_snapshot(failing, owner("queries:signature", &["docs"]))["error"].is_object()
3200 );
3201 assert!(query_snapshot(clean, owner("queries:signature", &["docs"]))["result"].is_object());
3202 let transitions = drain(&owner_rx);
3203 assert_eq!(transitions.len(), 2);
3204 assert!(transitions[0].is_some());
3205 assert!(transitions[1].is_none());
3206
3207 for index in 0..MAX_DIAGNOSTIC_QUERY_FAILURES {
3210 assert!(
3211 query_snapshot(failing, owner(&format!("bounded-{index}"), &["tasks"]))["error"]
3212 .is_object()
3213 );
3214 }
3215 assert!(query_snapshot(failing, owner("bounded-0", &["tasks"]))["error"].is_object());
3216 assert!(query_snapshot(failing, owner("bounded-1", &["changed"]))["error"].is_object());
3217 assert!(query_snapshot(failing, owner("bounded-new", &["tasks"]))["error"].is_object());
3218 assert_eq!(drain(&owner_rx).len(), MAX_DIAGNOSTIC_QUERY_FAILURES + 3);
3219 assert!(query_snapshot(clean, owner("bounded-0", &["tasks"]))["result"].is_object());
3220 assert!(
3221 drain(&owner_rx).is_empty(),
3222 "the evicted oldest id has no retained failure to clear"
3223 );
3224 assert!(snapshot_read(clean, owner("bounded-1", &["changed"]))["result"].is_object());
3225 let retained = drain(&owner_rx);
3226 assert_eq!(retained.len(), 1);
3227 assert!(
3228 retained[0].is_none(),
3229 "the changed, retained signature still clears"
3230 );
3231
3232 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
3233 reader.join().expect("join reader");
3234 std::fs::remove_file(path).expect("remove replica file");
3235 }
3236
3237 #[test]
3238 fn owned_read_failures_clear_from_either_route_and_keep_journal_order() {
3239 use tauri::test::{mock_builder, mock_context, noop_assets};
3240
3241 let path = std::env::temp_dir().join(format!(
3242 "syncular-tauri-read-order-{}.db",
3243 std::process::id()
3244 ));
3245 let _ = std::fs::remove_file(&path);
3246 let app = mock_builder()
3247 .plugin(init(SyncularConfig {
3248 db_path: Some(path.to_string_lossy().into_owned()),
3249 auto_sync: false,
3250 ..Default::default()
3251 }))
3252 .build(mock_context(noop_assets()))
3253 .expect("build file app");
3254 let command = |value: Value| {
3255 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
3256 .expect("command reply")
3257 };
3258 let read = |statements: Value, owner: Value| {
3259 tauri::async_runtime::block_on(syncular_snapshot_read(
3260 app.handle().clone(),
3261 statements,
3262 None,
3263 None,
3264 None,
3265 Some(owner),
3266 ))
3267 .expect("read reply")
3268 };
3269 let failures = || {
3270 command(json!({ "method": "diagnosticsSnapshot", "params": {} }))["result"]
3271 ["queryFailures"]
3272 .clone()
3273 };
3274 let ids = || -> Vec<String> {
3275 failures()
3276 .as_array()
3277 .expect("query failures array")
3278 .iter()
3279 .map(|failure| failure["id"].as_str().expect("failure id").to_owned())
3280 .collect()
3281 };
3282 let owner = |id: &str, tables: Value| json!({ "id": id, "tables": tables });
3283 let owned_command = |id: &str, tables: Value, sql: &str| {
3287 command(json!({ "method": "snapshotRead", "params": {
3288 "statements": [{ "sql": sql, "params": [] }],
3289 "owner": owner(id, tables),
3290 } }))
3291 };
3292 assert_eq!(
3293 command(json!({ "method": "create", "params": {
3294 "clientId": "read-routes",
3295 "schema": { "version": 1, "tables": [] },
3296 } }))["result"],
3297 json!({}),
3298 "the config db_path opens the replica the sidecar reads"
3299 );
3300
3301 for (id, tables) in [
3304 ("owner-x", json!(["tasks"])),
3305 ("owner-y", json!(["docs"])),
3306 ("owner-x", json!(["tasks"])),
3307 ] {
3308 assert!(
3309 owned_command(id, tables, "SELECT value FROM absent_owner_table")["error"]
3310 .is_object()
3311 );
3312 }
3313 assert_eq!(
3314 ids(),
3315 ["owner-x", "owner-y"],
3316 "an unchanged failure keeps its journal position"
3317 );
3318 assert!(owned_command(
3319 "owner-x",
3320 json!(["other"]),
3321 "SELECT value FROM absent_owner_table"
3322 )["error"]
3323 .is_object());
3324 assert_eq!(
3325 ids(),
3326 ["owner-y", "owner-x"],
3327 "a changed failure signature moves the entry last"
3328 );
3329
3330 assert!(read(
3333 json!([{ "sql": "SELECT 1 AS value" }]),
3334 owner("owner-x", json!(["tasks"]))
3335 )["result"]
3336 .is_object());
3337 assert_eq!(ids(), ["owner-y"], "the sidecar success cleared owner-x");
3338 assert!(read(
3339 json!([{ "sql": "SELECT 1 AS value" }]),
3340 owner("owner-y", json!(["docs"]))
3341 )["result"]
3342 .is_object());
3343 assert_eq!(ids(), Vec::<String>::new());
3344
3345 assert!(owned_command(
3346 "owner-z",
3347 json!(["tasks"]),
3348 "SELECT value FROM absent_owner_table"
3349 )["error"]
3350 .is_object());
3351 assert_eq!(ids(), ["owner-z"]);
3352 assert!(read(
3353 json!([{ "sql": "SELECT 1 AS value" }]),
3354 owner("owner-z", json!(["tasks"]))
3355 )["result"]
3356 .is_object());
3357 assert_eq!(
3358 ids(),
3359 Vec::<String>::new(),
3360 "a success on one route clears the other route's failure"
3361 );
3362
3363 assert!(read(
3364 json!([{ "sql": "SELECT value FROM absent_owner_table" }]),
3365 owner("owner-w", json!(["tasks"]))
3366 )["error"]
3367 .is_object());
3368 assert_eq!(ids(), ["owner-w"]);
3369 assert!(
3370 owned_command("owner-w", json!(["tasks"]), "SELECT 1 AS value")["result"].is_object()
3371 );
3372 assert_eq!(
3373 ids(),
3374 Vec::<String>::new(),
3375 "the generic route's success clears the sidecar failure"
3376 );
3377
3378 for round in 0..8 {
3380 assert!(
3381 read(
3382 json!([{ "sql": "SELECT 1 AS value" }]),
3383 owner("owner-clean", json!(["tasks"]))
3384 )["result"]
3385 .is_object(),
3386 "{round}"
3387 );
3388 }
3389 assert_eq!(ids(), Vec::<String>::new());
3390
3391 let _ = std::fs::remove_file(&path);
3392 }
3393}