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::{Change, Frame, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
1477 use ssp2::segment::{encode_row, Column, ColumnType, ColumnValue};
1478 use std::io::{Read, Write};
1479 use std::net::TcpListener;
1480 use std::sync::atomic::{AtomicBool, Ordering};
1481 use std::sync::mpsc::channel;
1482 use std::sync::Arc;
1483
1484 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1485 let address = listener.local_addr().unwrap();
1486 let (entered_tx, entered_rx) = channel();
1487 let (release_tx, release_rx) = channel();
1488 let (delivered_tx, delivered_rx) = channel();
1489 let image_sent = Arc::new(AtomicBool::new(false));
1490 let server_image_sent = image_sent.clone();
1491 let server = std::thread::spawn(move || {
1492 let mut pushed = Vec::new();
1493 loop {
1494 let (mut socket, _) = listener.accept().unwrap();
1495 let mut header = Vec::new();
1496 while !header.ends_with(b"\r\n\r\n") {
1497 let mut byte = [0];
1498 socket.read_exact(&mut byte).unwrap();
1499 header.push(byte[0]);
1500 }
1501 let header = String::from_utf8(header).unwrap();
1502 let length: usize = header
1503 .lines()
1504 .find_map(|line| {
1505 let (name, value) = line.split_once(':')?;
1506 name.eq_ignore_ascii_case("content-length")
1507 .then(|| value.trim().parse().unwrap())
1508 })
1509 .unwrap();
1510 let mut bytes = vec![0; length];
1511 socket.read_exact(&mut bytes).unwrap();
1512 let request = ssp2::decode_message(&bytes).unwrap();
1513 let reset = matches!(
1514 &request.frames[0],
1515 Frame::ReqHeader {
1516 log_epoch: None,
1517 ..
1518 }
1519 );
1520 let deliver = !reset && pushed.len() == 2;
1521 let mut frames = vec![Frame::RespHeader {
1522 required_schema_version: None,
1523 latest_schema_version: None,
1524 log_epoch: Some("epoch".into()),
1525 reset_required: Some(reset),
1526 }];
1527 if !reset {
1528 let commits: Vec<_> = request
1529 .frames
1530 .iter()
1531 .filter_map(|frame| {
1532 if let Frame::PushCommit {
1533 client_commit_id,
1534 operations,
1535 } = frame
1536 {
1537 Some((client_commit_id.clone(), operations.len()))
1538 } else {
1539 None
1540 }
1541 })
1542 .collect();
1543 if !commits.is_empty() && pushed.is_empty() {
1544 entered_tx.send(()).unwrap();
1545 release_rx.recv().unwrap();
1546 }
1547 for (id, count) in commits {
1548 pushed.push(id.clone());
1549 frames.push(Frame::PushResult {
1550 client_commit_id: id,
1551 status: PushStatus::Applied,
1552 commit_seq: Some(pushed.len() as i64),
1553 results: (0..count)
1554 .map(|i| OpResult::Applied { op_index: i as i32 })
1555 .collect(),
1556 });
1557 }
1558 }
1559 if !reset
1560 && request
1561 .frames
1562 .iter()
1563 .any(|frame| matches!(frame, Frame::Subscription { id, .. } if id == "own"))
1564 {
1565 frames.push(Frame::SubStart {
1566 id: "own".into(),
1567 status: SubStatus::Active,
1568 reason_code: String::new(),
1569 effective_scopes: vec![("id".into(), vec!["one".into()])],
1570 bootstrap: false,
1571 });
1572 if deliver {
1573 assert!(request
1574 .frames
1575 .iter()
1576 .any(|frame| matches!(frame, Frame::PullHeader { .. })));
1577 assert!(!request
1578 .frames
1579 .iter()
1580 .any(|frame| matches!(frame, Frame::PushCommit { .. })));
1581 let columns = ["id", "title"].map(|name| Column {
1582 name: name.into(),
1583 ty: ColumnType::String,
1584 nullable: false,
1585 });
1586 let mut row = ssp2::primitives::Writer::new();
1587 encode_row(
1588 &mut row,
1589 &columns,
1590 &vec![
1591 Some(ColumnValue::String("one".into())),
1592 Some(ColumnValue::String("300/v3".into())),
1593 ],
1594 );
1595 frames.push(Frame::Commit {
1596 commit_seq: 2,
1597 created_at_ms: 0,
1598 actor_id: "actor".into(),
1599 tables: vec!["todo".into()],
1600 changes: vec![Change {
1601 table_index: 0,
1602 row_id: "one".into(),
1603 op: Op::Upsert,
1604 row_version: Some(2),
1605 scopes: vec![("id".into(), "one".into())],
1606 row: Some(row.into_bytes()),
1607 }],
1608 });
1609 server_image_sent.store(true, Ordering::SeqCst);
1610 }
1611 frames.push(Frame::SubEnd {
1612 next_cursor: if deliver { 2 } else { 0 },
1613 bootstrap_state: None,
1614 });
1615 }
1616 let response = ssp2::encode_message(&Message {
1617 wire_version: ssp2::decode::WIRE_VERSION,
1618 msg_kind: MsgKind::Response,
1619 frames,
1620 });
1621 write!(
1622 socket,
1623 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1624 response.len()
1625 )
1626 .unwrap();
1627 socket.write_all(&response).unwrap();
1628 if deliver {
1629 break;
1630 }
1631 }
1632 pushed
1633 });
1634 let (tx, rx) = channel();
1635 let owner_tx = tx.clone();
1636 let owner = std::thread::spawn(move || {
1637 run_owner_thread(
1638 SyncularConfig {
1639 base_url: Some(format!("http://{address}")),
1640 auto_sync: true,
1641 ..Default::default()
1642 },
1643 owner_tx,
1644 rx,
1645 move |event| {
1646 if image_sent.load(Ordering::SeqCst) && event["type"] == "change" {
1647 let _ = delivered_tx.send(());
1648 }
1649 },
1650 )
1651 });
1652 let call = |command: Value| {
1653 let (reply, result) = channel();
1654 tx.send(Request::Command { command, reply }).unwrap();
1655 result
1656 };
1657 let created = call(json!({ "method": "create", "params": {
1658 "clientId": "pending-round", "schema": { "version": 1, "tables": [{
1659 "name": "todo", "primaryKey": "id", "columns": [
1660 { "name": "id", "type": "string", "nullable": false },
1661 { "name": "title", "type": "string", "nullable": false }
1662 ], "scopes": [{ "pattern": "todo:{id}" }]
1663 }] }
1664 } }));
1665 assert!(created.recv().unwrap().get("error").is_none());
1666 assert!(call(json!({ "method": "subscribe", "params": { "id": "own", "table": "todo", "scopes": { "id": ["one"] } } })).recv().unwrap().get("error").is_none());
1667 let first = call(json!({ "method": "mutate", "params": { "mutations": [{
1668 "op": "upsert", "table": "todo", "values": { "id": "one", "title": "100/v1" }
1669 }] } }))
1670 .recv()
1671 .unwrap();
1672 entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
1673 let started = Instant::now();
1674 let second = call(json!({ "method": "mutate", "params": { "mutations": [{
1675 "op": "patch", "table": "todo", "values": { "id": "one", "title": "300/v3" }
1676 }] } }));
1677 let local_reply = second.recv_timeout(Duration::from_millis(100));
1678 let elapsed = started.elapsed();
1679 let (query_tx, query_rx) = channel();
1680 tx.send(Request::Query {
1681 sql: "SELECT title FROM todo ORDER BY id".into(),
1682 params: Value::Null,
1683 reply: query_tx,
1684 })
1685 .unwrap();
1686 let local_rows = query_rx.recv_timeout(Duration::from_millis(100));
1687 release_tx.send(()).unwrap();
1690 let second_reply = local_reply
1691 .clone()
1692 .unwrap_or_else(|_| second.recv().unwrap());
1693 let rows = local_rows
1694 .clone()
1695 .unwrap_or_else(|_| query_rx.recv().unwrap());
1696 delivered_rx
1697 .recv_timeout(Duration::from_secs(10))
1698 .expect("ACK schedules the own-image pull without realtime or a manual round");
1699 let (query_tx, query_rx) = channel();
1700 tx.send(Request::Query {
1701 sql: "SELECT title,_sync_version AS version FROM todo WHERE id='one'".into(),
1702 params: Value::Null,
1703 reply: query_tx,
1704 })
1705 .unwrap();
1706 let delivered = query_rx.recv().unwrap();
1707 assert_eq!(delivered["result"]["rows"][0]["title"], "300/v3");
1708 assert_eq!(delivered["result"]["rows"][0]["version"], 2);
1709 let pushed = server.join().unwrap();
1710 tx.send(Request::Shutdown).unwrap();
1711 owner.join().unwrap();
1712 assert!(
1713 local_reply.is_ok(),
1714 "local mutation waited for network reply"
1715 );
1716 assert!(local_rows.is_ok(), "local query waited for network reply");
1717 eprintln!("second local mutation while network pending: {elapsed:?}");
1718 assert_eq!(rows["result"]["rows"].as_array().unwrap().len(), 1);
1719 assert_eq!(
1720 pushed,
1721 vec![
1722 first["result"]["clientCommitId"].as_str().unwrap(),
1723 second_reply["result"]["clientCommitId"].as_str().unwrap()
1724 ]
1725 );
1726 }
1727
1728 #[test]
1729 fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1730 use std::sync::mpsc::channel;
1731
1732 let path =
1733 std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1734 let config = SyncularConfig {
1735 db_path: Some(path.to_string_lossy().into_owned()),
1736 auto_sync: false,
1737 ..Default::default()
1738 };
1739 let (tx, rx) = channel::<Request>();
1740 let owner_tx = tx.clone();
1741 let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1742
1743 let (create_tx, create_rx) = channel();
1744 tx.send(Request::Command {
1745 command: json!({
1746 "method": "create",
1747 "params": {
1748 "clientId": "sidecar-client",
1749 "schema": { "version": 1, "tables": [] },
1750 "dbPath": path.to_string_lossy()
1751 }
1752 }),
1753 reply: create_tx,
1754 })
1755 .expect("post create");
1756 assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1757
1758 let (read_tx, read_rx) = channel::<ReadRequest>();
1759 let read_path = path.to_string_lossy().into_owned();
1760 let owner_tx = tx.clone();
1761 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
1762
1763 let (entered_tx, entered_rx) = channel();
1766 tx.send(Request::Block {
1767 duration: Duration::from_millis(200),
1768 entered: entered_tx,
1769 })
1770 .expect("block owner");
1771 entered_rx.recv().expect("owner entered blocking round");
1772 let (snapshot_tx, snapshot_rx) = channel();
1773 read_tx
1774 .send(ReadRequest::QuerySnapshot {
1775 sql: "SELECT 1 AS value".to_owned(),
1776 params: Vec::new(),
1777 coverage: Vec::new(),
1778 owner: None,
1779 reply: snapshot_tx,
1780 })
1781 .expect("post snapshot");
1782 let snapshot = snapshot_rx
1783 .recv_timeout(Duration::from_millis(50))
1784 .expect("local snapshot must not wait for the owner");
1785 assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
1786
1787 let (failed_tx, failed_rx) = channel();
1788 read_tx
1789 .send(ReadRequest::QuerySnapshot {
1790 sql: "SELECT private_value FROM missing_private_table".to_owned(),
1791 params: Vec::new(),
1792 coverage: Vec::new(),
1793 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1794 reply: failed_tx,
1795 })
1796 .expect("post failed snapshot");
1797 assert_eq!(
1798 failed_rx.recv().expect("failed snapshot reply")["error"]["code"],
1799 "client.failed"
1800 );
1801 let (diagnostics_tx, diagnostics_rx) = channel();
1802 tx.send(Request::Command {
1803 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1804 reply: diagnostics_tx,
1805 })
1806 .expect("post diagnostics");
1807 assert_eq!(
1808 diagnostics_rx.recv().expect("diagnostics reply")["result"]["queryFailures"][0]["id"],
1809 "queries:missing"
1810 );
1811
1812 let (recovered_tx, recovered_rx) = channel();
1813 read_tx
1814 .send(ReadRequest::QuerySnapshot {
1815 sql: "SELECT 1 AS value".to_owned(),
1816 params: Vec::new(),
1817 coverage: Vec::new(),
1818 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1819 reply: recovered_tx,
1820 })
1821 .expect("post recovered snapshot");
1822 assert!(recovered_rx.recv().expect("recovered snapshot reply")["result"].is_object());
1823 let (cleared_tx, cleared_rx) = channel();
1824 tx.send(Request::Command {
1825 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1826 reply: cleared_tx,
1827 })
1828 .expect("post cleared diagnostics");
1829 assert_eq!(
1830 cleared_rx.recv().expect("cleared diagnostics reply")["result"]["queryFailures"],
1831 json!([])
1832 );
1833
1834 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
1835 reader.join().expect("join reader");
1836 tx.send(Request::Shutdown).expect("stop owner");
1837 owner.join().expect("join owner");
1838 std::fs::remove_file(path).expect("remove temp database");
1839 }
1840}