1use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
37use std::sync::{Condvar, Mutex};
38use std::time::{Duration, Instant};
39
40use serde_json::{json, Value};
41use tauri::plugin::{Builder, TauriPlugin};
42use tauri::{Emitter, Manager, RunEvent, Runtime};
43
44pub mod core;
45pub mod transport;
46
47use core::SyncularCore;
48use syncular_client::{FileQuerySnapshotReader, QueryReadFailure, WindowBase, WindowCoverage};
49
50pub const EVENT_NAME: &str = "syncular://event";
52
53#[derive(Debug, Clone)]
58pub struct SyncularConfig {
59 pub base_url: Option<String>,
62 pub ws_url: Option<String>,
64 pub headers: Vec<(String, String)>,
66 pub db_path: Option<String>,
70 pub database_dir: Option<String>,
75 pub auto_sync: bool,
77}
78
79impl Default for SyncularConfig {
80 fn default() -> Self {
81 Self {
82 base_url: None,
83 ws_url: None,
84 headers: Vec::new(),
85 db_path: None,
86 database_dir: None,
87 auto_sync: true,
88 }
89 }
90}
91
92impl SyncularConfig {
93 fn to_transport_json(&self) -> Value {
95 let mut map = serde_json::Map::new();
96 if let Some(base) = &self.base_url {
97 map.insert("baseUrl".to_owned(), Value::from(base.clone()));
98 }
99 if let Some(ws) = &self.ws_url {
100 map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
101 }
102 if !self.headers.is_empty() {
103 let headers: serde_json::Map<String, Value> = self
104 .headers
105 .iter()
106 .map(|(k, v)| (k.clone(), Value::from(v.clone())))
107 .collect();
108 map.insert("headers".to_owned(), Value::Object(headers));
109 }
110 Value::Object(map)
111 }
112}
113
114enum Request {
117 Command {
118 command: Value,
119 reply: Sender<Value>,
120 },
121 Query {
122 sql: String,
123 params: Value,
124 reply: Sender<Value>,
125 },
126 SetHeaders {
130 headers: Vec<(String, String)>,
131 reply: Sender<Value>,
132 },
133 TransportWake,
135 RoundFinished(
136 Box<(
137 syncular_client::CompletedSyncRound,
138 crate::transport::HostTransport,
139 )>,
140 ),
141 QueryRead {
143 id: String,
144 tables: Vec<String>,
145 failure: Option<QueryReadFailure>,
146 },
147 #[cfg(test)]
148 Block {
149 duration: Duration,
150 entered: Sender<()>,
151 },
152 Shutdown,
153}
154
155enum ReadRequest {
159 QuerySnapshot {
160 sql: String,
161 params: Vec<Value>,
162 coverage: Vec<WindowCoverage>,
163 owner: Option<(String, Vec<String>)>,
164 reply: Sender<Value>,
165 },
166 Shutdown,
167}
168
169struct SyncularState {
172 sender: Mutex<Sender<Request>>,
173 reader: Mutex<Option<Sender<ReadRequest>>>,
178 config: SyncularConfig,
179 security_gate: SecurityGate,
180}
181
182struct SecurityGateState {
183 preflight: bool,
184 active_reads: usize,
185}
186
187struct SecurityGate {
188 state: Mutex<SecurityGateState>,
189 idle: Condvar,
190}
191
192struct SecurityReadGuard<'a> {
193 gate: &'a SecurityGate,
194}
195
196impl SecurityGate {
197 fn new_preflight() -> Self {
198 Self {
199 state: Mutex::new(SecurityGateState {
200 preflight: true,
201 active_reads: 0,
202 }),
203 idle: Condvar::new(),
204 }
205 }
206
207 fn begin_preflight(&self) -> Result<(), String> {
208 let mut state = self
209 .state
210 .lock()
211 .map_err(|_| "syncular security gate poisoned".to_owned())?;
212 state.preflight = true;
213 while state.active_reads > 0 {
214 state = self
215 .idle
216 .wait(state)
217 .map_err(|_| "syncular security gate poisoned".to_owned())?;
218 }
219 Ok(())
220 }
221
222 fn activate(&self) -> Result<(), String> {
223 let mut state = self
224 .state
225 .lock()
226 .map_err(|_| "syncular security gate poisoned".to_owned())?;
227 state.preflight = false;
228 Ok(())
229 }
230
231 fn enter_read(&self) -> Result<SecurityReadGuard<'_>, Value> {
232 let mut state = self
233 .state
234 .lock()
235 .map_err(|_| client_error("syncular security gate poisoned"))?;
236 if state.preflight {
237 return Err(security_preflight_error());
238 }
239 state.active_reads += 1;
240 Ok(SecurityReadGuard { gate: self })
241 }
242}
243
244impl Drop for SecurityReadGuard<'_> {
245 fn drop(&mut self) {
246 let Ok(mut state) = self.gate.state.lock() else {
247 return;
248 };
249 state.active_reads = state.active_reads.saturating_sub(1);
250 if state.active_reads == 0 {
251 self.gate.idle.notify_all();
252 }
253 }
254}
255
256impl SyncularState {
257 fn send(&self, request: Request) -> Result<(), String> {
258 self.sender
259 .lock()
260 .map_err(|_| "syncular mailbox poisoned".to_owned())?
261 .send(request)
262 .map_err(|_| "the syncular core thread has stopped".to_owned())
263 }
264
265 fn reader(&self) -> Result<Option<Sender<ReadRequest>>, String> {
266 Ok(self
267 .reader
268 .lock()
269 .map_err(|_| "syncular read mailbox poisoned".to_owned())?
270 .clone())
271 }
272}
273
274fn spawn_reader(path: String, owner_tx: Sender<Request>) -> Result<Sender<ReadRequest>, String> {
275 let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
276 std::thread::Builder::new()
277 .name("syncular-read".to_owned())
278 .spawn(move || run_reader_thread(path, reader_rx, owner_tx))
279 .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
280 Ok(reader_tx)
281}
282
283fn run_reader_thread(path: String, rx: Receiver<ReadRequest>, owner_tx: Sender<Request>) {
284 let mut reader = FileQuerySnapshotReader::new(path);
285 let mut failing = std::collections::HashSet::<String>::new();
288 while let Ok(request) = rx.recv() {
289 match request {
290 ReadRequest::QuerySnapshot {
291 sql,
292 params,
293 coverage,
294 owner,
295 reply,
296 } => {
297 let result = reader.query_snapshot(&sql, ¶ms, &coverage);
298 let value = match &result {
299 Ok(snapshot) => json!({ "result": snapshot }),
300 Err(failure) => json!({
301 "error": {
302 "code": failure.code.unwrap_or("client.failed"),
303 "message": failure.message,
304 "details": failure.details(),
305 }
306 }),
307 };
308 if let Some((id, tables)) = owner {
309 let failure = result.err();
310 let report = if failure.is_some() {
311 failing.insert(id.clone());
312 true
313 } else {
314 failing.remove(&id)
315 };
316 if report {
317 let _ = owner_tx.send(Request::QueryRead {
318 id,
319 tables,
320 failure,
321 });
322 }
323 }
324 let _ = reply.send(value);
325 }
326 ReadRequest::Shutdown => return,
327 }
328 }
329}
330
331enum NetworkWork {
335 Round(
336 Box<syncular_client::PreparedSyncRound>,
337 crate::transport::HostTransport,
338 ),
339 Controls(Vec<String>, crate::transport::HostTransport),
340}
341
342struct RoundRun {
343 reply: Option<Sender<Value>>,
344 until_idle: bool,
345 max_rounds: Option<u32>,
346 spent: u32,
347 aggregate: syncular_client::SyncReport,
348}
349
350fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
351where
352 F: Fn(&Value) + Send + Sync + 'static,
353{
354 let emit = std::sync::Arc::new(emit);
355 let wake_tx = tx.clone();
356 let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
357 let _ = wake_tx.send(Request::TransportWake);
358 });
359 let mut core = match SyncularCore::new_with_notify(&config.to_transport_json(), Some(notify)) {
360 Ok(core) => core,
361 Err(message) => {
362 emit(&json!({ "type": "error", "message": message }));
363 return;
364 }
365 };
366 let progress_emit = emit.clone();
367 core.progress_listener = Some(std::sync::Arc::new(move |progress| {
368 progress_emit(&json!({ "type": "progress", "progress": progress }));
369 }));
370 let (network_tx, network_rx) = std::sync::mpsc::channel();
371 let completed_tx = tx.clone();
372 let network = std::thread::spawn(move || {
373 use syncular_client::Transport;
374 while let Ok(work) = network_rx.recv() {
375 match work {
376 NetworkWork::Round(prepared, mut transport) => {
377 let completed = (*prepared).exchange(&mut transport);
378 if completed_tx
379 .send(Request::RoundFinished(Box::new((completed, transport))))
380 .is_err()
381 {
382 return;
383 }
384 }
385 NetworkWork::Controls(controls, mut transport) => {
386 for text in controls {
387 let _ = transport.realtime_send(&text);
388 }
389 }
390 }
391 }
392 });
393 let mut active: Option<RoundRun> = None;
394 let mut queued = std::collections::VecDeque::new();
395 let mut background_deadline: Option<Instant> = None;
396 loop {
397 if let Some((controls, transport)) = core.take_controls() {
398 if network_tx
399 .send(NetworkWork::Controls(controls, transport))
400 .is_err()
401 {
402 break;
403 }
404 }
405 if active.is_none() {
408 if let Some(run) = queued.pop_front() {
409 active = Some(run);
410 } else if config.auto_sync {
411 match core.take_sync_intent() {
412 syncular_client::SyncIntent::Interactive => {
413 background_deadline = None;
414 active = Some(RoundRun {
415 reply: None,
416 until_idle: true,
417 max_rounds: None,
418 spent: 0,
419 aggregate: Default::default(),
420 });
421 }
422 syncular_client::SyncIntent::Background { delay_ms } => {
423 let candidate = Instant::now()
424 .checked_add(Duration::from_millis(delay_ms))
425 .unwrap_or_else(Instant::now);
426 background_deadline = Some(
427 background_deadline.map_or(candidate, |current| current.min(candidate)),
428 );
429 }
430 syncular_client::SyncIntent::None => {}
431 }
432 }
433 if active.is_some() {
434 match core.prepare_round() {
435 Ok((prepared, transport)) => {
436 active.as_mut().expect("active run").spent += 1;
437 if network_tx
438 .send(NetworkWork::Round(Box::new(prepared), transport))
439 .is_err()
440 {
441 break;
442 }
443 }
444 Err(outcome) => {
445 if let Some(reply) = active.take().and_then(|run| run.reply) {
446 let _ = reply.send(json!({ "result": outcome.to_json() }));
447 }
448 }
449 }
450 pump_events(&mut core, &*emit);
451 if active.is_none() {
452 continue;
453 }
454 }
455 }
456 let request = if active.is_none() {
457 if let Some(deadline) = background_deadline {
458 match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
459 Ok(request) => request,
460 Err(RecvTimeoutError::Timeout) => {
461 background_deadline = None;
462 queued.push_back(RoundRun {
463 reply: None,
464 until_idle: true,
465 max_rounds: None,
466 spent: 0,
467 aggregate: Default::default(),
468 });
469 continue;
470 }
471 Err(RecvTimeoutError::Disconnected) => break,
472 }
473 } else {
474 match rx.recv() {
475 Ok(request) => request,
476 Err(_) => break,
477 }
478 }
479 } else {
480 match rx.recv() {
481 Ok(request) => request,
482 Err(_) => break,
483 }
484 };
485 match request {
486 Request::RoundFinished(completed) => {
487 let (completed, transport) = *completed;
488 let applied = core.apply_round(completed);
489 let (mut outcome, more, bootstrap_advanced) = match applied {
490 syncular_client::AppliedSyncRound::Continue(prepared) => {
491 if network_tx
492 .send(NetworkWork::Round(Box::new(prepared), transport))
493 .is_err()
494 {
495 break;
496 }
497 pump_events(&mut core, &*emit);
498 continue;
499 }
500 syncular_client::AppliedSyncRound::Complete {
501 outcome,
502 more,
503 bootstrap_advanced,
504 ..
505 } => (outcome, more, bootstrap_advanced),
506 };
507 let Some(mut run) = active.take() else {
508 continue;
509 };
510 let mut keep_running = false;
511 if run.until_idle {
512 if let syncular_client::SyncOutcome::Ok(report) = &outcome {
513 run.aggregate.merge(report);
514 if bootstrap_advanced && run.max_rounds.is_none() {
515 run.spent = 0;
516 }
517 keep_running = more;
518 if keep_running && run.spent >= run.max_rounds.unwrap_or(20).max(1) {
519 keep_running = false;
520 outcome = syncular_client::SyncOutcome::Failed {
521 error_code: "sync.invalid_request".into(),
522 message: "sync did not reach idle within the round budget".into(),
523 details: None,
524 };
525 } else if !keep_running {
526 outcome = syncular_client::SyncOutcome::Ok(run.aggregate.clone());
527 }
528 }
529 }
530 if keep_running {
531 queued.push_front(run);
532 } else if let Some(reply) = run.reply {
533 let _ = reply.send(json!({ "result": outcome.to_json() }));
534 }
535 pump_events(&mut core, &*emit);
536 }
537 Request::Command { command, reply } => {
538 let method = command.get("method").and_then(Value::as_str);
539 if matches!(method, Some("sync" | "syncUntilIdle")) {
540 queued.push_back(RoundRun {
541 reply: Some(reply),
542 until_idle: method == Some("syncUntilIdle"),
543 max_rounds: command
544 .pointer("/params/maxRounds")
545 .and_then(Value::as_u64)
546 .map(|n| n as u32),
547 spent: 0,
548 aggregate: Default::default(),
549 });
550 continue;
551 }
552 let result = core.command(&command);
553 if result.get("error").is_none() {
554 if let Some(headers) = activation_headers(&command) {
555 core.set_headers(headers);
556 }
557 }
558 let _ = reply.send(result);
559 pump_events(&mut core, &*emit);
560 }
561 Request::Query { sql, params, reply } => {
562 let _ = reply.send(core.query(&sql, params));
563 pump_events(&mut core, &*emit);
564 }
565 Request::SetHeaders { headers, reply } => {
566 core.set_headers(headers);
567 let _ = reply.send(json!({ "result": null }));
568 }
569 Request::TransportWake => {
570 core.poll_transport();
571 pump_events(&mut core, &*emit);
572 }
573 Request::QueryRead {
574 id,
575 tables,
576 failure,
577 } => {
578 core.record_query_read(&id, &tables, failure.as_ref());
579 pump_events(&mut core, &*emit);
580 }
581 #[cfg(test)]
582 Request::Block { duration, entered } => {
583 let _ = entered.send(());
584 std::thread::sleep(duration);
585 }
586 Request::Shutdown => break,
587 }
588 }
589 core.shutdown();
590 drop(network_tx);
591 let _ = network.join();
594 for run in active.into_iter().chain(queued) {
595 if let Some(reply) = run.reply {
596 let _ = reply.send(json!({ "error": { "code": "client.closed", "message": "client owner has closed" } }));
597 }
598 }
599}
600
601fn resolve_database(config: &SyncularConfig, params: &Value) -> Result<Option<String>, Value> {
608 if params.get("dbPath").is_some() {
609 return Err(invalid_request(
610 "sync.invalid_request: create.dbPath is host configuration; name a database with create.database",
611 ));
612 }
613 let Some(database) = params.get("database") else {
614 if config.db_path.is_none() && config.database_dir.is_some() {
615 return Err(invalid_request(
616 "sync.invalid_request: create.database is required when SyncularConfig sets database_dir without db_path",
617 ));
618 }
619 return Ok(config.db_path.clone());
620 };
621 let Some(name) = database.as_str().filter(|name| {
622 (1..=128).contains(&name.len())
623 && name.starts_with(|c: char| c.is_ascii_alphanumeric())
624 && !name.contains("..")
625 && name
626 .chars()
627 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
628 }) else {
629 return Err(invalid_request(
630 "sync.invalid_request: create.database must be 1 to 128 ASCII letters, digits, '-', '_' or '.', start with a letter or digit, and contain no '..'",
631 ));
632 };
633 let Some(dir) = &config.database_dir else {
634 return Err(invalid_request(
635 "sync.invalid_request: create.database needs SyncularConfig.database_dir",
636 ));
637 };
638 std::fs::create_dir_all(dir)
639 .map_err(|error| client_error(format!("cannot create the database directory: {error}")))?;
640 Ok(Some(
641 std::path::Path::new(dir)
642 .join(format!("{name}.db"))
643 .to_string_lossy()
644 .into_owned(),
645 ))
646}
647
648fn invalid_request(message: &str) -> Value {
649 json!({ "error": { "code": "sync.invalid_request", "message": message } })
650}
651
652fn activation_headers(command: &Value) -> Option<Vec<(String, String)>> {
656 if command.get("method").and_then(Value::as_str) != Some("activateSecurity") {
657 return None;
658 }
659 let headers = command.pointer("/params/headers")?;
660 syncular_command::parse_headers(headers).ok()
661}
662
663fn client_error(message: impl Into<String>) -> Value {
664 json!({ "error": { "code": "client.failed", "message": message.into() } })
665}
666
667fn security_preflight_error() -> Value {
668 json!({
669 "error": {
670 "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
671 "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
672 }
673 })
674}
675
676fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
677 let object = value
678 .and_then(Value::as_object)
679 .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
680 let table = object
681 .get("table")
682 .and_then(Value::as_str)
683 .ok_or_else(|| "window base missing table".to_owned())?
684 .to_owned();
685 let variable = object
686 .get("variable")
687 .and_then(Value::as_str)
688 .ok_or_else(|| "window base missing variable".to_owned())?
689 .to_owned();
690 let fixed_scopes = match object.get("fixedScopes") {
691 Some(value) => syncular_client::values::json_to_scope_map(value)?,
692 None => Vec::new(),
693 };
694 let params = object
695 .get("params")
696 .and_then(Value::as_str)
697 .map(str::to_owned);
698 Ok(WindowBase {
699 table,
700 variable,
701 fixed_scopes,
702 params,
703 })
704}
705
706fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
707 let Some(value) = value else {
708 return Ok(Vec::new());
709 };
710 if value.is_null() {
711 return Ok(Vec::new());
712 }
713 let entries = value
714 .as_array()
715 .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
716 entries
717 .iter()
718 .map(|entry| {
719 let units = entry
720 .get("units")
721 .and_then(Value::as_array)
722 .map(|values| {
723 values
724 .iter()
725 .filter_map(|value| value.as_str().map(str::to_owned))
726 .collect()
727 })
728 .unwrap_or_default();
729 Ok(WindowCoverage {
730 base: parse_window_base(entry.get("base"))?,
731 units,
732 })
733 })
734 .collect()
735}
736
737fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
738 for event in core.drain_events() {
739 emit(&event.json);
740 }
741}
742
743#[tauri::command]
746async fn syncular_command<R: Runtime>(
747 app: tauri::AppHandle<R>,
748 command: Value,
749) -> Result<Value, String> {
750 let state = app.state::<SyncularState>();
751 let method = command
752 .get("method")
753 .and_then(Value::as_str)
754 .unwrap_or("")
755 .to_owned();
756 if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
757 state.security_gate.begin_preflight()?;
760 }
761 let mut reader = if method == "create" || method == "shutdown" {
763 Some(
764 state
765 .reader
766 .lock()
767 .map_err(|_| "syncular read mailbox poisoned".to_owned())?,
768 )
769 } else {
770 None
771 };
772 let mut command = command;
773 let mut database = None;
774 if method == "create" {
775 database =
776 match resolve_database(&state.config, command.get("params").unwrap_or(&Value::Null)) {
777 Ok(path) => path,
778 Err(reply) => return Ok(reply),
779 };
780 if let Some(path) = &database {
781 match command.get_mut("params").and_then(Value::as_object_mut) {
782 Some(params) => {
783 params.insert("dbPath".to_owned(), Value::from(path.clone()));
784 }
785 None => {
786 if let Some(object) = command.as_object_mut() {
787 object.insert("params".to_owned(), json!({ "dbPath": path }));
788 }
789 }
790 }
791 }
792 }
793 let create_preflight = method == "create"
794 && command
795 .pointer("/params/securityPreflight")
796 .and_then(Value::as_bool)
797 .unwrap_or(false);
798 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
799 state.send(Request::Command {
800 command,
801 reply: reply_tx,
802 })?;
803 let reply = reply_rx
804 .recv()
805 .map_err(|_| "the syncular core dropped the reply".to_owned())?;
806 let succeeded = reply.get("error").is_none();
807 if let (true, Some(slot)) = (succeeded, reader.as_mut()) {
808 let next = match database.filter(|path| path.as_str() != ":memory:") {
809 Some(path) => Some(spawn_reader(
810 path,
811 state
812 .sender
813 .lock()
814 .map_err(|_| "syncular mailbox poisoned".to_owned())?
815 .clone(),
816 )?),
817 None => None,
818 };
819 if let Some(previous) = std::mem::replace(&mut **slot, next) {
820 let _ = previous.send(ReadRequest::Shutdown);
821 }
822 }
823 drop(reader);
824 if succeeded && method == "create" {
825 if !create_preflight {
826 state.security_gate.activate()?;
827 }
828 } else if succeeded && method == "activateSecurity" {
829 state.security_gate.activate()?;
830 }
831 Ok(reply)
832}
833
834#[tauri::command]
839async fn syncular_set_headers<R: Runtime>(
840 app: tauri::AppHandle<R>,
841 headers: std::collections::BTreeMap<String, String>,
842) -> Result<Value, String> {
843 let state = app.state::<SyncularState>();
844 let _active_guard = match state.security_gate.enter_read() {
848 Ok(guard) => guard,
849 Err(reply) => return Ok(reply),
850 };
851 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
852 state.send(Request::SetHeaders {
853 headers: headers.into_iter().collect(),
854 reply: reply_tx,
855 })?;
856 reply_rx
857 .recv()
858 .map_err(|_| "the syncular core dropped the reply".to_owned())
859}
860
861#[tauri::command]
862async fn syncular_query<R: Runtime>(
863 app: tauri::AppHandle<R>,
864 sql: String,
865 params: Option<Value>,
866) -> Result<Value, String> {
867 let state = app.state::<SyncularState>();
868 let _read_guard = match state.security_gate.enter_read() {
869 Ok(guard) => guard,
870 Err(reply) => return Ok(reply),
871 };
872 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
873 state.send(Request::Query {
874 sql,
875 params: params.unwrap_or(Value::Null),
876 reply: reply_tx,
877 })?;
878 reply_rx
879 .recv()
880 .map_err(|_| "the syncular core dropped the reply".to_owned())
881}
882
883#[tauri::command]
887async fn syncular_query_snapshot<R: Runtime>(
888 app: tauri::AppHandle<R>,
889 sql: String,
890 params: Option<Value>,
891 coverage: Option<Value>,
892 owner: Option<Value>,
893) -> Result<Value, String> {
894 let state = app.state::<SyncularState>();
895 let _read_guard = match state.security_gate.enter_read() {
896 Ok(guard) => guard,
897 Err(reply) => return Ok(reply),
898 };
899 let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
900 let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
901
902 if let Some(reader) = state.reader()? {
903 let bind = match params_value.as_array() {
904 Some(values) => values.clone(),
905 None => return Ok(client_error("querySnapshot params must be a list")),
906 };
907 let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
908 Ok(value) => value,
909 Err(message) => return Ok(client_error(message)),
910 };
911 let parsed_owner = match &owner {
913 None | Some(Value::Null) => None,
914 Some(value) => {
915 let id = value
916 .get("id")
917 .and_then(Value::as_str)
918 .filter(|id| !id.is_empty());
919 let tables = value
920 .get("tables")
921 .and_then(Value::as_array)
922 .and_then(|list| {
923 list.iter()
924 .map(|table| table.as_str().map(str::to_owned))
925 .collect::<Option<Vec<String>>>()
926 });
927 match (id, tables) {
928 (Some(id), Some(tables)) => Some((id.to_owned(), tables)),
929 _ => {
930 return Ok(json!({ "error": {
931 "code": "sync.invalid_request",
932 "message": "sync.invalid_request: querySnapshot owner must be {id: non-empty string, tables: string[]}",
933 } }))
934 }
935 }
936 }
937 };
938 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
939 reader
940 .send(ReadRequest::QuerySnapshot {
941 sql,
942 params: bind,
943 coverage: parsed_coverage,
944 owner: parsed_owner,
945 reply: reply_tx,
946 })
947 .map_err(|_| "the syncular read thread has stopped".to_owned())?;
948 return reply_rx
949 .recv()
950 .map_err(|_| "the syncular read thread dropped the reply".to_owned());
951 }
952
953 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
954 state.send(Request::Command {
955 command: json!({
956 "method": "querySnapshot",
957 "params": {
958 "sql": sql,
959 "params": params_value,
960 "coverage": coverage_value,
961 "owner": owner.unwrap_or(Value::Null),
962 }
963 }),
964 reply: reply_tx,
965 })?;
966 reply_rx
967 .recv()
968 .map_err(|_| "the syncular core dropped the reply".to_owned())
969}
970
971pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
979 Builder::<R>::new("syncular")
980 .invoke_handler(tauri::generate_handler![
981 syncular_command,
982 syncular_query,
983 syncular_query_snapshot,
984 syncular_set_headers
985 ])
986 .setup(move |app, _api| {
987 let (tx, rx) = std::sync::mpsc::channel::<Request>();
988 app.manage(SyncularState {
989 sender: Mutex::new(tx.clone()),
990 reader: Mutex::new(None),
991 config: config.clone(),
992 security_gate: SecurityGate::new_preflight(),
995 });
996 let app_handle = app.clone();
997 let emit = move |value: &Value| {
998 let _ = app_handle.emit(EVENT_NAME, value.clone());
1001 };
1002 std::thread::Builder::new()
1003 .name("syncular-core".to_owned())
1004 .spawn(move || run_owner_thread(config, tx, rx, emit))
1005 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
1006 Ok(())
1007 })
1008 .on_event(|app, event| {
1009 if let RunEvent::Exit = event {
1010 if let Some(state) = app.try_state::<SyncularState>() {
1011 let _ = state.send(Request::Shutdown);
1012 if let Ok(Some(reader)) = state.reader() {
1013 let _ = reader.send(ReadRequest::Shutdown);
1014 }
1015 }
1016 }
1017 })
1018 .build()
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023 use super::*;
1024
1025 #[test]
1026 fn config_to_transport_json_shapes_fields() {
1027 let config = SyncularConfig {
1028 base_url: Some("https://api.example.com".to_owned()),
1029 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
1030 ..Default::default()
1031 };
1032 let json = config.to_transport_json();
1033 assert_eq!(json["baseUrl"], "https://api.example.com");
1034 assert_eq!(json["headers"]["authorization"], "Bearer x");
1035 }
1036
1037 #[test]
1038 fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
1039 let gate = std::sync::Arc::new(SecurityGate::new_preflight());
1040 gate.activate().expect("activate test gate");
1041 let read = gate.enter_read().expect("active read");
1042 let gate_for_barrier = std::sync::Arc::clone(&gate);
1043 let (done_tx, done_rx) = std::sync::mpsc::channel();
1044 let barrier = std::thread::spawn(move || {
1045 gate_for_barrier.begin_preflight().expect("enter preflight");
1046 done_tx.send(()).expect("barrier reply");
1047 });
1048
1049 assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
1050 drop(read);
1051 done_rx
1052 .recv_timeout(Duration::from_secs(1))
1053 .expect("barrier drains after the read");
1054 barrier.join().expect("barrier thread");
1055 assert!(gate.enter_read().is_err());
1056 }
1057
1058 #[test]
1059 fn direct_set_headers_command_respects_the_native_preflight_gate() {
1060 use std::collections::BTreeMap;
1061 use tauri::test::{mock_builder, mock_context, noop_assets};
1062
1063 let app = mock_builder()
1064 .plugin(init(SyncularConfig {
1065 auto_sync: false,
1066 ..Default::default()
1067 }))
1068 .build(mock_context(noop_assets()))
1069 .expect("build mock app");
1070
1071 let blocked = tauri::async_runtime::block_on(syncular_set_headers(
1072 app.handle().clone(),
1073 BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
1074 ))
1075 .expect("preflight reply");
1076 assert_eq!(
1077 blocked["error"]["code"],
1078 Value::from("client.security_preflight_required")
1079 );
1080
1081 let created = tauri::async_runtime::block_on(syncular_command(
1082 app.handle().clone(),
1083 json!({
1084 "method": "create",
1085 "params": {
1086 "clientId": "native-header-gate",
1087 "schema": { "version": 1, "tables": [] }
1088 }
1089 }),
1090 ))
1091 .expect("create reply");
1092 assert!(created.get("error").is_none(), "{created}");
1093
1094 let active = tauri::async_runtime::block_on(syncular_set_headers(
1095 app.handle().clone(),
1096 BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
1097 ))
1098 .expect("active reply");
1099 assert_eq!(active["result"], Value::Null);
1100 }
1101
1102 #[test]
1103 fn activation_headers_extracts_only_the_activation_set() {
1104 assert_eq!(
1105 activation_headers(&json!({
1106 "method": "activateSecurity",
1107 "params": { "headers": { "authorization": "Bearer fresh" } }
1108 })),
1109 Some(vec![(
1110 "authorization".to_owned(),
1111 "Bearer fresh".to_owned()
1112 )])
1113 );
1114 assert_eq!(
1116 activation_headers(&json!({ "method": "activateSecurity", "params": {} })),
1117 None
1118 );
1119 assert_eq!(
1120 activation_headers(&json!({
1121 "method": "setWindow",
1122 "params": { "headers": { "authorization": "Bearer fresh" } }
1123 })),
1124 None
1125 );
1126 assert_eq!(
1127 activation_headers(&json!({
1128 "method": "activateSecurity",
1129 "params": { "headers": { "authorization": 7 } }
1130 })),
1131 None
1132 );
1133 }
1134
1135 #[test]
1136 fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
1137 use tauri::test::{mock_builder, mock_context, noop_assets};
1138
1139 let app = mock_builder()
1140 .plugin(init(SyncularConfig {
1141 auto_sync: false,
1142 ..Default::default()
1143 }))
1144 .build(mock_context(noop_assets()))
1145 .expect("build mock app");
1146 let command = |value: Value| {
1147 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1148 .expect("command reply")
1149 };
1150
1151 let created = command(json!({
1152 "method": "create",
1153 "params": {
1154 "clientId": "native-preflight-escape",
1155 "schema": { "version": 1, "tables": [] },
1156 "securityPreflight": true
1157 }
1158 }));
1159 assert!(created.get("error").is_none(), "{created}");
1160
1161 let escape = command(json!({
1164 "method": "create",
1165 "params": {
1166 "clientId": "native-preflight-escape",
1167 "schema": { "version": 1, "tables": [] }
1168 }
1169 }));
1170 assert_eq!(
1171 escape["error"]["code"],
1172 Value::from("client.security_preflight_required"),
1173 "{escape}"
1174 );
1175 let read = tauri::async_runtime::block_on(syncular_query(
1176 app.handle().clone(),
1177 "SELECT 1".to_owned(),
1178 None,
1179 ))
1180 .expect("query reply");
1181 assert_eq!(
1182 read["error"]["code"],
1183 Value::from("client.security_preflight_required")
1184 );
1185
1186 let activated = command(json!({
1188 "method": "activateSecurity",
1189 "params": { "headers": { "authorization": "Bearer fresh" } }
1190 }));
1191 assert!(activated.get("error").is_none(), "{activated}");
1192 let read = tauri::async_runtime::block_on(syncular_query(
1193 app.handle().clone(),
1194 "SELECT 1 AS value".to_owned(),
1195 None,
1196 ))
1197 .expect("query reply");
1198 assert_eq!(read["result"]["rows"][0]["value"], 1);
1199 let recreated = command(json!({
1200 "method": "create",
1201 "params": {
1202 "clientId": "native-preflight-escape",
1203 "schema": { "version": 1, "tables": [] }
1204 }
1205 }));
1206 assert!(recreated.get("error").is_none(), "{recreated}");
1207 }
1208
1209 #[test]
1210 fn resolve_database_names_a_file_inside_the_configured_directory() {
1211 let dir =
1212 std::env::temp_dir().join(format!("syncular-tauri-resolve-{}", std::process::id()));
1213 let config = SyncularConfig {
1214 db_path: Some("/tmp/default.db".to_owned()),
1215 database_dir: Some(dir.to_string_lossy().into_owned()),
1216 ..Default::default()
1217 };
1218 assert_eq!(
1220 resolve_database(&config, &json!({ "clientId": "c1" })),
1221 Ok(Some("/tmp/default.db".to_owned()))
1222 );
1223 assert_eq!(
1225 resolve_database(&config, &json!({ "database": "app-actor-0f.v2" })),
1226 Ok(Some(
1227 dir.join("app-actor-0f.v2.db")
1228 .to_string_lossy()
1229 .into_owned()
1230 ))
1231 );
1232 assert!(dir.is_dir());
1233 for params in [
1236 json!({ "dbPath": "/etc/other.db" }),
1237 json!({ "database": "../escape" }),
1238 json!({ "database": "a/b" }),
1239 json!({ "database": "a\\b" }),
1240 json!({ "database": "/abs" }),
1241 json!({ "database": ".hidden" }),
1242 json!({ "database": "a..b" }),
1243 json!({ "database": "" }),
1244 json!({ "database": "x".repeat(129) }),
1245 json!({ "database": 7 }),
1246 ] {
1247 let refused = resolve_database(&config, ¶ms).expect_err("refused");
1248 assert_eq!(refused["error"]["code"], "sync.invalid_request", "{params}");
1249 }
1250 let refused = resolve_database(
1253 &SyncularConfig {
1254 database_dir: Some(dir.to_string_lossy().into_owned()),
1255 ..Default::default()
1256 },
1257 &json!({ "clientId": "c1" }),
1258 )
1259 .expect_err("name required");
1260 assert_eq!(refused["error"]["code"], "sync.invalid_request");
1261 let refused = resolve_database(&SyncularConfig::default(), &json!({ "database": "actor" }))
1264 .expect_err("no directory");
1265 assert_eq!(refused["error"]["code"], "sync.invalid_request");
1266 std::fs::remove_dir_all(dir).expect("remove temp directory");
1267 }
1268
1269 #[test]
1272 fn named_databases_keep_one_replica_and_client_id_per_actor() {
1273 use tauri::test::{mock_builder, mock_context, noop_assets};
1274
1275 let dir =
1276 std::env::temp_dir().join(format!("syncular-tauri-actors-{}", std::process::id()));
1277 let _ = std::fs::remove_dir_all(&dir);
1278 let app = mock_builder()
1279 .plugin(init(SyncularConfig {
1280 database_dir: Some(dir.to_string_lossy().into_owned()),
1281 auto_sync: false,
1282 ..Default::default()
1283 }))
1284 .build(mock_context(noop_assets()))
1285 .expect("build mock app");
1286 let command = |value: Value| {
1287 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1288 .expect("command reply")
1289 };
1290 let snapshot = |sql: &str| {
1291 tauri::async_runtime::block_on(syncular_query_snapshot(
1292 app.handle().clone(),
1293 sql.to_owned(),
1294 None,
1295 None,
1296 None,
1297 ))
1298 .expect("snapshot reply")
1299 };
1300 let schema = json!({
1301 "version": 1,
1302 "tables": [{
1303 "name": "todo", "primaryKey": "id",
1304 "columns": [
1305 { "name": "id", "type": "string", "nullable": false },
1306 { "name": "title", "type": "string", "nullable": false }
1307 ],
1308 "scopes": []
1309 }]
1310 });
1311 let create = |database: &str, client_id: &str| {
1312 command(json!({ "method": "create", "params": {
1313 "database": database, "clientId": client_id, "schema": schema
1314 } }))
1315 };
1316
1317 assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1318 command(json!({ "method": "mutate", "params": { "mutations": [{
1319 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "a" }
1320 }] } }));
1321 assert_eq!(
1322 snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1323 "a"
1324 );
1325 assert_eq!(
1326 command(json!({ "method": "shutdown" }))["result"],
1327 json!({})
1328 );
1329
1330 assert_eq!(create("actor-b", "client-b")["result"], json!({}));
1331 assert_eq!(
1332 snapshot("SELECT title FROM todo")["result"]["rows"],
1333 json!([]),
1334 "the reader follows the second actor's database"
1335 );
1336 assert!(dir.join("actor-a.db").is_file());
1337 assert!(dir.join("actor-b.db").is_file());
1338 assert_eq!(
1339 command(json!({ "method": "shutdown" }))["result"],
1340 json!({})
1341 );
1342
1343 assert_eq!(
1345 create("actor-a", "client-b")["error"]["code"],
1346 "client.identity_mismatch"
1347 );
1348 assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1349 assert_eq!(
1350 snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1351 "a"
1352 );
1353 assert_eq!(
1354 create("../actor-a", "client-a")["error"]["code"],
1355 "sync.invalid_request"
1356 );
1357 command(json!({ "method": "shutdown" }));
1358 std::fs::remove_dir_all(dir).expect("remove temp directory");
1359 }
1360
1361 #[test]
1362 fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
1363 let parsed = parse_coverage(Some(&json!([{
1364 "base": {
1365 "table": "tasks",
1366 "variable": "project_id",
1367 "fixedScopes": { "tenant_id": ["one", "two"] },
1368 "params": "opaque"
1369 },
1370 "units": ["a", "b"]
1371 }])))
1372 .expect("parse coverage");
1373 assert_eq!(parsed.len(), 1);
1374 let entry = &parsed[0];
1375 assert_eq!(entry.base.table, "tasks");
1376 assert_eq!(entry.base.variable, "project_id");
1377 assert_eq!(
1378 entry.base.fixed_scopes,
1379 vec![(
1380 "tenant_id".to_owned(),
1381 vec!["one".to_owned(), "two".to_owned()]
1382 )]
1383 );
1384 assert_eq!(entry.base.params.as_deref(), Some("opaque"));
1385 assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
1386 }
1387
1388 #[test]
1392 fn owner_thread_round_trips_over_mailbox() {
1393 use std::sync::mpsc::channel;
1394 use std::sync::{Arc, Mutex as StdMutex};
1395
1396 let (tx, rx) = channel::<Request>();
1397 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
1398 let events_for_thread = Arc::clone(&events);
1399 let config = SyncularConfig {
1400 auto_sync: false,
1401 ..Default::default()
1402 };
1403 let owner_tx = tx.clone();
1404 let handle = std::thread::spawn(move || {
1405 run_owner_thread(config, owner_tx, rx, move |v| {
1406 events_for_thread.lock().unwrap().push(v.clone());
1407 });
1408 });
1409
1410 let call = |command: Value| -> Value {
1411 let (rtx, rrx) = channel();
1412 tx.send(Request::Command {
1413 command,
1414 reply: rtx,
1415 })
1416 .unwrap();
1417 rrx.recv().unwrap()
1418 };
1419
1420 let schema = json!({
1421 "version": 1,
1422 "tables": [{
1423 "name": "todo", "primaryKey": "id",
1424 "columns": [
1425 { "name": "id", "type": "string", "nullable": false },
1426 { "name": "title", "type": "string", "nullable": false }
1427 ],
1428 "scopes": []
1429 }]
1430 });
1431 assert_eq!(
1432 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1433 ["result"],
1434 json!({})
1435 );
1436 call(json!({ "method": "mutate", "params": { "mutations": [{
1437 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1438 }] } }));
1439
1440 let (qtx, qrx) = channel();
1442 tx.send(Request::Query {
1443 sql: "SELECT title FROM todo".to_owned(),
1444 params: Value::Null,
1445 reply: qtx,
1446 })
1447 .unwrap();
1448 let rows = qrx.recv().unwrap();
1449 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1450
1451 let (htx, hrx) = channel();
1454 tx.send(Request::SetHeaders {
1455 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1456 reply: htx,
1457 })
1458 .unwrap();
1459 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1460
1461 tx.send(Request::Shutdown).unwrap();
1462 handle.join().unwrap();
1463
1464 let seen = events.lock().unwrap();
1465 let kinds: Vec<String> = seen
1466 .iter()
1467 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1468 .collect();
1469 assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1471 }
1472
1473 #[cfg(feature = "native-transport")]
1474 #[test]
1475 fn pending_network_round_keeps_local_mutations_and_queries_responsive() {
1476 use ssp2::model::{Frame, Message, MsgKind, OpResult, PushStatus};
1477 use std::io::{Read, Write};
1478 use std::net::TcpListener;
1479 use std::sync::mpsc::channel;
1480
1481 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1482 let address = listener.local_addr().unwrap();
1483 let (entered_tx, entered_rx) = channel();
1484 let (release_tx, release_rx) = channel();
1485 let server = std::thread::spawn(move || {
1486 let mut pushed = Vec::new();
1487 while pushed.len() < 2 {
1488 let (mut socket, _) = listener.accept().unwrap();
1489 let mut header = Vec::new();
1490 while !header.ends_with(b"\r\n\r\n") {
1491 let mut byte = [0];
1492 socket.read_exact(&mut byte).unwrap();
1493 header.push(byte[0]);
1494 }
1495 let header = String::from_utf8(header).unwrap();
1496 let length: usize = header
1497 .lines()
1498 .find_map(|line| {
1499 let (name, value) = line.split_once(':')?;
1500 name.eq_ignore_ascii_case("content-length")
1501 .then(|| value.trim().parse().unwrap())
1502 })
1503 .unwrap();
1504 let mut bytes = vec![0; length];
1505 socket.read_exact(&mut bytes).unwrap();
1506 let request = ssp2::decode_message(&bytes).unwrap();
1507 let reset = matches!(
1508 &request.frames[0],
1509 Frame::ReqHeader {
1510 log_epoch: None,
1511 ..
1512 }
1513 );
1514 let mut frames = vec![Frame::RespHeader {
1515 required_schema_version: None,
1516 latest_schema_version: None,
1517 log_epoch: Some("epoch".into()),
1518 reset_required: Some(reset),
1519 }];
1520 if !reset {
1521 let commits: Vec<_> = request
1522 .frames
1523 .iter()
1524 .filter_map(|frame| {
1525 if let Frame::PushCommit {
1526 client_commit_id,
1527 operations,
1528 } = frame
1529 {
1530 Some((client_commit_id.clone(), operations.len()))
1531 } else {
1532 None
1533 }
1534 })
1535 .collect();
1536 if !commits.is_empty() && pushed.is_empty() {
1537 entered_tx.send(()).unwrap();
1538 release_rx.recv().unwrap();
1539 }
1540 for (id, count) in commits {
1541 pushed.push(id.clone());
1542 frames.push(Frame::PushResult {
1543 client_commit_id: id,
1544 status: PushStatus::Applied,
1545 commit_seq: Some(pushed.len() as i64),
1546 results: (0..count)
1547 .map(|i| OpResult::Applied { op_index: i as i32 })
1548 .collect(),
1549 });
1550 }
1551 }
1552 let response = ssp2::encode_message(&Message {
1553 wire_version: ssp2::decode::WIRE_VERSION,
1554 msg_kind: MsgKind::Response,
1555 frames,
1556 });
1557 write!(
1558 socket,
1559 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1560 response.len()
1561 )
1562 .unwrap();
1563 socket.write_all(&response).unwrap();
1564 }
1565 pushed
1566 });
1567 let (tx, rx) = channel();
1568 let owner_tx = tx.clone();
1569 let owner = std::thread::spawn(move || {
1570 run_owner_thread(
1571 SyncularConfig {
1572 base_url: Some(format!("http://{address}")),
1573 auto_sync: true,
1574 ..Default::default()
1575 },
1576 owner_tx,
1577 rx,
1578 |_| {},
1579 )
1580 });
1581 let call = |command: Value| {
1582 let (reply, result) = channel();
1583 tx.send(Request::Command { command, reply }).unwrap();
1584 result
1585 };
1586 let created = call(json!({ "method": "create", "params": {
1587 "clientId": "pending-round", "schema": { "version": 1, "tables": [{
1588 "name": "todo", "primaryKey": "id", "columns": [
1589 { "name": "id", "type": "string", "nullable": false },
1590 { "name": "title", "type": "string", "nullable": false }
1591 ], "scopes": []
1592 }] }
1593 } }));
1594 assert!(created.recv().unwrap().get("error").is_none());
1595 let first = call(json!({ "method": "mutate", "params": { "mutations": [{
1596 "op": "upsert", "table": "todo", "values": { "id": "one", "title": "first" }
1597 }] } }))
1598 .recv()
1599 .unwrap();
1600 entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
1601 let started = Instant::now();
1602 let second = call(json!({ "method": "mutate", "params": { "mutations": [{
1603 "op": "upsert", "table": "todo", "values": { "id": "two", "title": "second" }
1604 }] } }));
1605 let local_reply = second.recv_timeout(Duration::from_millis(100));
1606 let elapsed = started.elapsed();
1607 let (query_tx, query_rx) = channel();
1608 tx.send(Request::Query {
1609 sql: "SELECT title FROM todo ORDER BY id".into(),
1610 params: Value::Null,
1611 reply: query_tx,
1612 })
1613 .unwrap();
1614 let local_rows = query_rx.recv_timeout(Duration::from_millis(100));
1615 release_tx.send(()).unwrap();
1618 let second_reply = local_reply
1619 .clone()
1620 .unwrap_or_else(|_| second.recv().unwrap());
1621 let rows = local_rows
1622 .clone()
1623 .unwrap_or_else(|_| query_rx.recv().unwrap());
1624 let pushed = server.join().unwrap();
1625 tx.send(Request::Shutdown).unwrap();
1626 owner.join().unwrap();
1627 assert!(
1628 local_reply.is_ok(),
1629 "local mutation waited for network reply"
1630 );
1631 assert!(local_rows.is_ok(), "local query waited for network reply");
1632 eprintln!("second local mutation while network pending: {elapsed:?}");
1633 assert_eq!(rows["result"]["rows"].as_array().unwrap().len(), 2);
1634 assert_eq!(
1635 pushed,
1636 vec![
1637 first["result"]["clientCommitId"].as_str().unwrap(),
1638 second_reply["result"]["clientCommitId"].as_str().unwrap()
1639 ]
1640 );
1641 }
1642
1643 #[test]
1644 fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1645 use std::sync::mpsc::channel;
1646
1647 let path =
1648 std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1649 let config = SyncularConfig {
1650 db_path: Some(path.to_string_lossy().into_owned()),
1651 auto_sync: false,
1652 ..Default::default()
1653 };
1654 let (tx, rx) = channel::<Request>();
1655 let owner_tx = tx.clone();
1656 let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1657
1658 let (create_tx, create_rx) = channel();
1659 tx.send(Request::Command {
1660 command: json!({
1661 "method": "create",
1662 "params": {
1663 "clientId": "sidecar-client",
1664 "schema": { "version": 1, "tables": [] },
1665 "dbPath": path.to_string_lossy()
1666 }
1667 }),
1668 reply: create_tx,
1669 })
1670 .expect("post create");
1671 assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1672
1673 let (read_tx, read_rx) = channel::<ReadRequest>();
1674 let read_path = path.to_string_lossy().into_owned();
1675 let owner_tx = tx.clone();
1676 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
1677
1678 let (entered_tx, entered_rx) = channel();
1681 tx.send(Request::Block {
1682 duration: Duration::from_millis(200),
1683 entered: entered_tx,
1684 })
1685 .expect("block owner");
1686 entered_rx.recv().expect("owner entered blocking round");
1687 let (snapshot_tx, snapshot_rx) = channel();
1688 read_tx
1689 .send(ReadRequest::QuerySnapshot {
1690 sql: "SELECT 1 AS value".to_owned(),
1691 params: Vec::new(),
1692 coverage: Vec::new(),
1693 owner: None,
1694 reply: snapshot_tx,
1695 })
1696 .expect("post snapshot");
1697 let snapshot = snapshot_rx
1698 .recv_timeout(Duration::from_millis(50))
1699 .expect("local snapshot must not wait for the owner");
1700 assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
1701
1702 let (failed_tx, failed_rx) = channel();
1703 read_tx
1704 .send(ReadRequest::QuerySnapshot {
1705 sql: "SELECT private_value FROM missing_private_table".to_owned(),
1706 params: Vec::new(),
1707 coverage: Vec::new(),
1708 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1709 reply: failed_tx,
1710 })
1711 .expect("post failed snapshot");
1712 assert_eq!(
1713 failed_rx.recv().expect("failed snapshot reply")["error"]["code"],
1714 "client.failed"
1715 );
1716 let (diagnostics_tx, diagnostics_rx) = channel();
1717 tx.send(Request::Command {
1718 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1719 reply: diagnostics_tx,
1720 })
1721 .expect("post diagnostics");
1722 assert_eq!(
1723 diagnostics_rx.recv().expect("diagnostics reply")["result"]["queryFailures"][0]["id"],
1724 "queries:missing"
1725 );
1726
1727 let (recovered_tx, recovered_rx) = channel();
1728 read_tx
1729 .send(ReadRequest::QuerySnapshot {
1730 sql: "SELECT 1 AS value".to_owned(),
1731 params: Vec::new(),
1732 coverage: Vec::new(),
1733 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1734 reply: recovered_tx,
1735 })
1736 .expect("post recovered snapshot");
1737 assert!(recovered_rx.recv().expect("recovered snapshot reply")["result"].is_object());
1738 let (cleared_tx, cleared_rx) = channel();
1739 tx.send(Request::Command {
1740 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1741 reply: cleared_tx,
1742 })
1743 .expect("post cleared diagnostics");
1744 assert_eq!(
1745 cleared_rx.recv().expect("cleared diagnostics reply")["result"]["queryFailures"],
1746 json!([])
1747 );
1748
1749 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
1750 reader.join().expect("join reader");
1751 tx.send(Request::Shutdown).expect("stop owner");
1752 owner.join().expect("join owner");
1753 std::fs::remove_file(path).expect("remove temp database");
1754 }
1755}