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 (reply_tx, reply_rx) = std::sync::mpsc::channel();
539 state.send(Request::SetHeaders {
540 headers: headers.into_iter().collect(),
541 reply: reply_tx,
542 })?;
543 reply_rx
544 .recv()
545 .map_err(|_| "the syncular core dropped the reply".to_owned())
546}
547
548#[tauri::command]
549async fn syncular_query<R: Runtime>(
550 app: tauri::AppHandle<R>,
551 sql: String,
552 params: Option<Value>,
553) -> Result<Value, String> {
554 let state = app.state::<SyncularState>();
555 let _read_guard = match state.security_gate.enter_read() {
556 Ok(guard) => guard,
557 Err(reply) => return Ok(reply),
558 };
559 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
560 state.send(Request::Query {
561 sql,
562 params: params.unwrap_or(Value::Null),
563 reply: reply_tx,
564 })?;
565 reply_rx
566 .recv()
567 .map_err(|_| "the syncular core dropped the reply".to_owned())
568}
569
570#[tauri::command]
574async fn syncular_query_snapshot<R: Runtime>(
575 app: tauri::AppHandle<R>,
576 sql: String,
577 params: Option<Value>,
578 coverage: Option<Value>,
579) -> Result<Value, String> {
580 let state = app.state::<SyncularState>();
581 let _read_guard = match state.security_gate.enter_read() {
582 Ok(guard) => guard,
583 Err(reply) => return Ok(reply),
584 };
585 let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
586 let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
587
588 if state.reader.is_some() {
589 let bind = match params_value.as_array() {
590 Some(values) => values.clone(),
591 None => return Ok(client_error("querySnapshot params must be a list")),
592 };
593 let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
594 Ok(value) => value,
595 Err(message) => return Ok(client_error(message)),
596 };
597 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
598 state.send_read(ReadRequest::QuerySnapshot {
599 sql,
600 params: bind,
601 coverage: parsed_coverage,
602 reply: reply_tx,
603 })?;
604 return reply_rx
605 .recv()
606 .map_err(|_| "the syncular read thread dropped the reply".to_owned());
607 }
608
609 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
610 state.send(Request::Command {
611 command: json!({
612 "method": "querySnapshot",
613 "params": { "sql": sql, "params": params_value, "coverage": coverage_value }
614 }),
615 reply: reply_tx,
616 })?;
617 reply_rx
618 .recv()
619 .map_err(|_| "the syncular core dropped the reply".to_owned())
620}
621
622pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
630 Builder::<R>::new("syncular")
631 .invoke_handler(tauri::generate_handler![
632 syncular_command,
633 syncular_query,
634 syncular_query_snapshot,
635 syncular_set_headers
636 ])
637 .setup(move |app, _api| {
638 let (tx, rx) = std::sync::mpsc::channel::<Request>();
639 let reader = match config
640 .db_path
641 .as_ref()
642 .filter(|path| path.as_str() != ":memory:")
643 {
644 Some(path) => {
645 let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
646 let path = path.clone();
647 std::thread::Builder::new()
648 .name("syncular-read".to_owned())
649 .spawn(move || run_reader_thread(path, reader_rx))
650 .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
651 Some(Mutex::new(reader_tx))
652 }
653 None => None,
654 };
655 app.manage(SyncularState {
656 sender: Mutex::new(tx.clone()),
657 reader,
658 security_gate: SecurityGate::new_preflight(),
661 });
662 let app_handle = app.clone();
663 let emit = move |value: &Value| {
664 let _ = app_handle.emit(EVENT_NAME, value.clone());
667 };
668 std::thread::Builder::new()
669 .name("syncular-core".to_owned())
670 .spawn(move || run_owner_thread(config, tx, rx, emit))
671 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
672 Ok(())
673 })
674 .on_event(|app, event| {
675 if let RunEvent::Exit = event {
676 if let Some(state) = app.try_state::<SyncularState>() {
677 let _ = state.send(Request::Shutdown);
678 if state.reader.is_some() {
679 let _ = state.send_read(ReadRequest::Shutdown);
680 }
681 }
682 }
683 })
684 .build()
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn config_to_transport_json_shapes_fields() {
693 let config = SyncularConfig {
694 base_url: Some("https://api.example.com".to_owned()),
695 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
696 ..Default::default()
697 };
698 let json = config.to_transport_json();
699 assert_eq!(json["baseUrl"], "https://api.example.com");
700 assert_eq!(json["headers"]["authorization"], "Bearer x");
701 }
702
703 #[test]
704 fn security_gate_blocks_new_reads_and_waits_for_in_flight_snapshots() {
705 let gate = std::sync::Arc::new(SecurityGate::new_preflight());
706 gate.activate().expect("activate test gate");
707 let read = gate.enter_read().expect("active read");
708 let gate_for_barrier = std::sync::Arc::clone(&gate);
709 let (done_tx, done_rx) = std::sync::mpsc::channel();
710 let barrier = std::thread::spawn(move || {
711 gate_for_barrier.begin_preflight().expect("enter preflight");
712 done_tx.send(()).expect("barrier reply");
713 });
714
715 assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
716 drop(read);
717 done_rx
718 .recv_timeout(Duration::from_secs(1))
719 .expect("barrier drains after the read");
720 barrier.join().expect("barrier thread");
721 assert!(gate.enter_read().is_err());
722 }
723
724 #[test]
725 fn inject_db_path_adds_to_create_only() {
726 let config = SyncularConfig {
727 db_path: Some("/tmp/app.db".to_owned()),
728 ..Default::default()
729 };
730 let created = inject_db_path(
732 json!({ "method": "create", "params": { "clientId": "c1" } }),
733 &config,
734 );
735 assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
736 let created2 = inject_db_path(json!({ "method": "create" }), &config);
738 assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
739 let explicit = inject_db_path(
741 json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
742 &config,
743 );
744 assert_eq!(explicit["params"]["dbPath"], "/other.db");
745 let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
747 assert!(mutate["params"].get("dbPath").is_none());
748 }
749
750 #[test]
751 fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
752 let parsed = parse_coverage(Some(&json!([{
753 "base": {
754 "table": "tasks",
755 "variable": "project_id",
756 "fixedScopes": { "tenant_id": ["one", "two"] },
757 "params": "opaque"
758 },
759 "units": ["a", "b"]
760 }])))
761 .expect("parse coverage");
762 assert_eq!(parsed.len(), 1);
763 let entry = &parsed[0];
764 assert_eq!(entry.base.table, "tasks");
765 assert_eq!(entry.base.variable, "project_id");
766 assert_eq!(
767 entry.base.fixed_scopes,
768 vec![(
769 "tenant_id".to_owned(),
770 vec!["one".to_owned(), "two".to_owned()]
771 )]
772 );
773 assert_eq!(entry.base.params.as_deref(), Some("opaque"));
774 assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
775 }
776
777 #[test]
781 fn owner_thread_round_trips_over_mailbox() {
782 use std::sync::mpsc::channel;
783 use std::sync::{Arc, Mutex as StdMutex};
784
785 let (tx, rx) = channel::<Request>();
786 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
787 let events_for_thread = Arc::clone(&events);
788 let config = SyncularConfig {
789 auto_sync: false,
790 ..Default::default()
791 };
792 let owner_tx = tx.clone();
793 let handle = std::thread::spawn(move || {
794 run_owner_thread(config, owner_tx, rx, move |v| {
795 events_for_thread.lock().unwrap().push(v.clone());
796 });
797 });
798
799 let call = |command: Value| -> Value {
800 let (rtx, rrx) = channel();
801 tx.send(Request::Command {
802 command,
803 reply: rtx,
804 })
805 .unwrap();
806 rrx.recv().unwrap()
807 };
808
809 let schema = json!({
810 "version": 1,
811 "tables": [{
812 "name": "todo", "primaryKey": "id",
813 "columns": [
814 { "name": "id", "type": "string", "nullable": false },
815 { "name": "title", "type": "string", "nullable": false }
816 ],
817 "scopes": []
818 }]
819 });
820 assert_eq!(
821 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
822 ["result"],
823 json!({})
824 );
825 call(json!({ "method": "mutate", "params": { "mutations": [{
826 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
827 }] } }));
828
829 let (qtx, qrx) = channel();
831 tx.send(Request::Query {
832 sql: "SELECT title FROM todo".to_owned(),
833 params: Value::Null,
834 reply: qtx,
835 })
836 .unwrap();
837 let rows = qrx.recv().unwrap();
838 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
839
840 let (htx, hrx) = channel();
843 tx.send(Request::SetHeaders {
844 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
845 reply: htx,
846 })
847 .unwrap();
848 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
849
850 tx.send(Request::Shutdown).unwrap();
851 handle.join().unwrap();
852
853 let seen = events.lock().unwrap();
854 let kinds: Vec<String> = seen
855 .iter()
856 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
857 .collect();
858 assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
860 }
861
862 #[test]
863 fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
864 use std::sync::mpsc::channel;
865
866 let path =
867 std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
868 let config = SyncularConfig {
869 db_path: Some(path.to_string_lossy().into_owned()),
870 auto_sync: false,
871 ..Default::default()
872 };
873 let (tx, rx) = channel::<Request>();
874 let owner_tx = tx.clone();
875 let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
876
877 let (create_tx, create_rx) = channel();
878 tx.send(Request::Command {
879 command: json!({
880 "method": "create",
881 "params": {
882 "clientId": "sidecar-client",
883 "schema": { "version": 1, "tables": [] },
884 "dbPath": path.to_string_lossy()
885 }
886 }),
887 reply: create_tx,
888 })
889 .expect("post create");
890 assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
891
892 let (read_tx, read_rx) = channel::<ReadRequest>();
893 let read_path = path.to_string_lossy().into_owned();
894 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx));
895
896 let (entered_tx, entered_rx) = channel();
899 tx.send(Request::Block {
900 duration: Duration::from_millis(200),
901 entered: entered_tx,
902 })
903 .expect("block owner");
904 entered_rx.recv().expect("owner entered blocking round");
905 let (snapshot_tx, snapshot_rx) = channel();
906 read_tx
907 .send(ReadRequest::QuerySnapshot {
908 sql: "SELECT 1 AS value".to_owned(),
909 params: Vec::new(),
910 coverage: Vec::new(),
911 reply: snapshot_tx,
912 })
913 .expect("post snapshot");
914 let snapshot = snapshot_rx
915 .recv_timeout(Duration::from_millis(50))
916 .expect("local snapshot must not wait for the owner");
917 assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
918
919 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
920 reader.join().expect("join reader");
921 tx.send(Request::Shutdown).expect("stop owner");
922 owner.join().expect("join owner");
923 std::fs::remove_file(path).expect("remove temp database");
924 }
925}