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 if result.get("error").is_none() {
357 if let Some(headers) = activation_headers(&command) {
358 core.set_headers(headers);
364 }
365 }
366 let _ = reply.send(result);
367 pump_events(&mut core, &emit);
368 }
369 Request::Query { sql, params, reply } => {
370 let result = core.query(&sql, params);
371 let _ = reply.send(result);
372 pump_events(&mut core, &emit);
373 }
374 Request::SetHeaders { headers, reply } => {
375 core.set_headers(headers);
376 let _ = reply.send(json!({ "result": null }));
377 }
378 Request::TransportWake => {
379 core.poll_transport();
380 pump_events(&mut core, &emit);
381 }
382 #[cfg(test)]
383 Request::Block { duration, entered } => {
384 let _ = entered.send(());
385 std::thread::sleep(duration);
386 }
387 Request::Shutdown => {
388 core.shutdown();
389 return;
390 }
391 }
392 }
393}
394
395fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
399 if command.get("method").and_then(Value::as_str) != Some("create") {
400 return command;
401 }
402 let Some(db_path) = &config.db_path else {
403 return command;
404 };
405 let params = command.get_mut("params").and_then(Value::as_object_mut);
406 if let Some(params) = params {
407 params
408 .entry("dbPath")
409 .or_insert_with(|| Value::from(db_path.clone()));
410 } else if let Some(obj) = command.as_object_mut() {
411 obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
412 }
413 command
414}
415
416fn activation_headers(command: &Value) -> Option<Vec<(String, String)>> {
420 if command.get("method").and_then(Value::as_str) != Some("activateSecurity") {
421 return None;
422 }
423 let headers = command.pointer("/params/headers")?;
424 syncular_command::parse_headers(headers).ok()
425}
426
427fn client_error(message: impl Into<String>) -> Value {
428 json!({ "error": { "code": "client.failed", "message": message.into() } })
429}
430
431fn security_preflight_error() -> Value {
432 json!({
433 "error": {
434 "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
435 "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
436 }
437 })
438}
439
440fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
441 let object = value
442 .and_then(Value::as_object)
443 .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
444 let table = object
445 .get("table")
446 .and_then(Value::as_str)
447 .ok_or_else(|| "window base missing table".to_owned())?
448 .to_owned();
449 let variable = object
450 .get("variable")
451 .and_then(Value::as_str)
452 .ok_or_else(|| "window base missing variable".to_owned())?
453 .to_owned();
454 let fixed_scopes = match object.get("fixedScopes") {
455 Some(value) => syncular_client::values::json_to_scope_map(value)?,
456 None => Vec::new(),
457 };
458 let params = object
459 .get("params")
460 .and_then(Value::as_str)
461 .map(str::to_owned);
462 Ok(WindowBase {
463 table,
464 variable,
465 fixed_scopes,
466 params,
467 })
468}
469
470fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
471 let Some(value) = value else {
472 return Ok(Vec::new());
473 };
474 if value.is_null() {
475 return Ok(Vec::new());
476 }
477 let entries = value
478 .as_array()
479 .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
480 entries
481 .iter()
482 .map(|entry| {
483 let units = entry
484 .get("units")
485 .and_then(Value::as_array)
486 .map(|values| {
487 values
488 .iter()
489 .filter_map(|value| value.as_str().map(str::to_owned))
490 .collect()
491 })
492 .unwrap_or_default();
493 Ok(WindowCoverage {
494 base: parse_window_base(entry.get("base"))?,
495 units,
496 })
497 })
498 .collect()
499}
500
501fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
502 for event in core.drain_events() {
503 emit(&event.json);
504 }
505}
506
507#[tauri::command]
510async fn syncular_command<R: Runtime>(
511 app: tauri::AppHandle<R>,
512 command: Value,
513) -> Result<Value, String> {
514 let state = app.state::<SyncularState>();
515 let method = command
516 .get("method")
517 .and_then(Value::as_str)
518 .unwrap_or("")
519 .to_owned();
520 if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
521 state.security_gate.begin_preflight()?;
524 }
525 let create_preflight = method == "create"
526 && command
527 .pointer("/params/securityPreflight")
528 .and_then(Value::as_bool)
529 .unwrap_or(false);
530 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
531 state.send(Request::Command {
532 command,
533 reply: reply_tx,
534 })?;
535 let reply = reply_rx
536 .recv()
537 .map_err(|_| "the syncular core dropped the reply".to_owned())?;
538 let succeeded = reply.get("error").is_none();
539 if succeeded && method == "create" {
540 if !create_preflight {
541 state.security_gate.activate()?;
542 }
543 } else if succeeded && method == "activateSecurity" {
544 state.security_gate.activate()?;
545 }
546 Ok(reply)
547}
548
549#[tauri::command]
554async fn syncular_set_headers<R: Runtime>(
555 app: tauri::AppHandle<R>,
556 headers: std::collections::BTreeMap<String, String>,
557) -> Result<Value, String> {
558 let state = app.state::<SyncularState>();
559 let _active_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::SetHeaders {
568 headers: headers.into_iter().collect(),
569 reply: reply_tx,
570 })?;
571 reply_rx
572 .recv()
573 .map_err(|_| "the syncular core dropped the reply".to_owned())
574}
575
576#[tauri::command]
577async fn syncular_query<R: Runtime>(
578 app: tauri::AppHandle<R>,
579 sql: String,
580 params: Option<Value>,
581) -> Result<Value, String> {
582 let state = app.state::<SyncularState>();
583 let _read_guard = match state.security_gate.enter_read() {
584 Ok(guard) => guard,
585 Err(reply) => return Ok(reply),
586 };
587 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
588 state.send(Request::Query {
589 sql,
590 params: params.unwrap_or(Value::Null),
591 reply: reply_tx,
592 })?;
593 reply_rx
594 .recv()
595 .map_err(|_| "the syncular core dropped the reply".to_owned())
596}
597
598#[tauri::command]
602async fn syncular_query_snapshot<R: Runtime>(
603 app: tauri::AppHandle<R>,
604 sql: String,
605 params: Option<Value>,
606 coverage: Option<Value>,
607) -> Result<Value, String> {
608 let state = app.state::<SyncularState>();
609 let _read_guard = match state.security_gate.enter_read() {
610 Ok(guard) => guard,
611 Err(reply) => return Ok(reply),
612 };
613 let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
614 let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
615
616 if state.reader.is_some() {
617 let bind = match params_value.as_array() {
618 Some(values) => values.clone(),
619 None => return Ok(client_error("querySnapshot params must be a list")),
620 };
621 let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
622 Ok(value) => value,
623 Err(message) => return Ok(client_error(message)),
624 };
625 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
626 state.send_read(ReadRequest::QuerySnapshot {
627 sql,
628 params: bind,
629 coverage: parsed_coverage,
630 reply: reply_tx,
631 })?;
632 return reply_rx
633 .recv()
634 .map_err(|_| "the syncular read thread dropped the reply".to_owned());
635 }
636
637 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
638 state.send(Request::Command {
639 command: json!({
640 "method": "querySnapshot",
641 "params": { "sql": sql, "params": params_value, "coverage": coverage_value }
642 }),
643 reply: reply_tx,
644 })?;
645 reply_rx
646 .recv()
647 .map_err(|_| "the syncular core dropped the reply".to_owned())
648}
649
650pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
658 Builder::<R>::new("syncular")
659 .invoke_handler(tauri::generate_handler![
660 syncular_command,
661 syncular_query,
662 syncular_query_snapshot,
663 syncular_set_headers
664 ])
665 .setup(move |app, _api| {
666 let (tx, rx) = std::sync::mpsc::channel::<Request>();
667 let reader = match config
668 .db_path
669 .as_ref()
670 .filter(|path| path.as_str() != ":memory:")
671 {
672 Some(path) => {
673 let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
674 let path = path.clone();
675 std::thread::Builder::new()
676 .name("syncular-read".to_owned())
677 .spawn(move || run_reader_thread(path, reader_rx))
678 .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
679 Some(Mutex::new(reader_tx))
680 }
681 None => None,
682 };
683 app.manage(SyncularState {
684 sender: Mutex::new(tx.clone()),
685 reader,
686 security_gate: SecurityGate::new_preflight(),
689 });
690 let app_handle = app.clone();
691 let emit = move |value: &Value| {
692 let _ = app_handle.emit(EVENT_NAME, value.clone());
695 };
696 std::thread::Builder::new()
697 .name("syncular-core".to_owned())
698 .spawn(move || run_owner_thread(config, tx, rx, emit))
699 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
700 Ok(())
701 })
702 .on_event(|app, event| {
703 if let RunEvent::Exit = event {
704 if let Some(state) = app.try_state::<SyncularState>() {
705 let _ = state.send(Request::Shutdown);
706 if state.reader.is_some() {
707 let _ = state.send_read(ReadRequest::Shutdown);
708 }
709 }
710 }
711 })
712 .build()
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718
719 #[test]
720 fn config_to_transport_json_shapes_fields() {
721 let config = SyncularConfig {
722 base_url: Some("https://api.example.com".to_owned()),
723 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
724 ..Default::default()
725 };
726 let json = config.to_transport_json();
727 assert_eq!(json["baseUrl"], "https://api.example.com");
728 assert_eq!(json["headers"]["authorization"], "Bearer x");
729 }
730
731 #[test]
732 fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
733 let gate = std::sync::Arc::new(SecurityGate::new_preflight());
734 gate.activate().expect("activate test gate");
735 let read = gate.enter_read().expect("active read");
736 let gate_for_barrier = std::sync::Arc::clone(&gate);
737 let (done_tx, done_rx) = std::sync::mpsc::channel();
738 let barrier = std::thread::spawn(move || {
739 gate_for_barrier.begin_preflight().expect("enter preflight");
740 done_tx.send(()).expect("barrier reply");
741 });
742
743 assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
744 drop(read);
745 done_rx
746 .recv_timeout(Duration::from_secs(1))
747 .expect("barrier drains after the read");
748 barrier.join().expect("barrier thread");
749 assert!(gate.enter_read().is_err());
750 }
751
752 #[test]
753 fn direct_set_headers_command_respects_the_native_preflight_gate() {
754 use std::collections::BTreeMap;
755 use tauri::test::{mock_builder, mock_context, noop_assets};
756
757 let app = mock_builder()
758 .plugin(init(SyncularConfig {
759 auto_sync: false,
760 ..Default::default()
761 }))
762 .build(mock_context(noop_assets()))
763 .expect("build mock app");
764
765 let blocked = tauri::async_runtime::block_on(syncular_set_headers(
766 app.handle().clone(),
767 BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
768 ))
769 .expect("preflight reply");
770 assert_eq!(
771 blocked["error"]["code"],
772 Value::from("client.security_preflight_required")
773 );
774
775 let created = tauri::async_runtime::block_on(syncular_command(
776 app.handle().clone(),
777 json!({
778 "method": "create",
779 "params": {
780 "clientId": "native-header-gate",
781 "schema": { "version": 1, "tables": [] }
782 }
783 }),
784 ))
785 .expect("create reply");
786 assert!(created.get("error").is_none(), "{created}");
787
788 let active = tauri::async_runtime::block_on(syncular_set_headers(
789 app.handle().clone(),
790 BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
791 ))
792 .expect("active reply");
793 assert_eq!(active["result"], Value::Null);
794 }
795
796 #[test]
797 fn activation_headers_extracts_only_the_activation_set() {
798 assert_eq!(
799 activation_headers(&json!({
800 "method": "activateSecurity",
801 "params": { "headers": { "authorization": "Bearer fresh" } }
802 })),
803 Some(vec![(
804 "authorization".to_owned(),
805 "Bearer fresh".to_owned()
806 )])
807 );
808 assert_eq!(
810 activation_headers(&json!({ "method": "activateSecurity", "params": {} })),
811 None
812 );
813 assert_eq!(
814 activation_headers(&json!({
815 "method": "setWindow",
816 "params": { "headers": { "authorization": "Bearer fresh" } }
817 })),
818 None
819 );
820 assert_eq!(
821 activation_headers(&json!({
822 "method": "activateSecurity",
823 "params": { "headers": { "authorization": 7 } }
824 })),
825 None
826 );
827 }
828
829 #[test]
830 fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
831 use tauri::test::{mock_builder, mock_context, noop_assets};
832
833 let app = mock_builder()
834 .plugin(init(SyncularConfig {
835 auto_sync: false,
836 ..Default::default()
837 }))
838 .build(mock_context(noop_assets()))
839 .expect("build mock app");
840 let command = |value: Value| {
841 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
842 .expect("command reply")
843 };
844
845 let created = command(json!({
846 "method": "create",
847 "params": {
848 "clientId": "native-preflight-escape",
849 "schema": { "version": 1, "tables": [] },
850 "securityPreflight": true
851 }
852 }));
853 assert!(created.get("error").is_none(), "{created}");
854
855 let escape = command(json!({
858 "method": "create",
859 "params": {
860 "clientId": "native-preflight-escape",
861 "schema": { "version": 1, "tables": [] }
862 }
863 }));
864 assert_eq!(
865 escape["error"]["code"],
866 Value::from("client.security_preflight_required"),
867 "{escape}"
868 );
869 let read = tauri::async_runtime::block_on(syncular_query(
870 app.handle().clone(),
871 "SELECT 1".to_owned(),
872 None,
873 ))
874 .expect("query reply");
875 assert_eq!(
876 read["error"]["code"],
877 Value::from("client.security_preflight_required")
878 );
879
880 let activated = command(json!({
882 "method": "activateSecurity",
883 "params": { "headers": { "authorization": "Bearer fresh" } }
884 }));
885 assert!(activated.get("error").is_none(), "{activated}");
886 let read = tauri::async_runtime::block_on(syncular_query(
887 app.handle().clone(),
888 "SELECT 1 AS value".to_owned(),
889 None,
890 ))
891 .expect("query reply");
892 assert_eq!(read["result"]["rows"][0]["value"], 1);
893 let recreated = command(json!({
894 "method": "create",
895 "params": {
896 "clientId": "native-preflight-escape",
897 "schema": { "version": 1, "tables": [] }
898 }
899 }));
900 assert!(recreated.get("error").is_none(), "{recreated}");
901 }
902
903 #[test]
904 fn inject_db_path_adds_to_create_only() {
905 let config = SyncularConfig {
906 db_path: Some("/tmp/app.db".to_owned()),
907 ..Default::default()
908 };
909 let created = inject_db_path(
911 json!({ "method": "create", "params": { "clientId": "c1" } }),
912 &config,
913 );
914 assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
915 let created2 = inject_db_path(json!({ "method": "create" }), &config);
917 assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
918 let explicit = inject_db_path(
920 json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
921 &config,
922 );
923 assert_eq!(explicit["params"]["dbPath"], "/other.db");
924 let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
926 assert!(mutate["params"].get("dbPath").is_none());
927 }
928
929 #[test]
930 fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
931 let parsed = parse_coverage(Some(&json!([{
932 "base": {
933 "table": "tasks",
934 "variable": "project_id",
935 "fixedScopes": { "tenant_id": ["one", "two"] },
936 "params": "opaque"
937 },
938 "units": ["a", "b"]
939 }])))
940 .expect("parse coverage");
941 assert_eq!(parsed.len(), 1);
942 let entry = &parsed[0];
943 assert_eq!(entry.base.table, "tasks");
944 assert_eq!(entry.base.variable, "project_id");
945 assert_eq!(
946 entry.base.fixed_scopes,
947 vec![(
948 "tenant_id".to_owned(),
949 vec!["one".to_owned(), "two".to_owned()]
950 )]
951 );
952 assert_eq!(entry.base.params.as_deref(), Some("opaque"));
953 assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
954 }
955
956 #[test]
960 fn owner_thread_round_trips_over_mailbox() {
961 use std::sync::mpsc::channel;
962 use std::sync::{Arc, Mutex as StdMutex};
963
964 let (tx, rx) = channel::<Request>();
965 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
966 let events_for_thread = Arc::clone(&events);
967 let config = SyncularConfig {
968 auto_sync: false,
969 ..Default::default()
970 };
971 let owner_tx = tx.clone();
972 let handle = std::thread::spawn(move || {
973 run_owner_thread(config, owner_tx, rx, move |v| {
974 events_for_thread.lock().unwrap().push(v.clone());
975 });
976 });
977
978 let call = |command: Value| -> Value {
979 let (rtx, rrx) = channel();
980 tx.send(Request::Command {
981 command,
982 reply: rtx,
983 })
984 .unwrap();
985 rrx.recv().unwrap()
986 };
987
988 let schema = json!({
989 "version": 1,
990 "tables": [{
991 "name": "todo", "primaryKey": "id",
992 "columns": [
993 { "name": "id", "type": "string", "nullable": false },
994 { "name": "title", "type": "string", "nullable": false }
995 ],
996 "scopes": []
997 }]
998 });
999 assert_eq!(
1000 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1001 ["result"],
1002 json!({})
1003 );
1004 call(json!({ "method": "mutate", "params": { "mutations": [{
1005 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1006 }] } }));
1007
1008 let (qtx, qrx) = channel();
1010 tx.send(Request::Query {
1011 sql: "SELECT title FROM todo".to_owned(),
1012 params: Value::Null,
1013 reply: qtx,
1014 })
1015 .unwrap();
1016 let rows = qrx.recv().unwrap();
1017 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1018
1019 let (htx, hrx) = channel();
1022 tx.send(Request::SetHeaders {
1023 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1024 reply: htx,
1025 })
1026 .unwrap();
1027 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1028
1029 tx.send(Request::Shutdown).unwrap();
1030 handle.join().unwrap();
1031
1032 let seen = events.lock().unwrap();
1033 let kinds: Vec<String> = seen
1034 .iter()
1035 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1036 .collect();
1037 assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1039 }
1040
1041 #[test]
1042 fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1043 use std::sync::mpsc::channel;
1044
1045 let path =
1046 std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1047 let config = SyncularConfig {
1048 db_path: Some(path.to_string_lossy().into_owned()),
1049 auto_sync: false,
1050 ..Default::default()
1051 };
1052 let (tx, rx) = channel::<Request>();
1053 let owner_tx = tx.clone();
1054 let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1055
1056 let (create_tx, create_rx) = channel();
1057 tx.send(Request::Command {
1058 command: json!({
1059 "method": "create",
1060 "params": {
1061 "clientId": "sidecar-client",
1062 "schema": { "version": 1, "tables": [] },
1063 "dbPath": path.to_string_lossy()
1064 }
1065 }),
1066 reply: create_tx,
1067 })
1068 .expect("post create");
1069 assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1070
1071 let (read_tx, read_rx) = channel::<ReadRequest>();
1072 let read_path = path.to_string_lossy().into_owned();
1073 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx));
1074
1075 let (entered_tx, entered_rx) = channel();
1078 tx.send(Request::Block {
1079 duration: Duration::from_millis(200),
1080 entered: entered_tx,
1081 })
1082 .expect("block owner");
1083 entered_rx.recv().expect("owner entered blocking round");
1084 let (snapshot_tx, snapshot_rx) = channel();
1085 read_tx
1086 .send(ReadRequest::QuerySnapshot {
1087 sql: "SELECT 1 AS value".to_owned(),
1088 params: Vec::new(),
1089 coverage: Vec::new(),
1090 reply: snapshot_tx,
1091 })
1092 .expect("post snapshot");
1093 let snapshot = snapshot_rx
1094 .recv_timeout(Duration::from_millis(50))
1095 .expect("local snapshot must not wait for the owner");
1096 assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
1097
1098 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
1099 reader.join().expect("join reader");
1100 tx.send(Request::Shutdown).expect("stop owner");
1101 owner.join().expect("join owner");
1102 std::fs::remove_file(path).expect("remove temp database");
1103 }
1104}