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