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