1use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
38use std::sync::{Condvar, Mutex};
39use std::time::{Duration, Instant};
40
41use serde_json::{json, Value};
42use tauri::plugin::{Builder, TauriPlugin};
43use tauri::{Emitter, Manager, RunEvent, Runtime};
44
45pub mod core;
46pub mod transport;
47
48use core::SyncularCore;
49use syncular_client::{FileQuerySnapshotReader, WindowBase, WindowCoverage};
50
51pub const EVENT_NAME: &str = "syncular://event";
53
54#[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>,
69 pub auto_sync: bool,
71}
72
73impl Default for SyncularConfig {
74 fn default() -> Self {
75 Self {
76 base_url: None,
77 ws_url: None,
78 headers: Vec::new(),
79 db_path: None,
80 auto_sync: true,
81 }
82 }
83}
84
85impl SyncularConfig {
86 fn to_transport_json(&self) -> Value {
88 let mut map = serde_json::Map::new();
89 if let Some(base) = &self.base_url {
90 map.insert("baseUrl".to_owned(), Value::from(base.clone()));
91 }
92 if let Some(ws) = &self.ws_url {
93 map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
94 }
95 if !self.headers.is_empty() {
96 let headers: serde_json::Map<String, Value> = self
97 .headers
98 .iter()
99 .map(|(k, v)| (k.clone(), Value::from(v.clone())))
100 .collect();
101 map.insert("headers".to_owned(), Value::Object(headers));
102 }
103 Value::Object(map)
104 }
105}
106
107enum Request {
110 Command {
111 command: Value,
112 reply: Sender<Value>,
113 },
114 Query {
115 sql: String,
116 params: Value,
117 reply: Sender<Value>,
118 },
119 SetHeaders {
123 headers: Vec<(String, String)>,
124 reply: Sender<Value>,
125 },
126 TransportWake,
128 #[cfg(test)]
129 Block {
130 duration: Duration,
131 entered: Sender<()>,
132 },
133 Shutdown,
134}
135
136enum ReadRequest {
140 QuerySnapshot {
141 sql: String,
142 params: Vec<Value>,
143 coverage: Vec<WindowCoverage>,
144 reply: Sender<Value>,
145 },
146 Shutdown,
147}
148
149struct SyncularState {
152 sender: Mutex<Sender<Request>>,
153 reader: Option<Mutex<Sender<ReadRequest>>>,
154 security_gate: SecurityGate,
155}
156
157struct SecurityGateState {
158 preflight: bool,
159 active_reads: usize,
160}
161
162struct SecurityGate {
163 state: Mutex<SecurityGateState>,
164 idle: Condvar,
165}
166
167struct SecurityReadGuard<'a> {
168 gate: &'a SecurityGate,
169}
170
171impl SecurityGate {
172 fn new_preflight() -> Self {
173 Self {
174 state: Mutex::new(SecurityGateState {
175 preflight: true,
176 active_reads: 0,
177 }),
178 idle: Condvar::new(),
179 }
180 }
181
182 fn begin_preflight(&self) -> Result<(), String> {
183 let mut state = self
184 .state
185 .lock()
186 .map_err(|_| "syncular security gate poisoned".to_owned())?;
187 state.preflight = true;
188 while state.active_reads > 0 {
189 state = self
190 .idle
191 .wait(state)
192 .map_err(|_| "syncular security gate poisoned".to_owned())?;
193 }
194 Ok(())
195 }
196
197 fn activate(&self) -> Result<(), String> {
198 let mut state = self
199 .state
200 .lock()
201 .map_err(|_| "syncular security gate poisoned".to_owned())?;
202 state.preflight = false;
203 Ok(())
204 }
205
206 fn enter_read(&self) -> Result<SecurityReadGuard<'_>, Value> {
207 let mut state = self
208 .state
209 .lock()
210 .map_err(|_| client_error("syncular security gate poisoned"))?;
211 if state.preflight {
212 return Err(security_preflight_error());
213 }
214 state.active_reads += 1;
215 Ok(SecurityReadGuard { gate: self })
216 }
217}
218
219impl Drop for SecurityReadGuard<'_> {
220 fn drop(&mut self) {
221 let Ok(mut state) = self.gate.state.lock() else {
222 return;
223 };
224 state.active_reads = state.active_reads.saturating_sub(1);
225 if state.active_reads == 0 {
226 self.gate.idle.notify_all();
227 }
228 }
229}
230
231impl SyncularState {
232 fn send(&self, request: Request) -> Result<(), String> {
233 self.sender
234 .lock()
235 .map_err(|_| "syncular mailbox poisoned".to_owned())?
236 .send(request)
237 .map_err(|_| "the syncular core thread has stopped".to_owned())
238 }
239
240 fn send_read(&self, request: ReadRequest) -> Result<(), String> {
241 let Some(reader) = &self.reader else {
242 return Err("this syncular client has no file snapshot reader".to_owned());
243 };
244 reader
245 .lock()
246 .map_err(|_| "syncular read mailbox poisoned".to_owned())?
247 .send(request)
248 .map_err(|_| "the syncular read thread has stopped".to_owned())?;
249 Ok(())
250 }
251}
252
253fn run_reader_thread(path: String, rx: Receiver<ReadRequest>) {
254 let mut reader = FileQuerySnapshotReader::new(path);
255 while let Ok(request) = rx.recv() {
256 match request {
257 ReadRequest::QuerySnapshot {
258 sql,
259 params,
260 coverage,
261 reply,
262 } => {
263 let value = match reader.query_snapshot(&sql, ¶ms, &coverage) {
264 Ok(snapshot) => json!({ "result": snapshot }),
265 Err(message) => json!({
266 "error": { "code": "client.failed", "message": message }
267 }),
268 };
269 let _ = reply.send(value);
270 }
271 ReadRequest::Shutdown => return,
272 }
273 }
274}
275
276fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
279where
280 F: Fn(&Value) + Send + 'static,
281{
282 let transport_json = config.to_transport_json();
283 let wake_tx = tx.clone();
284 let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
285 let _ = wake_tx.send(Request::TransportWake);
286 });
287 let mut core = match SyncularCore::new_with_notify(&transport_json, Some(notify)) {
288 Ok(core) => core,
289 Err(message) => {
290 emit(&json!({ "type": "error", "message": message }));
293 return;
294 }
295 };
296
297 let mut background_deadline: Option<Instant> = None;
300 loop {
301 if config.auto_sync {
302 match core.take_sync_intent() {
303 syncular_client::SyncIntent::Interactive => {
304 background_deadline = None;
305 core.sync_until_idle();
306 pump_events(&mut core, &emit);
307 continue;
308 }
309 syncular_client::SyncIntent::Background { delay_ms } => {
310 let candidate = Instant::now()
311 .checked_add(Duration::from_millis(delay_ms))
312 .unwrap_or_else(Instant::now);
313 background_deadline = Some(
314 background_deadline.map_or(candidate, |current| current.min(candidate)),
315 );
316 }
317 syncular_client::SyncIntent::None => {}
318 }
319 }
320
321 let request = if let Some(deadline) = background_deadline {
322 let now = Instant::now();
323 if deadline <= now {
324 background_deadline = None;
325 core.sync_until_idle();
326 pump_events(&mut core, &emit);
327 continue;
328 }
329 match rx.recv_timeout(deadline.saturating_duration_since(now)) {
330 Ok(request) => request,
331 Err(RecvTimeoutError::Timeout) => {
332 background_deadline = None;
333 core.sync_until_idle();
334 pump_events(&mut core, &emit);
335 continue;
336 }
337 Err(RecvTimeoutError::Disconnected) => {
338 core.shutdown();
339 return;
340 }
341 }
342 } else {
343 match rx.recv() {
344 Ok(request) => request,
345 Err(std::sync::mpsc::RecvError) => {
346 core.shutdown();
347 return;
348 }
349 }
350 };
351
352 match request {
353 Request::Command { command, reply } => {
354 let command = inject_db_path(command, &config);
355 let result = core.command(&command);
356 let _ = reply.send(result);
357 pump_events(&mut core, &emit);
358 }
359 Request::Query { sql, params, reply } => {
360 let result = core.query(&sql, params);
361 let _ = reply.send(result);
362 pump_events(&mut core, &emit);
363 }
364 Request::SetHeaders { headers, reply } => {
365 core.set_headers(headers);
366 let _ = reply.send(json!({ "result": null }));
367 }
368 Request::TransportWake => {
369 core.poll_transport();
370 pump_events(&mut core, &emit);
371 }
372 #[cfg(test)]
373 Request::Block { duration, entered } => {
374 let _ = entered.send(());
375 std::thread::sleep(duration);
376 }
377 Request::Shutdown => {
378 core.shutdown();
379 return;
380 }
381 }
382 }
383}
384
385fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
389 if command.get("method").and_then(Value::as_str) != Some("create") {
390 return command;
391 }
392 let Some(db_path) = &config.db_path else {
393 return command;
394 };
395 let params = command.get_mut("params").and_then(Value::as_object_mut);
396 if let Some(params) = params {
397 params
398 .entry("dbPath")
399 .or_insert_with(|| Value::from(db_path.clone()));
400 } else if let Some(obj) = command.as_object_mut() {
401 obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
402 }
403 command
404}
405
406fn client_error(message: impl Into<String>) -> Value {
407 json!({ "error": { "code": "client.failed", "message": message.into() } })
408}
409
410fn security_preflight_error() -> Value {
411 json!({
412 "error": {
413 "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
414 "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
415 }
416 })
417}
418
419fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
420 let object = value
421 .and_then(Value::as_object)
422 .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
423 let table = object
424 .get("table")
425 .and_then(Value::as_str)
426 .ok_or_else(|| "window base missing table".to_owned())?
427 .to_owned();
428 let variable = object
429 .get("variable")
430 .and_then(Value::as_str)
431 .ok_or_else(|| "window base missing variable".to_owned())?
432 .to_owned();
433 let fixed_scopes = match object.get("fixedScopes") {
434 Some(value) => syncular_client::values::json_to_scope_map(value)?,
435 None => Vec::new(),
436 };
437 let params = object
438 .get("params")
439 .and_then(Value::as_str)
440 .map(str::to_owned);
441 Ok(WindowBase {
442 table,
443 variable,
444 fixed_scopes,
445 params,
446 })
447}
448
449fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
450 let Some(value) = value else {
451 return Ok(Vec::new());
452 };
453 if value.is_null() {
454 return Ok(Vec::new());
455 }
456 let entries = value
457 .as_array()
458 .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
459 entries
460 .iter()
461 .map(|entry| {
462 let units = entry
463 .get("units")
464 .and_then(Value::as_array)
465 .map(|values| {
466 values
467 .iter()
468 .filter_map(|value| value.as_str().map(str::to_owned))
469 .collect()
470 })
471 .unwrap_or_default();
472 Ok(WindowCoverage {
473 base: parse_window_base(entry.get("base"))?,
474 units,
475 })
476 })
477 .collect()
478}
479
480fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
481 for event in core.drain_events() {
482 emit(&event.json);
483 }
484}
485
486#[tauri::command]
489async fn syncular_command<R: Runtime>(
490 app: tauri::AppHandle<R>,
491 command: Value,
492) -> Result<Value, String> {
493 let state = app.state::<SyncularState>();
494 let method = command
495 .get("method")
496 .and_then(Value::as_str)
497 .unwrap_or("")
498 .to_owned();
499 if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
500 state.security_gate.begin_preflight()?;
503 }
504 let create_preflight = method == "create"
505 && command
506 .pointer("/params/securityPreflight")
507 .and_then(Value::as_bool)
508 .unwrap_or(false);
509 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
510 state.send(Request::Command {
511 command,
512 reply: reply_tx,
513 })?;
514 let reply = reply_rx
515 .recv()
516 .map_err(|_| "the syncular core dropped the reply".to_owned())?;
517 let succeeded = reply.get("error").is_none();
518 if succeeded && method == "create" {
519 if !create_preflight {
520 state.security_gate.activate()?;
521 }
522 } else if succeeded && method == "activateSecurity" {
523 state.security_gate.activate()?;
524 }
525 Ok(reply)
526}
527
528#[tauri::command]
533async fn syncular_set_headers<R: Runtime>(
534 app: tauri::AppHandle<R>,
535 headers: std::collections::BTreeMap<String, String>,
536) -> Result<Value, String> {
537 let state = app.state::<SyncularState>();
538 let _active_guard = match state.security_gate.enter_read() {
542 Ok(guard) => guard,
543 Err(reply) => return Ok(reply),
544 };
545 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
546 state.send(Request::SetHeaders {
547 headers: headers.into_iter().collect(),
548 reply: reply_tx,
549 })?;
550 reply_rx
551 .recv()
552 .map_err(|_| "the syncular core dropped the reply".to_owned())
553}
554
555#[tauri::command]
556async fn syncular_query<R: Runtime>(
557 app: tauri::AppHandle<R>,
558 sql: String,
559 params: Option<Value>,
560) -> Result<Value, String> {
561 let state = app.state::<SyncularState>();
562 let _read_guard = match state.security_gate.enter_read() {
563 Ok(guard) => guard,
564 Err(reply) => return Ok(reply),
565 };
566 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
567 state.send(Request::Query {
568 sql,
569 params: params.unwrap_or(Value::Null),
570 reply: reply_tx,
571 })?;
572 reply_rx
573 .recv()
574 .map_err(|_| "the syncular core dropped the reply".to_owned())
575}
576
577#[tauri::command]
581async fn syncular_query_snapshot<R: Runtime>(
582 app: tauri::AppHandle<R>,
583 sql: String,
584 params: Option<Value>,
585 coverage: Option<Value>,
586) -> Result<Value, String> {
587 let state = app.state::<SyncularState>();
588 let _read_guard = match state.security_gate.enter_read() {
589 Ok(guard) => guard,
590 Err(reply) => return Ok(reply),
591 };
592 let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
593 let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
594
595 if state.reader.is_some() {
596 let bind = match params_value.as_array() {
597 Some(values) => values.clone(),
598 None => return Ok(client_error("querySnapshot params must be a list")),
599 };
600 let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
601 Ok(value) => value,
602 Err(message) => return Ok(client_error(message)),
603 };
604 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
605 state.send_read(ReadRequest::QuerySnapshot {
606 sql,
607 params: bind,
608 coverage: parsed_coverage,
609 reply: reply_tx,
610 })?;
611 return reply_rx
612 .recv()
613 .map_err(|_| "the syncular read thread dropped the reply".to_owned());
614 }
615
616 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
617 state.send(Request::Command {
618 command: json!({
619 "method": "querySnapshot",
620 "params": { "sql": sql, "params": params_value, "coverage": coverage_value }
621 }),
622 reply: reply_tx,
623 })?;
624 reply_rx
625 .recv()
626 .map_err(|_| "the syncular core dropped the reply".to_owned())
627}
628
629pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
637 Builder::<R>::new("syncular")
638 .invoke_handler(tauri::generate_handler![
639 syncular_command,
640 syncular_query,
641 syncular_query_snapshot,
642 syncular_set_headers
643 ])
644 .setup(move |app, _api| {
645 let (tx, rx) = std::sync::mpsc::channel::<Request>();
646 let reader = match config
647 .db_path
648 .as_ref()
649 .filter(|path| path.as_str() != ":memory:")
650 {
651 Some(path) => {
652 let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
653 let path = path.clone();
654 std::thread::Builder::new()
655 .name("syncular-read".to_owned())
656 .spawn(move || run_reader_thread(path, reader_rx))
657 .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
658 Some(Mutex::new(reader_tx))
659 }
660 None => None,
661 };
662 app.manage(SyncularState {
663 sender: Mutex::new(tx.clone()),
664 reader,
665 security_gate: SecurityGate::new_preflight(),
668 });
669 let app_handle = app.clone();
670 let emit = move |value: &Value| {
671 let _ = app_handle.emit(EVENT_NAME, value.clone());
674 };
675 std::thread::Builder::new()
676 .name("syncular-core".to_owned())
677 .spawn(move || run_owner_thread(config, tx, rx, emit))
678 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
679 Ok(())
680 })
681 .on_event(|app, event| {
682 if let RunEvent::Exit = event {
683 if let Some(state) = app.try_state::<SyncularState>() {
684 let _ = state.send(Request::Shutdown);
685 if state.reader.is_some() {
686 let _ = state.send_read(ReadRequest::Shutdown);
687 }
688 }
689 }
690 })
691 .build()
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697
698 #[test]
699 fn config_to_transport_json_shapes_fields() {
700 let config = SyncularConfig {
701 base_url: Some("https://api.example.com".to_owned()),
702 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
703 ..Default::default()
704 };
705 let json = config.to_transport_json();
706 assert_eq!(json["baseUrl"], "https://api.example.com");
707 assert_eq!(json["headers"]["authorization"], "Bearer x");
708 }
709
710 #[test]
711 fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
712 let gate = std::sync::Arc::new(SecurityGate::new_preflight());
713 gate.activate().expect("activate test gate");
714 let read = gate.enter_read().expect("active read");
715 let gate_for_barrier = std::sync::Arc::clone(&gate);
716 let (done_tx, done_rx) = std::sync::mpsc::channel();
717 let barrier = std::thread::spawn(move || {
718 gate_for_barrier.begin_preflight().expect("enter preflight");
719 done_tx.send(()).expect("barrier reply");
720 });
721
722 assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
723 drop(read);
724 done_rx
725 .recv_timeout(Duration::from_secs(1))
726 .expect("barrier drains after the read");
727 barrier.join().expect("barrier thread");
728 assert!(gate.enter_read().is_err());
729 }
730
731 #[test]
732 fn direct_set_headers_command_respects_the_native_preflight_gate() {
733 use std::collections::BTreeMap;
734 use tauri::test::{mock_builder, mock_context, noop_assets};
735
736 let app = mock_builder()
737 .plugin(init(SyncularConfig {
738 auto_sync: false,
739 ..Default::default()
740 }))
741 .build(mock_context(noop_assets()))
742 .expect("build mock app");
743
744 let blocked = tauri::async_runtime::block_on(syncular_set_headers(
745 app.handle().clone(),
746 BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
747 ))
748 .expect("preflight reply");
749 assert_eq!(
750 blocked["error"]["code"],
751 Value::from("client.security_preflight_required")
752 );
753
754 let created = tauri::async_runtime::block_on(syncular_command(
755 app.handle().clone(),
756 json!({
757 "method": "create",
758 "params": {
759 "clientId": "native-header-gate",
760 "schema": { "version": 1, "tables": [] }
761 }
762 }),
763 ))
764 .expect("create reply");
765 assert!(created.get("error").is_none(), "{created}");
766
767 let active = tauri::async_runtime::block_on(syncular_set_headers(
768 app.handle().clone(),
769 BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
770 ))
771 .expect("active reply");
772 assert_eq!(active["result"], Value::Null);
773 }
774
775 #[test]
776 fn inject_db_path_adds_to_create_only() {
777 let config = SyncularConfig {
778 db_path: Some("/tmp/app.db".to_owned()),
779 ..Default::default()
780 };
781 let created = inject_db_path(
783 json!({ "method": "create", "params": { "clientId": "c1" } }),
784 &config,
785 );
786 assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
787 let created2 = inject_db_path(json!({ "method": "create" }), &config);
789 assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
790 let explicit = inject_db_path(
792 json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
793 &config,
794 );
795 assert_eq!(explicit["params"]["dbPath"], "/other.db");
796 let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
798 assert!(mutate["params"].get("dbPath").is_none());
799 }
800
801 #[test]
802 fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
803 let parsed = parse_coverage(Some(&json!([{
804 "base": {
805 "table": "tasks",
806 "variable": "project_id",
807 "fixedScopes": { "tenant_id": ["one", "two"] },
808 "params": "opaque"
809 },
810 "units": ["a", "b"]
811 }])))
812 .expect("parse coverage");
813 assert_eq!(parsed.len(), 1);
814 let entry = &parsed[0];
815 assert_eq!(entry.base.table, "tasks");
816 assert_eq!(entry.base.variable, "project_id");
817 assert_eq!(
818 entry.base.fixed_scopes,
819 vec![(
820 "tenant_id".to_owned(),
821 vec!["one".to_owned(), "two".to_owned()]
822 )]
823 );
824 assert_eq!(entry.base.params.as_deref(), Some("opaque"));
825 assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
826 }
827
828 #[test]
832 fn owner_thread_round_trips_over_mailbox() {
833 use std::sync::mpsc::channel;
834 use std::sync::{Arc, Mutex as StdMutex};
835
836 let (tx, rx) = channel::<Request>();
837 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
838 let events_for_thread = Arc::clone(&events);
839 let config = SyncularConfig {
840 auto_sync: false,
841 ..Default::default()
842 };
843 let owner_tx = tx.clone();
844 let handle = std::thread::spawn(move || {
845 run_owner_thread(config, owner_tx, rx, move |v| {
846 events_for_thread.lock().unwrap().push(v.clone());
847 });
848 });
849
850 let call = |command: Value| -> Value {
851 let (rtx, rrx) = channel();
852 tx.send(Request::Command {
853 command,
854 reply: rtx,
855 })
856 .unwrap();
857 rrx.recv().unwrap()
858 };
859
860 let schema = json!({
861 "version": 1,
862 "tables": [{
863 "name": "todo", "primaryKey": "id",
864 "columns": [
865 { "name": "id", "type": "string", "nullable": false },
866 { "name": "title", "type": "string", "nullable": false }
867 ],
868 "scopes": []
869 }]
870 });
871 assert_eq!(
872 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
873 ["result"],
874 json!({})
875 );
876 call(json!({ "method": "mutate", "params": { "mutations": [{
877 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
878 }] } }));
879
880 let (qtx, qrx) = channel();
882 tx.send(Request::Query {
883 sql: "SELECT title FROM todo".to_owned(),
884 params: Value::Null,
885 reply: qtx,
886 })
887 .unwrap();
888 let rows = qrx.recv().unwrap();
889 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
890
891 let (htx, hrx) = channel();
894 tx.send(Request::SetHeaders {
895 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
896 reply: htx,
897 })
898 .unwrap();
899 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
900
901 tx.send(Request::Shutdown).unwrap();
902 handle.join().unwrap();
903
904 let seen = events.lock().unwrap();
905 let kinds: Vec<String> = seen
906 .iter()
907 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
908 .collect();
909 assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
911 }
912
913 #[test]
914 fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
915 use std::sync::mpsc::channel;
916
917 let path =
918 std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
919 let config = SyncularConfig {
920 db_path: Some(path.to_string_lossy().into_owned()),
921 auto_sync: false,
922 ..Default::default()
923 };
924 let (tx, rx) = channel::<Request>();
925 let owner_tx = tx.clone();
926 let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
927
928 let (create_tx, create_rx) = channel();
929 tx.send(Request::Command {
930 command: json!({
931 "method": "create",
932 "params": {
933 "clientId": "sidecar-client",
934 "schema": { "version": 1, "tables": [] },
935 "dbPath": path.to_string_lossy()
936 }
937 }),
938 reply: create_tx,
939 })
940 .expect("post create");
941 assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
942
943 let (read_tx, read_rx) = channel::<ReadRequest>();
944 let read_path = path.to_string_lossy().into_owned();
945 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx));
946
947 let (entered_tx, entered_rx) = channel();
950 tx.send(Request::Block {
951 duration: Duration::from_millis(200),
952 entered: entered_tx,
953 })
954 .expect("block owner");
955 entered_rx.recv().expect("owner entered blocking round");
956 let (snapshot_tx, snapshot_rx) = channel();
957 read_tx
958 .send(ReadRequest::QuerySnapshot {
959 sql: "SELECT 1 AS value".to_owned(),
960 params: Vec::new(),
961 coverage: Vec::new(),
962 reply: snapshot_tx,
963 })
964 .expect("post snapshot");
965 let snapshot = snapshot_rx
966 .recv_timeout(Duration::from_millis(50))
967 .expect("local snapshot must not wait for the owner");
968 assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
969
970 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
971 reader.join().expect("join reader");
972 tx.send(Request::Shutdown).expect("stop owner");
973 owner.join().expect("join owner");
974 std::fs::remove_file(path).expect("remove temp database");
975 }
976}