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 !core.transport_enabled() {
398 background_deadline = None;
399 }
400 if let Some((controls, transport)) = core.take_controls() {
401 if network_tx
402 .send(NetworkWork::Controls(controls, transport))
403 .is_err()
404 {
405 break;
406 }
407 }
408 if active.is_none() {
411 if let Some(run) = queued.pop_front() {
412 active = Some(run);
413 } else if config.auto_sync {
414 match core.take_sync_intent() {
415 syncular_client::SyncIntent::Interactive => {
416 background_deadline = None;
417 active = Some(RoundRun {
418 reply: None,
419 until_idle: true,
420 max_rounds: None,
421 spent: 0,
422 aggregate: Default::default(),
423 });
424 }
425 syncular_client::SyncIntent::Background { delay_ms } => {
426 let candidate = Instant::now()
427 .checked_add(Duration::from_millis(delay_ms))
428 .unwrap_or_else(Instant::now);
429 background_deadline = Some(
430 background_deadline.map_or(candidate, |current| current.min(candidate)),
431 );
432 }
433 syncular_client::SyncIntent::None => {}
434 }
435 }
436 if active.is_some() {
437 match core.prepare_round() {
438 Ok((prepared, transport)) => {
439 active.as_mut().expect("active run").spent += 1;
440 if network_tx
441 .send(NetworkWork::Round(Box::new(prepared), transport))
442 .is_err()
443 {
444 break;
445 }
446 }
447 Err(outcome) => {
448 if let Some(reply) = active.take().and_then(|run| run.reply) {
449 let _ = reply.send(json!({ "result": outcome.to_json() }));
450 }
451 }
452 }
453 pump_events(&mut core, &*emit);
454 if active.is_none() {
455 continue;
456 }
457 }
458 }
459 let request = if active.is_none() {
460 if let Some(deadline) = background_deadline {
461 match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
462 Ok(request) => request,
463 Err(RecvTimeoutError::Timeout) => {
464 background_deadline = None;
465 queued.push_back(RoundRun {
466 reply: None,
467 until_idle: true,
468 max_rounds: None,
469 spent: 0,
470 aggregate: Default::default(),
471 });
472 continue;
473 }
474 Err(RecvTimeoutError::Disconnected) => break,
475 }
476 } else {
477 match rx.recv() {
478 Ok(request) => request,
479 Err(_) => break,
480 }
481 }
482 } else {
483 match rx.recv() {
484 Ok(request) => request,
485 Err(_) => break,
486 }
487 };
488 match request {
489 Request::RoundFinished(completed) => {
490 let (completed, transport) = *completed;
491 let applied = core.apply_round(completed);
492 let (mut outcome, more, bootstrap_advanced) = match applied {
493 syncular_client::AppliedSyncRound::Continue(prepared) => {
494 if network_tx
495 .send(NetworkWork::Round(Box::new(prepared), transport))
496 .is_err()
497 {
498 break;
499 }
500 pump_events(&mut core, &*emit);
501 continue;
502 }
503 syncular_client::AppliedSyncRound::Complete {
504 outcome,
505 more,
506 bootstrap_advanced,
507 ..
508 } => (outcome, more, bootstrap_advanced),
509 };
510 let Some(mut run) = active.take() else {
511 continue;
512 };
513 let mut keep_running = false;
514 if run.until_idle {
515 if let syncular_client::SyncOutcome::Ok(report) = &outcome {
516 run.aggregate.merge(report);
517 if bootstrap_advanced && run.max_rounds.is_none() {
518 run.spent = 0;
519 }
520 keep_running = more && core.transport_enabled();
521 if keep_running && run.spent >= run.max_rounds.unwrap_or(20).max(1) {
522 keep_running = false;
523 outcome = syncular_client::SyncOutcome::Failed {
524 error_code: "sync.invalid_request".into(),
525 message: "sync did not reach idle within the round budget".into(),
526 details: None,
527 };
528 } else if !keep_running {
529 outcome = syncular_client::SyncOutcome::Ok(run.aggregate.clone());
530 }
531 }
532 }
533 if keep_running {
534 queued.push_front(run);
535 } else if let Some(reply) = run.reply {
536 let _ = reply.send(json!({ "result": outcome.to_json() }));
537 }
538 pump_events(&mut core, &*emit);
539 }
540 Request::Command { command, reply } => {
541 let method = command.get("method").and_then(Value::as_str);
542 if matches!(method, Some("sync" | "syncUntilIdle")) && core.transport_enabled() {
543 queued.push_back(RoundRun {
544 reply: Some(reply),
545 until_idle: method == Some("syncUntilIdle"),
546 max_rounds: command
547 .pointer("/params/maxRounds")
548 .and_then(Value::as_u64)
549 .map(|n| n as u32),
550 spent: 0,
551 aggregate: Default::default(),
552 });
553 continue;
554 }
555 let result = core.command(&command);
556 let _ = reply.send(result);
557 pump_events(&mut core, &*emit);
558 }
559 Request::Query { sql, params, reply } => {
560 let _ = reply.send(core.query(&sql, params));
561 pump_events(&mut core, &*emit);
562 }
563 Request::SetHeaders { headers, reply } => {
564 core.set_headers(headers);
565 let _ = reply.send(json!({ "result": null }));
566 }
567 Request::TransportWake => {
568 core.poll_transport();
569 pump_events(&mut core, &*emit);
570 }
571 Request::QueryRead {
572 id,
573 tables,
574 failure,
575 } => {
576 core.record_query_read(&id, &tables, failure.as_ref());
577 pump_events(&mut core, &*emit);
578 }
579 #[cfg(test)]
580 Request::Block { duration, entered } => {
581 let _ = entered.send(());
582 std::thread::sleep(duration);
583 }
584 Request::Shutdown => break,
585 }
586 }
587 core.shutdown();
588 drop(network_tx);
589 let _ = network.join();
592 for run in active.into_iter().chain(queued) {
593 if let Some(reply) = run.reply {
594 let _ = reply.send(json!({ "error": { "code": "client.closed", "message": "client owner has closed" } }));
595 }
596 }
597}
598
599fn resolve_database(config: &SyncularConfig, params: &Value) -> Result<Option<String>, Value> {
606 if params.get("dbPath").is_some() {
607 return Err(invalid_request(
608 "sync.invalid_request: create.dbPath is host configuration; name a database with create.database",
609 ));
610 }
611 let Some(database) = params.get("database") else {
612 if config.db_path.is_none() && config.database_dir.is_some() {
613 return Err(invalid_request(
614 "sync.invalid_request: create.database is required when SyncularConfig sets database_dir without db_path",
615 ));
616 }
617 return Ok(config.db_path.clone());
618 };
619 let Some(name) = database.as_str().filter(|name| {
620 (1..=128).contains(&name.len())
621 && name.starts_with(|c: char| c.is_ascii_alphanumeric())
622 && !name.contains("..")
623 && name
624 .chars()
625 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
626 }) else {
627 return Err(invalid_request(
628 "sync.invalid_request: create.database must be 1 to 128 ASCII letters, digits, '-', '_' or '.', start with a letter or digit, and contain no '..'",
629 ));
630 };
631 let Some(dir) = &config.database_dir else {
632 return Err(invalid_request(
633 "sync.invalid_request: create.database needs SyncularConfig.database_dir",
634 ));
635 };
636 std::fs::create_dir_all(dir)
637 .map_err(|error| client_error(format!("cannot create the database directory: {error}")))?;
638 Ok(Some(
639 std::path::Path::new(dir)
640 .join(format!("{name}.db"))
641 .to_string_lossy()
642 .into_owned(),
643 ))
644}
645
646fn invalid_request(message: &str) -> Value {
647 json!({ "error": { "code": "sync.invalid_request", "message": message } })
648}
649
650fn client_error(message: impl Into<String>) -> Value {
651 json!({ "error": { "code": "client.failed", "message": message.into() } })
652}
653
654fn security_preflight_error() -> Value {
655 json!({
656 "error": {
657 "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
658 "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
659 }
660 })
661}
662
663fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
664 let object = value
665 .and_then(Value::as_object)
666 .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
667 let table = object
668 .get("table")
669 .and_then(Value::as_str)
670 .ok_or_else(|| "window base missing table".to_owned())?
671 .to_owned();
672 let variable = object
673 .get("variable")
674 .and_then(Value::as_str)
675 .ok_or_else(|| "window base missing variable".to_owned())?
676 .to_owned();
677 let fixed_scopes = match object.get("fixedScopes") {
678 Some(value) => syncular_client::values::json_to_scope_map(value)?,
679 None => Vec::new(),
680 };
681 let params = object
682 .get("params")
683 .and_then(Value::as_str)
684 .map(str::to_owned);
685 Ok(WindowBase {
686 table,
687 variable,
688 fixed_scopes,
689 params,
690 })
691}
692
693fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
694 let Some(value) = value else {
695 return Ok(Vec::new());
696 };
697 if value.is_null() {
698 return Ok(Vec::new());
699 }
700 let entries = value
701 .as_array()
702 .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
703 entries
704 .iter()
705 .map(|entry| {
706 let units = entry
707 .get("units")
708 .and_then(Value::as_array)
709 .map(|values| {
710 values
711 .iter()
712 .filter_map(|value| value.as_str().map(str::to_owned))
713 .collect()
714 })
715 .unwrap_or_default();
716 Ok(WindowCoverage {
717 base: parse_window_base(entry.get("base"))?,
718 units,
719 })
720 })
721 .collect()
722}
723
724fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
725 for event in core.drain_events() {
726 emit(&event.json);
727 }
728}
729
730#[tauri::command]
733async fn syncular_command<R: Runtime>(
734 app: tauri::AppHandle<R>,
735 command: Value,
736) -> Result<Value, String> {
737 let state = app.state::<SyncularState>();
738 let method = command
739 .get("method")
740 .and_then(Value::as_str)
741 .unwrap_or("")
742 .to_owned();
743 if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
744 state.security_gate.begin_preflight()?;
747 }
748 let mut reader = if method == "create" || method == "shutdown" {
750 Some(
751 state
752 .reader
753 .lock()
754 .map_err(|_| "syncular read mailbox poisoned".to_owned())?,
755 )
756 } else {
757 None
758 };
759 let mut command = command;
760 let mut database = None;
761 if method == "create" {
762 database =
763 match resolve_database(&state.config, command.get("params").unwrap_or(&Value::Null)) {
764 Ok(path) => path,
765 Err(reply) => return Ok(reply),
766 };
767 if let Some(path) = &database {
768 match command.get_mut("params").and_then(Value::as_object_mut) {
769 Some(params) => {
770 params.insert("dbPath".to_owned(), Value::from(path.clone()));
771 }
772 None => {
773 if let Some(object) = command.as_object_mut() {
774 object.insert("params".to_owned(), json!({ "dbPath": path }));
775 }
776 }
777 }
778 }
779 }
780 let create_preflight = method == "create"
781 && command
782 .pointer("/params/securityPreflight")
783 .and_then(Value::as_bool)
784 .unwrap_or(false);
785 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
786 state.send(Request::Command {
787 command,
788 reply: reply_tx,
789 })?;
790 let reply = reply_rx
791 .recv()
792 .map_err(|_| "the syncular core dropped the reply".to_owned())?;
793 let succeeded = reply.get("error").is_none();
794 if let (true, Some(slot)) = (succeeded, reader.as_mut()) {
795 let next = match database.filter(|path| path.as_str() != ":memory:") {
796 Some(path) => Some(spawn_reader(
797 path,
798 state
799 .sender
800 .lock()
801 .map_err(|_| "syncular mailbox poisoned".to_owned())?
802 .clone(),
803 )?),
804 None => None,
805 };
806 if let Some(previous) = std::mem::replace(&mut **slot, next) {
807 let _ = previous.send(ReadRequest::Shutdown);
808 }
809 }
810 drop(reader);
811 if succeeded && method == "create" {
812 if !create_preflight {
813 state.security_gate.activate()?;
814 }
815 } else if succeeded && method == "activateSecurity" {
816 state.security_gate.activate()?;
817 }
818 Ok(reply)
819}
820
821#[tauri::command]
826async fn syncular_set_headers<R: Runtime>(
827 app: tauri::AppHandle<R>,
828 headers: std::collections::BTreeMap<String, String>,
829) -> Result<Value, String> {
830 let state = app.state::<SyncularState>();
831 let _active_guard = match state.security_gate.enter_read() {
835 Ok(guard) => guard,
836 Err(reply) => return Ok(reply),
837 };
838 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
839 state.send(Request::SetHeaders {
840 headers: headers.into_iter().collect(),
841 reply: reply_tx,
842 })?;
843 reply_rx
844 .recv()
845 .map_err(|_| "the syncular core dropped the reply".to_owned())
846}
847
848#[tauri::command]
849async fn syncular_query<R: Runtime>(
850 app: tauri::AppHandle<R>,
851 sql: String,
852 params: Option<Value>,
853) -> Result<Value, String> {
854 let state = app.state::<SyncularState>();
855 let _read_guard = match state.security_gate.enter_read() {
856 Ok(guard) => guard,
857 Err(reply) => return Ok(reply),
858 };
859 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
860 state.send(Request::Query {
861 sql,
862 params: params.unwrap_or(Value::Null),
863 reply: reply_tx,
864 })?;
865 reply_rx
866 .recv()
867 .map_err(|_| "the syncular core dropped the reply".to_owned())
868}
869
870#[tauri::command]
874async fn syncular_query_snapshot<R: Runtime>(
875 app: tauri::AppHandle<R>,
876 sql: String,
877 params: Option<Value>,
878 coverage: Option<Value>,
879 owner: Option<Value>,
880) -> Result<Value, String> {
881 let state = app.state::<SyncularState>();
882 let _read_guard = match state.security_gate.enter_read() {
883 Ok(guard) => guard,
884 Err(reply) => return Ok(reply),
885 };
886 let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
887 let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
888
889 if let Some(reader) = state.reader()? {
890 let bind = match params_value.as_array() {
891 Some(values) => values.clone(),
892 None => return Ok(client_error("querySnapshot params must be a list")),
893 };
894 let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
895 Ok(value) => value,
896 Err(message) => return Ok(client_error(message)),
897 };
898 let parsed_owner = match &owner {
900 None | Some(Value::Null) => None,
901 Some(value) => {
902 let id = value
903 .get("id")
904 .and_then(Value::as_str)
905 .filter(|id| !id.is_empty());
906 let tables = value
907 .get("tables")
908 .and_then(Value::as_array)
909 .and_then(|list| {
910 list.iter()
911 .map(|table| table.as_str().map(str::to_owned))
912 .collect::<Option<Vec<String>>>()
913 });
914 match (id, tables) {
915 (Some(id), Some(tables)) => Some((id.to_owned(), tables)),
916 _ => {
917 return Ok(json!({ "error": {
918 "code": "sync.invalid_request",
919 "message": "sync.invalid_request: querySnapshot owner must be {id: non-empty string, tables: string[]}",
920 } }))
921 }
922 }
923 }
924 };
925 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
926 reader
927 .send(ReadRequest::QuerySnapshot {
928 sql,
929 params: bind,
930 coverage: parsed_coverage,
931 owner: parsed_owner,
932 reply: reply_tx,
933 })
934 .map_err(|_| "the syncular read thread has stopped".to_owned())?;
935 return reply_rx
936 .recv()
937 .map_err(|_| "the syncular read thread dropped the reply".to_owned());
938 }
939
940 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
941 state.send(Request::Command {
942 command: json!({
943 "method": "querySnapshot",
944 "params": {
945 "sql": sql,
946 "params": params_value,
947 "coverage": coverage_value,
948 "owner": owner.unwrap_or(Value::Null),
949 }
950 }),
951 reply: reply_tx,
952 })?;
953 reply_rx
954 .recv()
955 .map_err(|_| "the syncular core dropped the reply".to_owned())
956}
957
958pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
966 Builder::<R>::new("syncular")
967 .invoke_handler(tauri::generate_handler![
968 syncular_command,
969 syncular_query,
970 syncular_query_snapshot,
971 syncular_set_headers
972 ])
973 .setup(move |app, _api| {
974 let (tx, rx) = std::sync::mpsc::channel::<Request>();
975 app.manage(SyncularState {
976 sender: Mutex::new(tx.clone()),
977 reader: Mutex::new(None),
978 config: config.clone(),
979 security_gate: SecurityGate::new_preflight(),
982 });
983 let app_handle = app.clone();
984 let emit = move |value: &Value| {
985 let _ = app_handle.emit(EVENT_NAME, value.clone());
988 };
989 std::thread::Builder::new()
990 .name("syncular-core".to_owned())
991 .spawn(move || run_owner_thread(config, tx, rx, emit))
992 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
993 Ok(())
994 })
995 .on_event(|app, event| {
996 if let RunEvent::Exit = event {
997 if let Some(state) = app.try_state::<SyncularState>() {
998 let _ = state.send(Request::Shutdown);
999 if let Ok(Some(reader)) = state.reader() {
1000 let _ = reader.send(ReadRequest::Shutdown);
1001 }
1002 }
1003 }
1004 })
1005 .build()
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use super::*;
1011
1012 #[test]
1013 fn config_to_transport_json_shapes_fields() {
1014 let config = SyncularConfig {
1015 base_url: Some("https://api.example.com".to_owned()),
1016 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
1017 ..Default::default()
1018 };
1019 let json = config.to_transport_json();
1020 assert_eq!(json["baseUrl"], "https://api.example.com");
1021 assert_eq!(json["headers"]["authorization"], "Bearer x");
1022 }
1023
1024 #[test]
1025 fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
1026 let gate = std::sync::Arc::new(SecurityGate::new_preflight());
1027 gate.activate().expect("activate test gate");
1028 let read = gate.enter_read().expect("active read");
1029 let gate_for_barrier = std::sync::Arc::clone(&gate);
1030 let (done_tx, done_rx) = std::sync::mpsc::channel();
1031 let barrier = std::thread::spawn(move || {
1032 gate_for_barrier.begin_preflight().expect("enter preflight");
1033 done_tx.send(()).expect("barrier reply");
1034 });
1035
1036 assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
1037 drop(read);
1038 done_rx
1039 .recv_timeout(Duration::from_secs(1))
1040 .expect("barrier drains after the read");
1041 barrier.join().expect("barrier thread");
1042 assert!(gate.enter_read().is_err());
1043 }
1044
1045 #[test]
1046 fn direct_set_headers_command_respects_the_native_preflight_gate() {
1047 use std::collections::BTreeMap;
1048 use tauri::test::{mock_builder, mock_context, noop_assets};
1049
1050 let app = mock_builder()
1051 .plugin(init(SyncularConfig {
1052 auto_sync: false,
1053 ..Default::default()
1054 }))
1055 .build(mock_context(noop_assets()))
1056 .expect("build mock app");
1057
1058 let blocked = tauri::async_runtime::block_on(syncular_set_headers(
1059 app.handle().clone(),
1060 BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
1061 ))
1062 .expect("preflight reply");
1063 assert_eq!(
1064 blocked["error"]["code"],
1065 Value::from("client.security_preflight_required")
1066 );
1067
1068 let created = tauri::async_runtime::block_on(syncular_command(
1069 app.handle().clone(),
1070 json!({
1071 "method": "create",
1072 "params": {
1073 "clientId": "native-header-gate",
1074 "schema": { "version": 1, "tables": [] }
1075 }
1076 }),
1077 ))
1078 .expect("create reply");
1079 assert!(created.get("error").is_none(), "{created}");
1080
1081 let active = tauri::async_runtime::block_on(syncular_set_headers(
1082 app.handle().clone(),
1083 BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
1084 ))
1085 .expect("active reply");
1086 assert_eq!(active["result"], Value::Null);
1087 }
1088
1089 #[test]
1090 fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
1091 use tauri::test::{mock_builder, mock_context, noop_assets};
1092
1093 let app = mock_builder()
1094 .plugin(init(SyncularConfig {
1095 auto_sync: false,
1096 ..Default::default()
1097 }))
1098 .build(mock_context(noop_assets()))
1099 .expect("build mock app");
1100 let command = |value: Value| {
1101 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1102 .expect("command reply")
1103 };
1104
1105 let created = command(json!({
1106 "method": "create",
1107 "params": {
1108 "clientId": "native-preflight-escape",
1109 "schema": { "version": 1, "tables": [] },
1110 "securityPreflight": true
1111 }
1112 }));
1113 assert!(created.get("error").is_none(), "{created}");
1114
1115 let escape = command(json!({
1118 "method": "create",
1119 "params": {
1120 "clientId": "native-preflight-escape",
1121 "schema": { "version": 1, "tables": [] }
1122 }
1123 }));
1124 assert_eq!(
1125 escape["error"]["code"],
1126 Value::from("client.security_preflight_required"),
1127 "{escape}"
1128 );
1129 let read = tauri::async_runtime::block_on(syncular_query(
1130 app.handle().clone(),
1131 "SELECT 1".to_owned(),
1132 None,
1133 ))
1134 .expect("query reply");
1135 assert_eq!(
1136 read["error"]["code"],
1137 Value::from("client.security_preflight_required")
1138 );
1139
1140 let activated = command(json!({
1142 "method": "activateSecurity",
1143 "params": { "headers": { "authorization": "Bearer fresh" } }
1144 }));
1145 assert!(activated.get("error").is_none(), "{activated}");
1146 let read = tauri::async_runtime::block_on(syncular_query(
1147 app.handle().clone(),
1148 "SELECT 1 AS value".to_owned(),
1149 None,
1150 ))
1151 .expect("query reply");
1152 assert_eq!(read["result"]["rows"][0]["value"], 1);
1153 let recreated = command(json!({
1154 "method": "create",
1155 "params": {
1156 "clientId": "native-preflight-escape",
1157 "schema": { "version": 1, "tables": [] }
1158 }
1159 }));
1160 assert!(recreated.get("error").is_none(), "{recreated}");
1161 }
1162
1163 #[test]
1164 fn resolve_database_names_a_file_inside_the_configured_directory() {
1165 let dir =
1166 std::env::temp_dir().join(format!("syncular-tauri-resolve-{}", std::process::id()));
1167 let config = SyncularConfig {
1168 db_path: Some("/tmp/default.db".to_owned()),
1169 database_dir: Some(dir.to_string_lossy().into_owned()),
1170 ..Default::default()
1171 };
1172 assert_eq!(
1174 resolve_database(&config, &json!({ "clientId": "c1" })),
1175 Ok(Some("/tmp/default.db".to_owned()))
1176 );
1177 assert_eq!(
1179 resolve_database(&config, &json!({ "database": "app-actor-0f.v2" })),
1180 Ok(Some(
1181 dir.join("app-actor-0f.v2.db")
1182 .to_string_lossy()
1183 .into_owned()
1184 ))
1185 );
1186 assert!(dir.is_dir());
1187 for params in [
1190 json!({ "dbPath": "/etc/other.db" }),
1191 json!({ "database": "../escape" }),
1192 json!({ "database": "a/b" }),
1193 json!({ "database": "a\\b" }),
1194 json!({ "database": "/abs" }),
1195 json!({ "database": ".hidden" }),
1196 json!({ "database": "a..b" }),
1197 json!({ "database": "" }),
1198 json!({ "database": "x".repeat(129) }),
1199 json!({ "database": 7 }),
1200 ] {
1201 let refused = resolve_database(&config, ¶ms).expect_err("refused");
1202 assert_eq!(refused["error"]["code"], "sync.invalid_request", "{params}");
1203 }
1204 let refused = resolve_database(
1207 &SyncularConfig {
1208 database_dir: Some(dir.to_string_lossy().into_owned()),
1209 ..Default::default()
1210 },
1211 &json!({ "clientId": "c1" }),
1212 )
1213 .expect_err("name required");
1214 assert_eq!(refused["error"]["code"], "sync.invalid_request");
1215 let refused = resolve_database(&SyncularConfig::default(), &json!({ "database": "actor" }))
1218 .expect_err("no directory");
1219 assert_eq!(refused["error"]["code"], "sync.invalid_request");
1220 std::fs::remove_dir_all(dir).expect("remove temp directory");
1221 }
1222
1223 #[test]
1226 fn named_databases_keep_one_replica_and_client_id_per_actor() {
1227 use tauri::test::{mock_builder, mock_context, noop_assets};
1228
1229 let dir =
1230 std::env::temp_dir().join(format!("syncular-tauri-actors-{}", std::process::id()));
1231 let _ = std::fs::remove_dir_all(&dir);
1232 let app = mock_builder()
1233 .plugin(init(SyncularConfig {
1234 database_dir: Some(dir.to_string_lossy().into_owned()),
1235 auto_sync: false,
1236 ..Default::default()
1237 }))
1238 .build(mock_context(noop_assets()))
1239 .expect("build mock app");
1240 let command = |value: Value| {
1241 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1242 .expect("command reply")
1243 };
1244 let snapshot = |sql: &str| {
1245 tauri::async_runtime::block_on(syncular_query_snapshot(
1246 app.handle().clone(),
1247 sql.to_owned(),
1248 None,
1249 None,
1250 None,
1251 ))
1252 .expect("snapshot reply")
1253 };
1254 let schema = json!({
1255 "version": 1,
1256 "tables": [{
1257 "name": "todo", "primaryKey": "id",
1258 "columns": [
1259 { "name": "id", "type": "string", "nullable": false },
1260 { "name": "title", "type": "string", "nullable": false }
1261 ],
1262 "scopes": []
1263 }]
1264 });
1265 let create = |database: &str, client_id: &str| {
1266 command(json!({ "method": "create", "params": {
1267 "database": database, "clientId": client_id, "schema": schema
1268 } }))
1269 };
1270
1271 assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1272 command(json!({ "method": "mutate", "params": { "mutations": [{
1273 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "a" }
1274 }] } }));
1275 assert_eq!(
1276 snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1277 "a"
1278 );
1279 assert_eq!(
1280 command(json!({ "method": "shutdown" }))["result"],
1281 json!({})
1282 );
1283
1284 assert_eq!(create("actor-b", "client-b")["result"], json!({}));
1285 assert_eq!(
1286 snapshot("SELECT title FROM todo")["result"]["rows"],
1287 json!([]),
1288 "the reader follows the second actor's database"
1289 );
1290 assert!(dir.join("actor-a.db").is_file());
1291 assert!(dir.join("actor-b.db").is_file());
1292 assert_eq!(
1293 command(json!({ "method": "shutdown" }))["result"],
1294 json!({})
1295 );
1296
1297 assert_eq!(
1299 create("actor-a", "client-b")["error"]["code"],
1300 "client.identity_mismatch"
1301 );
1302 assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1303 assert_eq!(
1304 snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1305 "a"
1306 );
1307 assert_eq!(
1308 create("../actor-a", "client-a")["error"]["code"],
1309 "sync.invalid_request"
1310 );
1311 command(json!({ "method": "shutdown" }));
1312 std::fs::remove_dir_all(dir).expect("remove temp directory");
1313 }
1314
1315 #[test]
1316 fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
1317 let parsed = parse_coverage(Some(&json!([{
1318 "base": {
1319 "table": "tasks",
1320 "variable": "project_id",
1321 "fixedScopes": { "tenant_id": ["one", "two"] },
1322 "params": "opaque"
1323 },
1324 "units": ["a", "b"]
1325 }])))
1326 .expect("parse coverage");
1327 assert_eq!(parsed.len(), 1);
1328 let entry = &parsed[0];
1329 assert_eq!(entry.base.table, "tasks");
1330 assert_eq!(entry.base.variable, "project_id");
1331 assert_eq!(
1332 entry.base.fixed_scopes,
1333 vec![(
1334 "tenant_id".to_owned(),
1335 vec!["one".to_owned(), "two".to_owned()]
1336 )]
1337 );
1338 assert_eq!(entry.base.params.as_deref(), Some("opaque"));
1339 assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
1340 }
1341
1342 #[test]
1346 fn owner_thread_round_trips_over_mailbox() {
1347 use std::sync::mpsc::channel;
1348 use std::sync::{Arc, Mutex as StdMutex};
1349
1350 let (tx, rx) = channel::<Request>();
1351 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
1352 let events_for_thread = Arc::clone(&events);
1353 let config = SyncularConfig {
1354 auto_sync: false,
1355 ..Default::default()
1356 };
1357 let owner_tx = tx.clone();
1358 let handle = std::thread::spawn(move || {
1359 run_owner_thread(config, owner_tx, rx, move |v| {
1360 events_for_thread.lock().unwrap().push(v.clone());
1361 });
1362 });
1363
1364 let call = |command: Value| -> Value {
1365 let (rtx, rrx) = channel();
1366 tx.send(Request::Command {
1367 command,
1368 reply: rtx,
1369 })
1370 .unwrap();
1371 rrx.recv().unwrap()
1372 };
1373
1374 let schema = json!({
1375 "version": 1,
1376 "tables": [{
1377 "name": "todo", "primaryKey": "id",
1378 "columns": [
1379 { "name": "id", "type": "string", "nullable": false },
1380 { "name": "title", "type": "string", "nullable": false }
1381 ],
1382 "scopes": []
1383 }]
1384 });
1385 assert_eq!(
1386 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1387 ["result"],
1388 json!({})
1389 );
1390 call(json!({ "method": "mutate", "params": { "mutations": [{
1391 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1392 }] } }));
1393
1394 let (qtx, qrx) = channel();
1396 tx.send(Request::Query {
1397 sql: "SELECT title FROM todo".to_owned(),
1398 params: Value::Null,
1399 reply: qtx,
1400 })
1401 .unwrap();
1402 let rows = qrx.recv().unwrap();
1403 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1404
1405 let (htx, hrx) = channel();
1408 tx.send(Request::SetHeaders {
1409 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1410 reply: htx,
1411 })
1412 .unwrap();
1413 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1414
1415 tx.send(Request::Shutdown).unwrap();
1416 handle.join().unwrap();
1417
1418 let seen = events.lock().unwrap();
1419 let kinds: Vec<String> = seen
1420 .iter()
1421 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1422 .collect();
1423 assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1425 }
1426
1427 #[cfg(feature = "native-transport")]
1428 #[test]
1429 fn pending_network_round_keeps_local_mutations_and_queries_responsive() {
1430 use ssp2::model::{Change, Frame, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
1431 use ssp2::segment::{encode_row, Column, ColumnType, ColumnValue};
1432 use std::io::{Read, Write};
1433 use std::net::TcpListener;
1434 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1435 use std::sync::mpsc::channel;
1436 use std::sync::Arc;
1437
1438 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1439 let address = listener.local_addr().unwrap();
1440 let (entered_tx, entered_rx) = channel();
1441 let (release_tx, release_rx) = channel();
1442 let (delivered_tx, delivered_rx) = channel();
1443 let (ack_tx, ack_rx) = channel();
1444 let image_sent = Arc::new(AtomicBool::new(false));
1445 let server_image_sent = image_sent.clone();
1446 let requests = Arc::new(AtomicUsize::new(0));
1447 let server_requests = requests.clone();
1448 let server = std::thread::spawn(move || {
1449 let mut pushed = Vec::new();
1450 loop {
1451 let (mut socket, _) = listener.accept().unwrap();
1452 server_requests.fetch_add(1, Ordering::SeqCst);
1453 let mut header = Vec::new();
1454 while !header.ends_with(b"\r\n\r\n") {
1455 let mut byte = [0];
1456 socket.read_exact(&mut byte).unwrap();
1457 header.push(byte[0]);
1458 }
1459 let header = String::from_utf8(header).unwrap();
1460 assert!(header
1461 .to_ascii_lowercase()
1462 .contains("authorization: bearer fresh"));
1463 let length: usize = header
1464 .lines()
1465 .find_map(|line| {
1466 let (name, value) = line.split_once(':')?;
1467 name.eq_ignore_ascii_case("content-length")
1468 .then(|| value.trim().parse().unwrap())
1469 })
1470 .unwrap();
1471 let mut bytes = vec![0; length];
1472 socket.read_exact(&mut bytes).unwrap();
1473 let request = ssp2::decode_message(&bytes).unwrap();
1474 let reset = matches!(
1475 &request.frames[0],
1476 Frame::ReqHeader {
1477 log_epoch: None,
1478 ..
1479 }
1480 );
1481 let deliver = !reset && pushed.len() == 3;
1482 let mut frames = vec![Frame::RespHeader {
1483 required_schema_version: None,
1484 latest_schema_version: None,
1485 log_epoch: Some("epoch".into()),
1486 reset_required: Some(reset),
1487 }];
1488 if !reset {
1489 let commits: Vec<_> = request
1490 .frames
1491 .iter()
1492 .filter_map(|frame| {
1493 if let Frame::PushCommit {
1494 client_commit_id,
1495 operations,
1496 } = frame
1497 {
1498 Some((client_commit_id.clone(), operations.len()))
1499 } else {
1500 None
1501 }
1502 })
1503 .collect();
1504 if !commits.is_empty() && pushed.is_empty() {
1505 entered_tx.send(()).unwrap();
1506 release_rx.recv().unwrap();
1507 }
1508 for (id, count) in commits {
1509 pushed.push(id.clone());
1510 frames.push(Frame::PushResult {
1511 client_commit_id: id,
1512 status: PushStatus::Applied,
1513 commit_seq: Some(pushed.len() as i64),
1514 results: (0..count)
1515 .map(|i| OpResult::Applied { op_index: i as i32 })
1516 .collect(),
1517 });
1518 }
1519 }
1520 if !reset
1521 && request
1522 .frames
1523 .iter()
1524 .any(|frame| matches!(frame, Frame::Subscription { id, .. } if id == "own"))
1525 {
1526 frames.push(Frame::SubStart {
1527 id: "own".into(),
1528 status: SubStatus::Active,
1529 reason_code: String::new(),
1530 effective_scopes: vec![("id".into(), vec!["one".into()])],
1531 bootstrap: false,
1532 });
1533 if deliver {
1534 assert!(request
1535 .frames
1536 .iter()
1537 .any(|frame| matches!(frame, Frame::PullHeader { .. })));
1538 assert!(!request
1539 .frames
1540 .iter()
1541 .any(|frame| matches!(frame, Frame::PushCommit { .. })));
1542 let columns = ["id", "title"].map(|name| Column {
1543 name: name.into(),
1544 ty: ColumnType::String,
1545 nullable: false,
1546 });
1547 let mut row = ssp2::primitives::Writer::new();
1548 encode_row(
1549 &mut row,
1550 &columns,
1551 &vec![
1552 Some(ColumnValue::String("one".into())),
1553 Some(ColumnValue::String("300/v3".into())),
1554 ],
1555 );
1556 frames.push(Frame::Commit {
1557 commit_seq: 3,
1558 created_at_ms: 0,
1559 actor_id: "actor".into(),
1560 tables: vec!["todo".into()],
1561 changes: vec![Change {
1562 table_index: 0,
1563 row_id: "one".into(),
1564 op: Op::Upsert,
1565 row_version: Some(3),
1566 scopes: vec![("id".into(), "one".into())],
1567 row: Some(row.into_bytes()),
1568 }],
1569 });
1570 server_image_sent.store(true, Ordering::SeqCst);
1571 }
1572 frames.push(Frame::SubEnd {
1573 next_cursor: if deliver { 3 } else { 0 },
1574 bootstrap_state: None,
1575 });
1576 }
1577 let response = ssp2::encode_message(&Message {
1578 wire_version: ssp2::decode::WIRE_VERSION,
1579 msg_kind: MsgKind::Response,
1580 frames,
1581 });
1582 write!(
1583 socket,
1584 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1585 response.len()
1586 )
1587 .unwrap();
1588 socket.write_all(&response).unwrap();
1589 if deliver {
1590 break;
1591 }
1592 }
1593 pushed
1594 });
1595 let (tx, rx) = channel();
1596 let owner_tx = tx.clone();
1597 let owner = std::thread::spawn(move || {
1598 run_owner_thread(
1599 SyncularConfig {
1600 base_url: Some(format!("http://{address}")),
1601 auto_sync: true,
1602 ..Default::default()
1603 },
1604 owner_tx,
1605 rx,
1606 move |event| {
1607 if event["type"] == "change" && event["batch"]["outcomesChanged"] == true {
1608 let _ = ack_tx.send(());
1609 }
1610 if image_sent.load(Ordering::SeqCst) && event["type"] == "change" {
1611 let _ = delivered_tx.send(());
1612 }
1613 },
1614 )
1615 });
1616 let call = |command: Value| {
1617 let (reply, result) = channel();
1618 tx.send(Request::Command { command, reply }).unwrap();
1619 result
1620 };
1621 let created = call(json!({ "method": "create", "params": {
1622 "clientId": "pending-round", "transportEnabled": false, "securityPreflight": true, "schema": { "version": 1, "tables": [{
1623 "name": "todo", "primaryKey": "id", "columns": [
1624 { "name": "id", "type": "string", "nullable": false },
1625 { "name": "title", "type": "string", "nullable": false }
1626 ], "scopes": [{ "pattern": "todo:{id}" }]
1627 }] }
1628 } }));
1629 assert!(created.recv().unwrap().get("error").is_none());
1630 assert!(call(json!({ "method": "activateSecurity", "params": {} }))
1631 .recv()
1632 .unwrap()
1633 .get("error")
1634 .is_none());
1635 assert!(call(json!({ "method": "subscribe", "params": { "id": "own", "table": "todo", "scopes": { "id": ["one"] } } })).recv().unwrap().get("error").is_none());
1636 let first = call(json!({ "method": "mutate", "params": { "mutations": [{
1637 "op": "upsert", "table": "todo", "values": { "id": "one", "title": "100/v1" }
1638 }] } }))
1639 .recv()
1640 .unwrap();
1641 let queued_second = call(json!({ "method": "mutate", "params": { "mutations": [{
1642 "op": "patch", "table": "todo", "values": { "id": "one", "title": "200/v2" }
1643 }] } }))
1644 .recv()
1645 .unwrap();
1646 assert_eq!(
1647 call(json!({ "method": "sync" })).recv().unwrap()["result"]["errorCode"],
1648 "sync.offline"
1649 );
1650 assert_eq!(
1651 call(json!({ "method": "connectRealtime" })).recv().unwrap()["error"]["code"],
1652 "sync.offline"
1653 );
1654 assert_eq!(requests.load(Ordering::SeqCst), 0);
1655 let (reply, result) = channel();
1656 tx.send(Request::SetHeaders {
1657 headers: vec![("authorization".into(), "Bearer fresh".into())],
1658 reply,
1659 })
1660 .unwrap();
1661 assert_eq!(result.recv().unwrap()["result"], Value::Null);
1662 assert!(
1663 call(json!({ "method": "setTransportEnabled", "params": { "enabled": true } }))
1664 .recv()
1665 .unwrap()
1666 .get("error")
1667 .is_none()
1668 );
1669 entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
1670 assert!(
1671 call(json!({ "method": "setTransportEnabled", "params": { "enabled": false } }))
1672 .recv()
1673 .unwrap()
1674 .get("error")
1675 .is_none()
1676 );
1677 let started = Instant::now();
1678 let second = call(json!({ "method": "mutate", "params": { "mutations": [{
1679 "op": "patch", "table": "todo", "values": { "id": "one", "title": "300/v3" }
1680 }] } }));
1681 let local_reply = second.recv_timeout(Duration::from_millis(100));
1682 let elapsed = started.elapsed();
1683 let (query_tx, query_rx) = channel();
1684 tx.send(Request::Query {
1685 sql: "SELECT title FROM todo ORDER BY id".into(),
1686 params: Value::Null,
1687 reply: query_tx,
1688 })
1689 .unwrap();
1690 let local_rows = query_rx.recv_timeout(Duration::from_millis(100));
1691 release_tx.send(()).unwrap();
1694 let second_reply = local_reply
1695 .clone()
1696 .unwrap_or_else(|_| second.recv().unwrap());
1697 let rows = local_rows
1698 .clone()
1699 .unwrap_or_else(|_| query_rx.recv().unwrap());
1700 loop {
1702 ack_rx.recv_timeout(Duration::from_secs(10)).unwrap();
1703 let outcomes = call(json!({ "method": "commitOutcomes" })).recv().unwrap();
1704 if outcomes["result"]["outcomes"]
1705 .as_array()
1706 .unwrap()
1707 .iter()
1708 .filter(|o| o["status"] == "applied")
1709 .count()
1710 == 2
1711 {
1712 break;
1713 }
1714 }
1715 let paused_count = requests.load(Ordering::SeqCst);
1716 assert_eq!(
1717 call(json!({ "method": "sync" })).recv().unwrap()["result"]["errorCode"],
1718 "sync.offline"
1719 );
1720 assert_eq!(requests.load(Ordering::SeqCst), paused_count);
1721 assert!(
1722 call(json!({ "method": "setTransportEnabled", "params": { "enabled": true } }))
1723 .recv()
1724 .unwrap()
1725 .get("error")
1726 .is_none()
1727 );
1728 delivered_rx
1729 .recv_timeout(Duration::from_secs(10))
1730 .expect("ACK schedules the own-image pull without realtime or a manual round");
1731 let (query_tx, query_rx) = channel();
1732 tx.send(Request::Query {
1733 sql: "SELECT title,_sync_version AS version FROM todo WHERE id='one'".into(),
1734 params: Value::Null,
1735 reply: query_tx,
1736 })
1737 .unwrap();
1738 let delivered = query_rx.recv().unwrap();
1739 assert_eq!(delivered["result"]["rows"][0]["title"], "300/v3");
1740 assert_eq!(delivered["result"]["rows"][0]["version"], 3);
1741 let pushed = server.join().unwrap();
1742 tx.send(Request::Shutdown).unwrap();
1743 owner.join().unwrap();
1744 assert!(
1745 local_reply.is_ok(),
1746 "local mutation waited for network reply"
1747 );
1748 assert!(local_rows.is_ok(), "local query waited for network reply");
1749 eprintln!("second local mutation while network pending: {elapsed:?}");
1750 assert_eq!(rows["result"]["rows"].as_array().unwrap().len(), 1);
1751 assert_eq!(
1752 pushed,
1753 vec![
1754 first["result"]["clientCommitId"].as_str().unwrap(),
1755 queued_second["result"]["clientCommitId"].as_str().unwrap(),
1756 second_reply["result"]["clientCommitId"].as_str().unwrap()
1757 ]
1758 );
1759 }
1760
1761 #[test]
1762 fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1763 use std::sync::mpsc::channel;
1764
1765 let path =
1766 std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1767 let config = SyncularConfig {
1768 db_path: Some(path.to_string_lossy().into_owned()),
1769 auto_sync: false,
1770 ..Default::default()
1771 };
1772 let (tx, rx) = channel::<Request>();
1773 let owner_tx = tx.clone();
1774 let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1775
1776 let (create_tx, create_rx) = channel();
1777 tx.send(Request::Command {
1778 command: json!({
1779 "method": "create",
1780 "params": {
1781 "clientId": "sidecar-client",
1782 "schema": { "version": 1, "tables": [] },
1783 "dbPath": path.to_string_lossy()
1784 }
1785 }),
1786 reply: create_tx,
1787 })
1788 .expect("post create");
1789 assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1790
1791 let (read_tx, read_rx) = channel::<ReadRequest>();
1792 let read_path = path.to_string_lossy().into_owned();
1793 let owner_tx = tx.clone();
1794 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
1795
1796 let (entered_tx, entered_rx) = channel();
1799 tx.send(Request::Block {
1800 duration: Duration::from_millis(200),
1801 entered: entered_tx,
1802 })
1803 .expect("block owner");
1804 entered_rx.recv().expect("owner entered blocking round");
1805 let (snapshot_tx, snapshot_rx) = channel();
1806 read_tx
1807 .send(ReadRequest::QuerySnapshot {
1808 sql: "SELECT 1 AS value".to_owned(),
1809 params: Vec::new(),
1810 coverage: Vec::new(),
1811 owner: None,
1812 reply: snapshot_tx,
1813 })
1814 .expect("post snapshot");
1815 let snapshot = snapshot_rx
1816 .recv_timeout(Duration::from_millis(50))
1817 .expect("local snapshot must not wait for the owner");
1818 assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
1819
1820 let (failed_tx, failed_rx) = channel();
1821 read_tx
1822 .send(ReadRequest::QuerySnapshot {
1823 sql: "SELECT private_value FROM missing_private_table".to_owned(),
1824 params: Vec::new(),
1825 coverage: Vec::new(),
1826 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1827 reply: failed_tx,
1828 })
1829 .expect("post failed snapshot");
1830 assert_eq!(
1831 failed_rx.recv().expect("failed snapshot reply")["error"]["code"],
1832 "client.failed"
1833 );
1834 let (diagnostics_tx, diagnostics_rx) = channel();
1835 tx.send(Request::Command {
1836 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1837 reply: diagnostics_tx,
1838 })
1839 .expect("post diagnostics");
1840 assert_eq!(
1841 diagnostics_rx.recv().expect("diagnostics reply")["result"]["queryFailures"][0]["id"],
1842 "queries:missing"
1843 );
1844
1845 let (recovered_tx, recovered_rx) = channel();
1846 read_tx
1847 .send(ReadRequest::QuerySnapshot {
1848 sql: "SELECT 1 AS value".to_owned(),
1849 params: Vec::new(),
1850 coverage: Vec::new(),
1851 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1852 reply: recovered_tx,
1853 })
1854 .expect("post recovered snapshot");
1855 assert!(recovered_rx.recv().expect("recovered snapshot reply")["result"].is_object());
1856 let (cleared_tx, cleared_rx) = channel();
1857 tx.send(Request::Command {
1858 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1859 reply: cleared_tx,
1860 })
1861 .expect("post cleared diagnostics");
1862 assert_eq!(
1863 cleared_rx.recv().expect("cleared diagnostics reply")["result"]["queryFailures"],
1864 json!([])
1865 );
1866
1867 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
1868 reader.join().expect("join reader");
1869 tx.send(Request::Shutdown).expect("stop owner");
1870 owner.join().expect("join owner");
1871 std::fs::remove_file(path).expect("remove temp database");
1872 }
1873}