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, QueryReadFailure, 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 QueryRead {
129 id: String,
130 tables: Vec<String>,
131 failure: Option<QueryReadFailure>,
132 },
133 #[cfg(test)]
134 Block {
135 duration: Duration,
136 entered: Sender<()>,
137 },
138 Shutdown,
139}
140
141enum ReadRequest {
145 QuerySnapshot {
146 sql: String,
147 params: Vec<Value>,
148 coverage: Vec<WindowCoverage>,
149 owner: Option<(String, Vec<String>)>,
150 reply: Sender<Value>,
151 },
152 Shutdown,
153}
154
155struct SyncularState {
158 sender: Mutex<Sender<Request>>,
159 reader: Option<Mutex<Sender<ReadRequest>>>,
160 security_gate: SecurityGate,
161}
162
163struct SecurityGateState {
164 preflight: bool,
165 active_reads: usize,
166}
167
168struct SecurityGate {
169 state: Mutex<SecurityGateState>,
170 idle: Condvar,
171}
172
173struct SecurityReadGuard<'a> {
174 gate: &'a SecurityGate,
175}
176
177impl SecurityGate {
178 fn new_preflight() -> Self {
179 Self {
180 state: Mutex::new(SecurityGateState {
181 preflight: true,
182 active_reads: 0,
183 }),
184 idle: Condvar::new(),
185 }
186 }
187
188 fn begin_preflight(&self) -> Result<(), String> {
189 let mut state = self
190 .state
191 .lock()
192 .map_err(|_| "syncular security gate poisoned".to_owned())?;
193 state.preflight = true;
194 while state.active_reads > 0 {
195 state = self
196 .idle
197 .wait(state)
198 .map_err(|_| "syncular security gate poisoned".to_owned())?;
199 }
200 Ok(())
201 }
202
203 fn activate(&self) -> Result<(), String> {
204 let mut state = self
205 .state
206 .lock()
207 .map_err(|_| "syncular security gate poisoned".to_owned())?;
208 state.preflight = false;
209 Ok(())
210 }
211
212 fn enter_read(&self) -> Result<SecurityReadGuard<'_>, Value> {
213 let mut state = self
214 .state
215 .lock()
216 .map_err(|_| client_error("syncular security gate poisoned"))?;
217 if state.preflight {
218 return Err(security_preflight_error());
219 }
220 state.active_reads += 1;
221 Ok(SecurityReadGuard { gate: self })
222 }
223}
224
225impl Drop for SecurityReadGuard<'_> {
226 fn drop(&mut self) {
227 let Ok(mut state) = self.gate.state.lock() else {
228 return;
229 };
230 state.active_reads = state.active_reads.saturating_sub(1);
231 if state.active_reads == 0 {
232 self.gate.idle.notify_all();
233 }
234 }
235}
236
237impl SyncularState {
238 fn send(&self, request: Request) -> Result<(), String> {
239 self.sender
240 .lock()
241 .map_err(|_| "syncular mailbox poisoned".to_owned())?
242 .send(request)
243 .map_err(|_| "the syncular core thread has stopped".to_owned())
244 }
245
246 fn send_read(&self, request: ReadRequest) -> Result<(), String> {
247 let Some(reader) = &self.reader else {
248 return Err("this syncular client has no file snapshot reader".to_owned());
249 };
250 reader
251 .lock()
252 .map_err(|_| "syncular read mailbox poisoned".to_owned())?
253 .send(request)
254 .map_err(|_| "the syncular read thread has stopped".to_owned())?;
255 Ok(())
256 }
257}
258
259fn run_reader_thread(path: String, rx: Receiver<ReadRequest>, owner_tx: Sender<Request>) {
260 let mut reader = FileQuerySnapshotReader::new(path);
261 let mut failing = std::collections::HashSet::<String>::new();
264 while let Ok(request) = rx.recv() {
265 match request {
266 ReadRequest::QuerySnapshot {
267 sql,
268 params,
269 coverage,
270 owner,
271 reply,
272 } => {
273 let result = reader.query_snapshot(&sql, ¶ms, &coverage);
274 let value = match &result {
275 Ok(snapshot) => json!({ "result": snapshot }),
276 Err(failure) => json!({
277 "error": {
278 "code": failure.code.unwrap_or("client.failed"),
279 "message": failure.message,
280 }
281 }),
282 };
283 if let Some((id, tables)) = owner {
284 let failure = result.err();
285 let report = if failure.is_some() {
286 failing.insert(id.clone());
287 true
288 } else {
289 failing.remove(&id)
290 };
291 if report {
292 let _ = owner_tx.send(Request::QueryRead {
293 id,
294 tables,
295 failure,
296 });
297 }
298 }
299 let _ = reply.send(value);
300 }
301 ReadRequest::Shutdown => return,
302 }
303 }
304}
305
306fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
309where
310 F: Fn(&Value) + Send + Sync + 'static,
311{
312 let emit = std::sync::Arc::new(emit);
313 let transport_json = config.to_transport_json();
314 let wake_tx = tx.clone();
315 let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
316 let _ = wake_tx.send(Request::TransportWake);
317 });
318 let mut core = match SyncularCore::new_with_notify(&transport_json, Some(notify)) {
319 Ok(core) => core,
320 Err(message) => {
321 emit(&json!({ "type": "error", "message": message }));
324 return;
325 }
326 };
327 let progress_emit = emit.clone();
328 core.progress_listener = Some(std::sync::Arc::new(move |progress| {
329 progress_emit(&json!({ "type": "progress", "progress": progress }));
330 }));
331
332 let mut background_deadline: Option<Instant> = None;
335 loop {
336 if config.auto_sync {
337 match core.take_sync_intent() {
338 syncular_client::SyncIntent::Interactive => {
339 background_deadline = None;
340 core.sync_until_idle();
341 pump_events(&mut core, &*emit);
342 continue;
343 }
344 syncular_client::SyncIntent::Background { delay_ms } => {
345 let candidate = Instant::now()
346 .checked_add(Duration::from_millis(delay_ms))
347 .unwrap_or_else(Instant::now);
348 background_deadline = Some(
349 background_deadline.map_or(candidate, |current| current.min(candidate)),
350 );
351 }
352 syncular_client::SyncIntent::None => {}
353 }
354 }
355
356 let request = if let Some(deadline) = background_deadline {
357 let now = Instant::now();
358 if deadline <= now {
359 background_deadline = None;
360 core.sync_until_idle();
361 pump_events(&mut core, &*emit);
362 continue;
363 }
364 match rx.recv_timeout(deadline.saturating_duration_since(now)) {
365 Ok(request) => request,
366 Err(RecvTimeoutError::Timeout) => {
367 background_deadline = None;
368 core.sync_until_idle();
369 pump_events(&mut core, &*emit);
370 continue;
371 }
372 Err(RecvTimeoutError::Disconnected) => {
373 core.shutdown();
374 return;
375 }
376 }
377 } else {
378 match rx.recv() {
379 Ok(request) => request,
380 Err(std::sync::mpsc::RecvError) => {
381 core.shutdown();
382 return;
383 }
384 }
385 };
386
387 match request {
388 Request::Command { command, reply } => {
389 let command = inject_db_path(command, &config);
390 let result = core.command(&command);
391 if result.get("error").is_none() {
392 if let Some(headers) = activation_headers(&command) {
393 core.set_headers(headers);
399 }
400 }
401 let _ = reply.send(result);
402 pump_events(&mut core, &*emit);
403 }
404 Request::Query { sql, params, reply } => {
405 let result = core.query(&sql, params);
406 let _ = reply.send(result);
407 pump_events(&mut core, &*emit);
408 }
409 Request::SetHeaders { headers, reply } => {
410 core.set_headers(headers);
411 let _ = reply.send(json!({ "result": null }));
412 }
413 Request::TransportWake => {
414 core.poll_transport();
415 pump_events(&mut core, &*emit);
416 }
417 Request::QueryRead {
418 id,
419 tables,
420 failure,
421 } => {
422 core.record_query_read(&id, &tables, failure.as_ref());
423 pump_events(&mut core, &*emit);
424 }
425 #[cfg(test)]
426 Request::Block { duration, entered } => {
427 let _ = entered.send(());
428 std::thread::sleep(duration);
429 }
430 Request::Shutdown => {
431 core.shutdown();
432 return;
433 }
434 }
435 }
436}
437
438fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
442 if command.get("method").and_then(Value::as_str) != Some("create") {
443 return command;
444 }
445 let Some(db_path) = &config.db_path else {
446 return command;
447 };
448 let params = command.get_mut("params").and_then(Value::as_object_mut);
449 if let Some(params) = params {
450 params
451 .entry("dbPath")
452 .or_insert_with(|| Value::from(db_path.clone()));
453 } else if let Some(obj) = command.as_object_mut() {
454 obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
455 }
456 command
457}
458
459fn activation_headers(command: &Value) -> Option<Vec<(String, String)>> {
463 if command.get("method").and_then(Value::as_str) != Some("activateSecurity") {
464 return None;
465 }
466 let headers = command.pointer("/params/headers")?;
467 syncular_command::parse_headers(headers).ok()
468}
469
470fn client_error(message: impl Into<String>) -> Value {
471 json!({ "error": { "code": "client.failed", "message": message.into() } })
472}
473
474fn security_preflight_error() -> Value {
475 json!({
476 "error": {
477 "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
478 "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
479 }
480 })
481}
482
483fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
484 let object = value
485 .and_then(Value::as_object)
486 .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
487 let table = object
488 .get("table")
489 .and_then(Value::as_str)
490 .ok_or_else(|| "window base missing table".to_owned())?
491 .to_owned();
492 let variable = object
493 .get("variable")
494 .and_then(Value::as_str)
495 .ok_or_else(|| "window base missing variable".to_owned())?
496 .to_owned();
497 let fixed_scopes = match object.get("fixedScopes") {
498 Some(value) => syncular_client::values::json_to_scope_map(value)?,
499 None => Vec::new(),
500 };
501 let params = object
502 .get("params")
503 .and_then(Value::as_str)
504 .map(str::to_owned);
505 Ok(WindowBase {
506 table,
507 variable,
508 fixed_scopes,
509 params,
510 })
511}
512
513fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
514 let Some(value) = value else {
515 return Ok(Vec::new());
516 };
517 if value.is_null() {
518 return Ok(Vec::new());
519 }
520 let entries = value
521 .as_array()
522 .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
523 entries
524 .iter()
525 .map(|entry| {
526 let units = entry
527 .get("units")
528 .and_then(Value::as_array)
529 .map(|values| {
530 values
531 .iter()
532 .filter_map(|value| value.as_str().map(str::to_owned))
533 .collect()
534 })
535 .unwrap_or_default();
536 Ok(WindowCoverage {
537 base: parse_window_base(entry.get("base"))?,
538 units,
539 })
540 })
541 .collect()
542}
543
544fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
545 for event in core.drain_events() {
546 emit(&event.json);
547 }
548}
549
550#[tauri::command]
553async fn syncular_command<R: Runtime>(
554 app: tauri::AppHandle<R>,
555 command: Value,
556) -> Result<Value, String> {
557 let state = app.state::<SyncularState>();
558 let method = command
559 .get("method")
560 .and_then(Value::as_str)
561 .unwrap_or("")
562 .to_owned();
563 if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
564 state.security_gate.begin_preflight()?;
567 }
568 let create_preflight = method == "create"
569 && command
570 .pointer("/params/securityPreflight")
571 .and_then(Value::as_bool)
572 .unwrap_or(false);
573 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
574 state.send(Request::Command {
575 command,
576 reply: reply_tx,
577 })?;
578 let reply = reply_rx
579 .recv()
580 .map_err(|_| "the syncular core dropped the reply".to_owned())?;
581 let succeeded = reply.get("error").is_none();
582 if succeeded && method == "create" {
583 if !create_preflight {
584 state.security_gate.activate()?;
585 }
586 } else if succeeded && method == "activateSecurity" {
587 state.security_gate.activate()?;
588 }
589 Ok(reply)
590}
591
592#[tauri::command]
597async fn syncular_set_headers<R: Runtime>(
598 app: tauri::AppHandle<R>,
599 headers: std::collections::BTreeMap<String, String>,
600) -> Result<Value, String> {
601 let state = app.state::<SyncularState>();
602 let _active_guard = match state.security_gate.enter_read() {
606 Ok(guard) => guard,
607 Err(reply) => return Ok(reply),
608 };
609 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
610 state.send(Request::SetHeaders {
611 headers: headers.into_iter().collect(),
612 reply: reply_tx,
613 })?;
614 reply_rx
615 .recv()
616 .map_err(|_| "the syncular core dropped the reply".to_owned())
617}
618
619#[tauri::command]
620async fn syncular_query<R: Runtime>(
621 app: tauri::AppHandle<R>,
622 sql: String,
623 params: Option<Value>,
624) -> Result<Value, String> {
625 let state = app.state::<SyncularState>();
626 let _read_guard = match state.security_gate.enter_read() {
627 Ok(guard) => guard,
628 Err(reply) => return Ok(reply),
629 };
630 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
631 state.send(Request::Query {
632 sql,
633 params: params.unwrap_or(Value::Null),
634 reply: reply_tx,
635 })?;
636 reply_rx
637 .recv()
638 .map_err(|_| "the syncular core dropped the reply".to_owned())
639}
640
641#[tauri::command]
645async fn syncular_query_snapshot<R: Runtime>(
646 app: tauri::AppHandle<R>,
647 sql: String,
648 params: Option<Value>,
649 coverage: Option<Value>,
650 owner: Option<Value>,
651) -> Result<Value, String> {
652 let state = app.state::<SyncularState>();
653 let _read_guard = match state.security_gate.enter_read() {
654 Ok(guard) => guard,
655 Err(reply) => return Ok(reply),
656 };
657 let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
658 let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
659
660 if state.reader.is_some() {
661 let bind = match params_value.as_array() {
662 Some(values) => values.clone(),
663 None => return Ok(client_error("querySnapshot params must be a list")),
664 };
665 let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
666 Ok(value) => value,
667 Err(message) => return Ok(client_error(message)),
668 };
669 let parsed_owner = match &owner {
671 None | Some(Value::Null) => None,
672 Some(value) => {
673 let id = value
674 .get("id")
675 .and_then(Value::as_str)
676 .filter(|id| !id.is_empty());
677 let tables = value
678 .get("tables")
679 .and_then(Value::as_array)
680 .and_then(|list| {
681 list.iter()
682 .map(|table| table.as_str().map(str::to_owned))
683 .collect::<Option<Vec<String>>>()
684 });
685 match (id, tables) {
686 (Some(id), Some(tables)) => Some((id.to_owned(), tables)),
687 _ => {
688 return Ok(json!({ "error": {
689 "code": "sync.invalid_request",
690 "message": "sync.invalid_request: querySnapshot owner must be {id: non-empty string, tables: string[]}",
691 } }))
692 }
693 }
694 }
695 };
696 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
697 state.send_read(ReadRequest::QuerySnapshot {
698 sql,
699 params: bind,
700 coverage: parsed_coverage,
701 owner: parsed_owner,
702 reply: reply_tx,
703 })?;
704 return reply_rx
705 .recv()
706 .map_err(|_| "the syncular read thread dropped the reply".to_owned());
707 }
708
709 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
710 state.send(Request::Command {
711 command: json!({
712 "method": "querySnapshot",
713 "params": {
714 "sql": sql,
715 "params": params_value,
716 "coverage": coverage_value,
717 "owner": owner.unwrap_or(Value::Null),
718 }
719 }),
720 reply: reply_tx,
721 })?;
722 reply_rx
723 .recv()
724 .map_err(|_| "the syncular core dropped the reply".to_owned())
725}
726
727pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
735 Builder::<R>::new("syncular")
736 .invoke_handler(tauri::generate_handler![
737 syncular_command,
738 syncular_query,
739 syncular_query_snapshot,
740 syncular_set_headers
741 ])
742 .setup(move |app, _api| {
743 let (tx, rx) = std::sync::mpsc::channel::<Request>();
744 let reader = match config
745 .db_path
746 .as_ref()
747 .filter(|path| path.as_str() != ":memory:")
748 {
749 Some(path) => {
750 let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
751 let path = path.clone();
752 std::thread::Builder::new()
753 .name("syncular-read".to_owned())
754 .spawn({
755 let owner_tx = tx.clone();
756 move || run_reader_thread(path, reader_rx, owner_tx)
757 })
758 .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
759 Some(Mutex::new(reader_tx))
760 }
761 None => None,
762 };
763 app.manage(SyncularState {
764 sender: Mutex::new(tx.clone()),
765 reader,
766 security_gate: SecurityGate::new_preflight(),
769 });
770 let app_handle = app.clone();
771 let emit = move |value: &Value| {
772 let _ = app_handle.emit(EVENT_NAME, value.clone());
775 };
776 std::thread::Builder::new()
777 .name("syncular-core".to_owned())
778 .spawn(move || run_owner_thread(config, tx, rx, emit))
779 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
780 Ok(())
781 })
782 .on_event(|app, event| {
783 if let RunEvent::Exit = event {
784 if let Some(state) = app.try_state::<SyncularState>() {
785 let _ = state.send(Request::Shutdown);
786 if state.reader.is_some() {
787 let _ = state.send_read(ReadRequest::Shutdown);
788 }
789 }
790 }
791 })
792 .build()
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798
799 #[test]
800 fn config_to_transport_json_shapes_fields() {
801 let config = SyncularConfig {
802 base_url: Some("https://api.example.com".to_owned()),
803 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
804 ..Default::default()
805 };
806 let json = config.to_transport_json();
807 assert_eq!(json["baseUrl"], "https://api.example.com");
808 assert_eq!(json["headers"]["authorization"], "Bearer x");
809 }
810
811 #[test]
812 fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
813 let gate = std::sync::Arc::new(SecurityGate::new_preflight());
814 gate.activate().expect("activate test gate");
815 let read = gate.enter_read().expect("active read");
816 let gate_for_barrier = std::sync::Arc::clone(&gate);
817 let (done_tx, done_rx) = std::sync::mpsc::channel();
818 let barrier = std::thread::spawn(move || {
819 gate_for_barrier.begin_preflight().expect("enter preflight");
820 done_tx.send(()).expect("barrier reply");
821 });
822
823 assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
824 drop(read);
825 done_rx
826 .recv_timeout(Duration::from_secs(1))
827 .expect("barrier drains after the read");
828 barrier.join().expect("barrier thread");
829 assert!(gate.enter_read().is_err());
830 }
831
832 #[test]
833 fn direct_set_headers_command_respects_the_native_preflight_gate() {
834 use std::collections::BTreeMap;
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
845 let blocked = tauri::async_runtime::block_on(syncular_set_headers(
846 app.handle().clone(),
847 BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
848 ))
849 .expect("preflight reply");
850 assert_eq!(
851 blocked["error"]["code"],
852 Value::from("client.security_preflight_required")
853 );
854
855 let created = tauri::async_runtime::block_on(syncular_command(
856 app.handle().clone(),
857 json!({
858 "method": "create",
859 "params": {
860 "clientId": "native-header-gate",
861 "schema": { "version": 1, "tables": [] }
862 }
863 }),
864 ))
865 .expect("create reply");
866 assert!(created.get("error").is_none(), "{created}");
867
868 let active = tauri::async_runtime::block_on(syncular_set_headers(
869 app.handle().clone(),
870 BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
871 ))
872 .expect("active reply");
873 assert_eq!(active["result"], Value::Null);
874 }
875
876 #[test]
877 fn activation_headers_extracts_only_the_activation_set() {
878 assert_eq!(
879 activation_headers(&json!({
880 "method": "activateSecurity",
881 "params": { "headers": { "authorization": "Bearer fresh" } }
882 })),
883 Some(vec![(
884 "authorization".to_owned(),
885 "Bearer fresh".to_owned()
886 )])
887 );
888 assert_eq!(
890 activation_headers(&json!({ "method": "activateSecurity", "params": {} })),
891 None
892 );
893 assert_eq!(
894 activation_headers(&json!({
895 "method": "setWindow",
896 "params": { "headers": { "authorization": "Bearer fresh" } }
897 })),
898 None
899 );
900 assert_eq!(
901 activation_headers(&json!({
902 "method": "activateSecurity",
903 "params": { "headers": { "authorization": 7 } }
904 })),
905 None
906 );
907 }
908
909 #[test]
910 fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
911 use tauri::test::{mock_builder, mock_context, noop_assets};
912
913 let app = mock_builder()
914 .plugin(init(SyncularConfig {
915 auto_sync: false,
916 ..Default::default()
917 }))
918 .build(mock_context(noop_assets()))
919 .expect("build mock app");
920 let command = |value: Value| {
921 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
922 .expect("command reply")
923 };
924
925 let created = command(json!({
926 "method": "create",
927 "params": {
928 "clientId": "native-preflight-escape",
929 "schema": { "version": 1, "tables": [] },
930 "securityPreflight": true
931 }
932 }));
933 assert!(created.get("error").is_none(), "{created}");
934
935 let escape = command(json!({
938 "method": "create",
939 "params": {
940 "clientId": "native-preflight-escape",
941 "schema": { "version": 1, "tables": [] }
942 }
943 }));
944 assert_eq!(
945 escape["error"]["code"],
946 Value::from("client.security_preflight_required"),
947 "{escape}"
948 );
949 let read = tauri::async_runtime::block_on(syncular_query(
950 app.handle().clone(),
951 "SELECT 1".to_owned(),
952 None,
953 ))
954 .expect("query reply");
955 assert_eq!(
956 read["error"]["code"],
957 Value::from("client.security_preflight_required")
958 );
959
960 let activated = command(json!({
962 "method": "activateSecurity",
963 "params": { "headers": { "authorization": "Bearer fresh" } }
964 }));
965 assert!(activated.get("error").is_none(), "{activated}");
966 let read = tauri::async_runtime::block_on(syncular_query(
967 app.handle().clone(),
968 "SELECT 1 AS value".to_owned(),
969 None,
970 ))
971 .expect("query reply");
972 assert_eq!(read["result"]["rows"][0]["value"], 1);
973 let recreated = command(json!({
974 "method": "create",
975 "params": {
976 "clientId": "native-preflight-escape",
977 "schema": { "version": 1, "tables": [] }
978 }
979 }));
980 assert!(recreated.get("error").is_none(), "{recreated}");
981 }
982
983 #[test]
984 fn inject_db_path_adds_to_create_only() {
985 let config = SyncularConfig {
986 db_path: Some("/tmp/app.db".to_owned()),
987 ..Default::default()
988 };
989 let created = inject_db_path(
991 json!({ "method": "create", "params": { "clientId": "c1" } }),
992 &config,
993 );
994 assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
995 let created2 = inject_db_path(json!({ "method": "create" }), &config);
997 assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
998 let explicit = inject_db_path(
1000 json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
1001 &config,
1002 );
1003 assert_eq!(explicit["params"]["dbPath"], "/other.db");
1004 let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
1006 assert!(mutate["params"].get("dbPath").is_none());
1007 }
1008
1009 #[test]
1010 fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
1011 let parsed = parse_coverage(Some(&json!([{
1012 "base": {
1013 "table": "tasks",
1014 "variable": "project_id",
1015 "fixedScopes": { "tenant_id": ["one", "two"] },
1016 "params": "opaque"
1017 },
1018 "units": ["a", "b"]
1019 }])))
1020 .expect("parse coverage");
1021 assert_eq!(parsed.len(), 1);
1022 let entry = &parsed[0];
1023 assert_eq!(entry.base.table, "tasks");
1024 assert_eq!(entry.base.variable, "project_id");
1025 assert_eq!(
1026 entry.base.fixed_scopes,
1027 vec![(
1028 "tenant_id".to_owned(),
1029 vec!["one".to_owned(), "two".to_owned()]
1030 )]
1031 );
1032 assert_eq!(entry.base.params.as_deref(), Some("opaque"));
1033 assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
1034 }
1035
1036 #[test]
1040 fn owner_thread_round_trips_over_mailbox() {
1041 use std::sync::mpsc::channel;
1042 use std::sync::{Arc, Mutex as StdMutex};
1043
1044 let (tx, rx) = channel::<Request>();
1045 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
1046 let events_for_thread = Arc::clone(&events);
1047 let config = SyncularConfig {
1048 auto_sync: false,
1049 ..Default::default()
1050 };
1051 let owner_tx = tx.clone();
1052 let handle = std::thread::spawn(move || {
1053 run_owner_thread(config, owner_tx, rx, move |v| {
1054 events_for_thread.lock().unwrap().push(v.clone());
1055 });
1056 });
1057
1058 let call = |command: Value| -> Value {
1059 let (rtx, rrx) = channel();
1060 tx.send(Request::Command {
1061 command,
1062 reply: rtx,
1063 })
1064 .unwrap();
1065 rrx.recv().unwrap()
1066 };
1067
1068 let schema = json!({
1069 "version": 1,
1070 "tables": [{
1071 "name": "todo", "primaryKey": "id",
1072 "columns": [
1073 { "name": "id", "type": "string", "nullable": false },
1074 { "name": "title", "type": "string", "nullable": false }
1075 ],
1076 "scopes": []
1077 }]
1078 });
1079 assert_eq!(
1080 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1081 ["result"],
1082 json!({})
1083 );
1084 call(json!({ "method": "mutate", "params": { "mutations": [{
1085 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1086 }] } }));
1087
1088 let (qtx, qrx) = channel();
1090 tx.send(Request::Query {
1091 sql: "SELECT title FROM todo".to_owned(),
1092 params: Value::Null,
1093 reply: qtx,
1094 })
1095 .unwrap();
1096 let rows = qrx.recv().unwrap();
1097 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1098
1099 let (htx, hrx) = channel();
1102 tx.send(Request::SetHeaders {
1103 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1104 reply: htx,
1105 })
1106 .unwrap();
1107 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1108
1109 tx.send(Request::Shutdown).unwrap();
1110 handle.join().unwrap();
1111
1112 let seen = events.lock().unwrap();
1113 let kinds: Vec<String> = seen
1114 .iter()
1115 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1116 .collect();
1117 assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1119 }
1120
1121 #[test]
1122 fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1123 use std::sync::mpsc::channel;
1124
1125 let path =
1126 std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1127 let config = SyncularConfig {
1128 db_path: Some(path.to_string_lossy().into_owned()),
1129 auto_sync: false,
1130 ..Default::default()
1131 };
1132 let (tx, rx) = channel::<Request>();
1133 let owner_tx = tx.clone();
1134 let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1135
1136 let (create_tx, create_rx) = channel();
1137 tx.send(Request::Command {
1138 command: json!({
1139 "method": "create",
1140 "params": {
1141 "clientId": "sidecar-client",
1142 "schema": { "version": 1, "tables": [] },
1143 "dbPath": path.to_string_lossy()
1144 }
1145 }),
1146 reply: create_tx,
1147 })
1148 .expect("post create");
1149 assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1150
1151 let (read_tx, read_rx) = channel::<ReadRequest>();
1152 let read_path = path.to_string_lossy().into_owned();
1153 let owner_tx = tx.clone();
1154 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
1155
1156 let (entered_tx, entered_rx) = channel();
1159 tx.send(Request::Block {
1160 duration: Duration::from_millis(200),
1161 entered: entered_tx,
1162 })
1163 .expect("block owner");
1164 entered_rx.recv().expect("owner entered blocking round");
1165 let (snapshot_tx, snapshot_rx) = channel();
1166 read_tx
1167 .send(ReadRequest::QuerySnapshot {
1168 sql: "SELECT 1 AS value".to_owned(),
1169 params: Vec::new(),
1170 coverage: Vec::new(),
1171 owner: None,
1172 reply: snapshot_tx,
1173 })
1174 .expect("post snapshot");
1175 let snapshot = snapshot_rx
1176 .recv_timeout(Duration::from_millis(50))
1177 .expect("local snapshot must not wait for the owner");
1178 assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
1179
1180 let (failed_tx, failed_rx) = channel();
1181 read_tx
1182 .send(ReadRequest::QuerySnapshot {
1183 sql: "SELECT private_value FROM missing_private_table".to_owned(),
1184 params: Vec::new(),
1185 coverage: Vec::new(),
1186 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1187 reply: failed_tx,
1188 })
1189 .expect("post failed snapshot");
1190 assert_eq!(
1191 failed_rx.recv().expect("failed snapshot reply")["error"]["code"],
1192 "client.failed"
1193 );
1194 let (diagnostics_tx, diagnostics_rx) = channel();
1195 tx.send(Request::Command {
1196 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1197 reply: diagnostics_tx,
1198 })
1199 .expect("post diagnostics");
1200 assert_eq!(
1201 diagnostics_rx.recv().expect("diagnostics reply")["result"]["queryFailures"][0]["id"],
1202 "queries:missing"
1203 );
1204
1205 let (recovered_tx, recovered_rx) = channel();
1206 read_tx
1207 .send(ReadRequest::QuerySnapshot {
1208 sql: "SELECT 1 AS value".to_owned(),
1209 params: Vec::new(),
1210 coverage: Vec::new(),
1211 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1212 reply: recovered_tx,
1213 })
1214 .expect("post recovered snapshot");
1215 assert!(recovered_rx.recv().expect("recovered snapshot reply")["result"].is_object());
1216 let (cleared_tx, cleared_rx) = channel();
1217 tx.send(Request::Command {
1218 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1219 reply: cleared_tx,
1220 })
1221 .expect("post cleared diagnostics");
1222 assert_eq!(
1223 cleared_rx.recv().expect("cleared diagnostics reply")["result"]["queryFailures"],
1224 json!([])
1225 );
1226
1227 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
1228 reader.join().expect("join reader");
1229 tx.send(Request::Shutdown).expect("stop owner");
1230 owner.join().expect("join owner");
1231 std::fs::remove_file(path).expect("remove temp database");
1232 }
1233}