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)]
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>,
70 pub database_dir: Option<String>,
75 pub auto_sync: bool,
77}
78
79impl Default for SyncularConfig {
80 fn default() -> Self {
81 Self {
82 base_url: None,
83 ws_url: None,
84 headers: Vec::new(),
85 db_path: None,
86 database_dir: None,
87 auto_sync: true,
88 }
89 }
90}
91
92impl SyncularConfig {
93 fn to_transport_json(&self) -> Value {
95 let mut map = serde_json::Map::new();
96 if let Some(base) = &self.base_url {
97 map.insert("baseUrl".to_owned(), Value::from(base.clone()));
98 }
99 if let Some(ws) = &self.ws_url {
100 map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
101 }
102 if !self.headers.is_empty() {
103 let headers: serde_json::Map<String, Value> = self
104 .headers
105 .iter()
106 .map(|(k, v)| (k.clone(), Value::from(v.clone())))
107 .collect();
108 map.insert("headers".to_owned(), Value::Object(headers));
109 }
110 Value::Object(map)
111 }
112}
113
114enum Request {
117 Command {
118 command: Value,
119 reply: Sender<Value>,
120 },
121 Query {
122 sql: String,
123 params: Value,
124 reply: Sender<Value>,
125 },
126 SetHeaders {
130 headers: Vec<(String, String)>,
131 reply: Sender<Value>,
132 },
133 TransportWake,
135 QueryRead {
137 id: String,
138 tables: Vec<String>,
139 failure: Option<QueryReadFailure>,
140 },
141 #[cfg(test)]
142 Block {
143 duration: Duration,
144 entered: Sender<()>,
145 },
146 Shutdown,
147}
148
149enum ReadRequest {
153 QuerySnapshot {
154 sql: String,
155 params: Vec<Value>,
156 coverage: Vec<WindowCoverage>,
157 owner: Option<(String, Vec<String>)>,
158 reply: Sender<Value>,
159 },
160 Shutdown,
161}
162
163struct SyncularState {
166 sender: Mutex<Sender<Request>>,
167 reader: Mutex<Option<Sender<ReadRequest>>>,
172 config: SyncularConfig,
173 security_gate: SecurityGate,
174}
175
176struct SecurityGateState {
177 preflight: bool,
178 active_reads: usize,
179}
180
181struct SecurityGate {
182 state: Mutex<SecurityGateState>,
183 idle: Condvar,
184}
185
186struct SecurityReadGuard<'a> {
187 gate: &'a SecurityGate,
188}
189
190impl SecurityGate {
191 fn new_preflight() -> Self {
192 Self {
193 state: Mutex::new(SecurityGateState {
194 preflight: true,
195 active_reads: 0,
196 }),
197 idle: Condvar::new(),
198 }
199 }
200
201 fn begin_preflight(&self) -> Result<(), String> {
202 let mut state = self
203 .state
204 .lock()
205 .map_err(|_| "syncular security gate poisoned".to_owned())?;
206 state.preflight = true;
207 while state.active_reads > 0 {
208 state = self
209 .idle
210 .wait(state)
211 .map_err(|_| "syncular security gate poisoned".to_owned())?;
212 }
213 Ok(())
214 }
215
216 fn activate(&self) -> Result<(), String> {
217 let mut state = self
218 .state
219 .lock()
220 .map_err(|_| "syncular security gate poisoned".to_owned())?;
221 state.preflight = false;
222 Ok(())
223 }
224
225 fn enter_read(&self) -> Result<SecurityReadGuard<'_>, Value> {
226 let mut state = self
227 .state
228 .lock()
229 .map_err(|_| client_error("syncular security gate poisoned"))?;
230 if state.preflight {
231 return Err(security_preflight_error());
232 }
233 state.active_reads += 1;
234 Ok(SecurityReadGuard { gate: self })
235 }
236}
237
238impl Drop for SecurityReadGuard<'_> {
239 fn drop(&mut self) {
240 let Ok(mut state) = self.gate.state.lock() else {
241 return;
242 };
243 state.active_reads = state.active_reads.saturating_sub(1);
244 if state.active_reads == 0 {
245 self.gate.idle.notify_all();
246 }
247 }
248}
249
250impl SyncularState {
251 fn send(&self, request: Request) -> Result<(), String> {
252 self.sender
253 .lock()
254 .map_err(|_| "syncular mailbox poisoned".to_owned())?
255 .send(request)
256 .map_err(|_| "the syncular core thread has stopped".to_owned())
257 }
258
259 fn reader(&self) -> Result<Option<Sender<ReadRequest>>, String> {
260 Ok(self
261 .reader
262 .lock()
263 .map_err(|_| "syncular read mailbox poisoned".to_owned())?
264 .clone())
265 }
266}
267
268fn spawn_reader(path: String, owner_tx: Sender<Request>) -> Result<Sender<ReadRequest>, String> {
269 let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
270 std::thread::Builder::new()
271 .name("syncular-read".to_owned())
272 .spawn(move || run_reader_thread(path, reader_rx, owner_tx))
273 .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
274 Ok(reader_tx)
275}
276
277fn run_reader_thread(path: String, rx: Receiver<ReadRequest>, owner_tx: Sender<Request>) {
278 let mut reader = FileQuerySnapshotReader::new(path);
279 let mut failing = std::collections::HashSet::<String>::new();
282 while let Ok(request) = rx.recv() {
283 match request {
284 ReadRequest::QuerySnapshot {
285 sql,
286 params,
287 coverage,
288 owner,
289 reply,
290 } => {
291 let result = reader.query_snapshot(&sql, ¶ms, &coverage);
292 let value = match &result {
293 Ok(snapshot) => json!({ "result": snapshot }),
294 Err(failure) => json!({
295 "error": {
296 "code": failure.code.unwrap_or("client.failed"),
297 "message": failure.message,
298 }
299 }),
300 };
301 if let Some((id, tables)) = owner {
302 let failure = result.err();
303 let report = if failure.is_some() {
304 failing.insert(id.clone());
305 true
306 } else {
307 failing.remove(&id)
308 };
309 if report {
310 let _ = owner_tx.send(Request::QueryRead {
311 id,
312 tables,
313 failure,
314 });
315 }
316 }
317 let _ = reply.send(value);
318 }
319 ReadRequest::Shutdown => return,
320 }
321 }
322}
323
324fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
327where
328 F: Fn(&Value) + Send + Sync + 'static,
329{
330 let emit = std::sync::Arc::new(emit);
331 let transport_json = config.to_transport_json();
332 let wake_tx = tx.clone();
333 let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
334 let _ = wake_tx.send(Request::TransportWake);
335 });
336 let mut core = match SyncularCore::new_with_notify(&transport_json, Some(notify)) {
337 Ok(core) => core,
338 Err(message) => {
339 emit(&json!({ "type": "error", "message": message }));
342 return;
343 }
344 };
345 let progress_emit = emit.clone();
346 core.progress_listener = Some(std::sync::Arc::new(move |progress| {
347 progress_emit(&json!({ "type": "progress", "progress": progress }));
348 }));
349
350 let mut background_deadline: Option<Instant> = None;
353 loop {
354 if config.auto_sync {
355 match core.take_sync_intent() {
356 syncular_client::SyncIntent::Interactive => {
357 background_deadline = None;
358 core.sync_until_idle();
359 pump_events(&mut core, &*emit);
360 continue;
361 }
362 syncular_client::SyncIntent::Background { delay_ms } => {
363 let candidate = Instant::now()
364 .checked_add(Duration::from_millis(delay_ms))
365 .unwrap_or_else(Instant::now);
366 background_deadline = Some(
367 background_deadline.map_or(candidate, |current| current.min(candidate)),
368 );
369 }
370 syncular_client::SyncIntent::None => {}
371 }
372 }
373
374 let request = if let Some(deadline) = background_deadline {
375 let now = Instant::now();
376 if deadline <= now {
377 background_deadline = None;
378 core.sync_until_idle();
379 pump_events(&mut core, &*emit);
380 continue;
381 }
382 match rx.recv_timeout(deadline.saturating_duration_since(now)) {
383 Ok(request) => request,
384 Err(RecvTimeoutError::Timeout) => {
385 background_deadline = None;
386 core.sync_until_idle();
387 pump_events(&mut core, &*emit);
388 continue;
389 }
390 Err(RecvTimeoutError::Disconnected) => {
391 core.shutdown();
392 return;
393 }
394 }
395 } else {
396 match rx.recv() {
397 Ok(request) => request,
398 Err(std::sync::mpsc::RecvError) => {
399 core.shutdown();
400 return;
401 }
402 }
403 };
404
405 match request {
406 Request::Command { command, reply } => {
407 let result = core.command(&command);
408 if result.get("error").is_none() {
409 if let Some(headers) = activation_headers(&command) {
410 core.set_headers(headers);
416 }
417 }
418 let _ = reply.send(result);
419 pump_events(&mut core, &*emit);
420 }
421 Request::Query { sql, params, reply } => {
422 let result = core.query(&sql, params);
423 let _ = reply.send(result);
424 pump_events(&mut core, &*emit);
425 }
426 Request::SetHeaders { headers, reply } => {
427 core.set_headers(headers);
428 let _ = reply.send(json!({ "result": null }));
429 }
430 Request::TransportWake => {
431 core.poll_transport();
432 pump_events(&mut core, &*emit);
433 }
434 Request::QueryRead {
435 id,
436 tables,
437 failure,
438 } => {
439 core.record_query_read(&id, &tables, failure.as_ref());
440 pump_events(&mut core, &*emit);
441 }
442 #[cfg(test)]
443 Request::Block { duration, entered } => {
444 let _ = entered.send(());
445 std::thread::sleep(duration);
446 }
447 Request::Shutdown => {
448 core.shutdown();
449 return;
450 }
451 }
452 }
453}
454
455fn resolve_database(config: &SyncularConfig, params: &Value) -> Result<Option<String>, Value> {
462 if params.get("dbPath").is_some() {
463 return Err(invalid_request(
464 "sync.invalid_request: create.dbPath is host configuration; name a database with create.database",
465 ));
466 }
467 let Some(database) = params.get("database") else {
468 if config.db_path.is_none() && config.database_dir.is_some() {
469 return Err(invalid_request(
470 "sync.invalid_request: create.database is required when SyncularConfig sets database_dir without db_path",
471 ));
472 }
473 return Ok(config.db_path.clone());
474 };
475 let Some(name) = database.as_str().filter(|name| {
476 (1..=128).contains(&name.len())
477 && name.starts_with(|c: char| c.is_ascii_alphanumeric())
478 && !name.contains("..")
479 && name
480 .chars()
481 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
482 }) else {
483 return Err(invalid_request(
484 "sync.invalid_request: create.database must be 1 to 128 ASCII letters, digits, '-', '_' or '.', start with a letter or digit, and contain no '..'",
485 ));
486 };
487 let Some(dir) = &config.database_dir else {
488 return Err(invalid_request(
489 "sync.invalid_request: create.database needs SyncularConfig.database_dir",
490 ));
491 };
492 std::fs::create_dir_all(dir)
493 .map_err(|error| client_error(format!("cannot create the database directory: {error}")))?;
494 Ok(Some(
495 std::path::Path::new(dir)
496 .join(format!("{name}.db"))
497 .to_string_lossy()
498 .into_owned(),
499 ))
500}
501
502fn invalid_request(message: &str) -> Value {
503 json!({ "error": { "code": "sync.invalid_request", "message": message } })
504}
505
506fn activation_headers(command: &Value) -> Option<Vec<(String, String)>> {
510 if command.get("method").and_then(Value::as_str) != Some("activateSecurity") {
511 return None;
512 }
513 let headers = command.pointer("/params/headers")?;
514 syncular_command::parse_headers(headers).ok()
515}
516
517fn client_error(message: impl Into<String>) -> Value {
518 json!({ "error": { "code": "client.failed", "message": message.into() } })
519}
520
521fn security_preflight_error() -> Value {
522 json!({
523 "error": {
524 "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
525 "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
526 }
527 })
528}
529
530fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
531 let object = value
532 .and_then(Value::as_object)
533 .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
534 let table = object
535 .get("table")
536 .and_then(Value::as_str)
537 .ok_or_else(|| "window base missing table".to_owned())?
538 .to_owned();
539 let variable = object
540 .get("variable")
541 .and_then(Value::as_str)
542 .ok_or_else(|| "window base missing variable".to_owned())?
543 .to_owned();
544 let fixed_scopes = match object.get("fixedScopes") {
545 Some(value) => syncular_client::values::json_to_scope_map(value)?,
546 None => Vec::new(),
547 };
548 let params = object
549 .get("params")
550 .and_then(Value::as_str)
551 .map(str::to_owned);
552 Ok(WindowBase {
553 table,
554 variable,
555 fixed_scopes,
556 params,
557 })
558}
559
560fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
561 let Some(value) = value else {
562 return Ok(Vec::new());
563 };
564 if value.is_null() {
565 return Ok(Vec::new());
566 }
567 let entries = value
568 .as_array()
569 .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
570 entries
571 .iter()
572 .map(|entry| {
573 let units = entry
574 .get("units")
575 .and_then(Value::as_array)
576 .map(|values| {
577 values
578 .iter()
579 .filter_map(|value| value.as_str().map(str::to_owned))
580 .collect()
581 })
582 .unwrap_or_default();
583 Ok(WindowCoverage {
584 base: parse_window_base(entry.get("base"))?,
585 units,
586 })
587 })
588 .collect()
589}
590
591fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
592 for event in core.drain_events() {
593 emit(&event.json);
594 }
595}
596
597#[tauri::command]
600async fn syncular_command<R: Runtime>(
601 app: tauri::AppHandle<R>,
602 command: Value,
603) -> Result<Value, String> {
604 let state = app.state::<SyncularState>();
605 let method = command
606 .get("method")
607 .and_then(Value::as_str)
608 .unwrap_or("")
609 .to_owned();
610 if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
611 state.security_gate.begin_preflight()?;
614 }
615 let mut reader = if method == "create" || method == "shutdown" {
617 Some(
618 state
619 .reader
620 .lock()
621 .map_err(|_| "syncular read mailbox poisoned".to_owned())?,
622 )
623 } else {
624 None
625 };
626 let mut command = command;
627 let mut database = None;
628 if method == "create" {
629 database =
630 match resolve_database(&state.config, command.get("params").unwrap_or(&Value::Null)) {
631 Ok(path) => path,
632 Err(reply) => return Ok(reply),
633 };
634 if let Some(path) = &database {
635 match command.get_mut("params").and_then(Value::as_object_mut) {
636 Some(params) => {
637 params.insert("dbPath".to_owned(), Value::from(path.clone()));
638 }
639 None => {
640 if let Some(object) = command.as_object_mut() {
641 object.insert("params".to_owned(), json!({ "dbPath": path }));
642 }
643 }
644 }
645 }
646 }
647 let create_preflight = method == "create"
648 && command
649 .pointer("/params/securityPreflight")
650 .and_then(Value::as_bool)
651 .unwrap_or(false);
652 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
653 state.send(Request::Command {
654 command,
655 reply: reply_tx,
656 })?;
657 let reply = reply_rx
658 .recv()
659 .map_err(|_| "the syncular core dropped the reply".to_owned())?;
660 let succeeded = reply.get("error").is_none();
661 if let (true, Some(slot)) = (succeeded, reader.as_mut()) {
662 let next = match database.filter(|path| path.as_str() != ":memory:") {
663 Some(path) => Some(spawn_reader(
664 path,
665 state
666 .sender
667 .lock()
668 .map_err(|_| "syncular mailbox poisoned".to_owned())?
669 .clone(),
670 )?),
671 None => None,
672 };
673 if let Some(previous) = std::mem::replace(&mut **slot, next) {
674 let _ = previous.send(ReadRequest::Shutdown);
675 }
676 }
677 drop(reader);
678 if succeeded && method == "create" {
679 if !create_preflight {
680 state.security_gate.activate()?;
681 }
682 } else if succeeded && method == "activateSecurity" {
683 state.security_gate.activate()?;
684 }
685 Ok(reply)
686}
687
688#[tauri::command]
693async fn syncular_set_headers<R: Runtime>(
694 app: tauri::AppHandle<R>,
695 headers: std::collections::BTreeMap<String, String>,
696) -> Result<Value, String> {
697 let state = app.state::<SyncularState>();
698 let _active_guard = match state.security_gate.enter_read() {
702 Ok(guard) => guard,
703 Err(reply) => return Ok(reply),
704 };
705 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
706 state.send(Request::SetHeaders {
707 headers: headers.into_iter().collect(),
708 reply: reply_tx,
709 })?;
710 reply_rx
711 .recv()
712 .map_err(|_| "the syncular core dropped the reply".to_owned())
713}
714
715#[tauri::command]
716async fn syncular_query<R: Runtime>(
717 app: tauri::AppHandle<R>,
718 sql: String,
719 params: Option<Value>,
720) -> Result<Value, String> {
721 let state = app.state::<SyncularState>();
722 let _read_guard = match state.security_gate.enter_read() {
723 Ok(guard) => guard,
724 Err(reply) => return Ok(reply),
725 };
726 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
727 state.send(Request::Query {
728 sql,
729 params: params.unwrap_or(Value::Null),
730 reply: reply_tx,
731 })?;
732 reply_rx
733 .recv()
734 .map_err(|_| "the syncular core dropped the reply".to_owned())
735}
736
737#[tauri::command]
741async fn syncular_query_snapshot<R: Runtime>(
742 app: tauri::AppHandle<R>,
743 sql: String,
744 params: Option<Value>,
745 coverage: Option<Value>,
746 owner: Option<Value>,
747) -> Result<Value, String> {
748 let state = app.state::<SyncularState>();
749 let _read_guard = match state.security_gate.enter_read() {
750 Ok(guard) => guard,
751 Err(reply) => return Ok(reply),
752 };
753 let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
754 let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
755
756 if let Some(reader) = state.reader()? {
757 let bind = match params_value.as_array() {
758 Some(values) => values.clone(),
759 None => return Ok(client_error("querySnapshot params must be a list")),
760 };
761 let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
762 Ok(value) => value,
763 Err(message) => return Ok(client_error(message)),
764 };
765 let parsed_owner = match &owner {
767 None | Some(Value::Null) => None,
768 Some(value) => {
769 let id = value
770 .get("id")
771 .and_then(Value::as_str)
772 .filter(|id| !id.is_empty());
773 let tables = value
774 .get("tables")
775 .and_then(Value::as_array)
776 .and_then(|list| {
777 list.iter()
778 .map(|table| table.as_str().map(str::to_owned))
779 .collect::<Option<Vec<String>>>()
780 });
781 match (id, tables) {
782 (Some(id), Some(tables)) => Some((id.to_owned(), tables)),
783 _ => {
784 return Ok(json!({ "error": {
785 "code": "sync.invalid_request",
786 "message": "sync.invalid_request: querySnapshot owner must be {id: non-empty string, tables: string[]}",
787 } }))
788 }
789 }
790 }
791 };
792 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
793 reader
794 .send(ReadRequest::QuerySnapshot {
795 sql,
796 params: bind,
797 coverage: parsed_coverage,
798 owner: parsed_owner,
799 reply: reply_tx,
800 })
801 .map_err(|_| "the syncular read thread has stopped".to_owned())?;
802 return reply_rx
803 .recv()
804 .map_err(|_| "the syncular read thread dropped the reply".to_owned());
805 }
806
807 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
808 state.send(Request::Command {
809 command: json!({
810 "method": "querySnapshot",
811 "params": {
812 "sql": sql,
813 "params": params_value,
814 "coverage": coverage_value,
815 "owner": owner.unwrap_or(Value::Null),
816 }
817 }),
818 reply: reply_tx,
819 })?;
820 reply_rx
821 .recv()
822 .map_err(|_| "the syncular core dropped the reply".to_owned())
823}
824
825pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
833 Builder::<R>::new("syncular")
834 .invoke_handler(tauri::generate_handler![
835 syncular_command,
836 syncular_query,
837 syncular_query_snapshot,
838 syncular_set_headers
839 ])
840 .setup(move |app, _api| {
841 let (tx, rx) = std::sync::mpsc::channel::<Request>();
842 app.manage(SyncularState {
843 sender: Mutex::new(tx.clone()),
844 reader: Mutex::new(None),
845 config: config.clone(),
846 security_gate: SecurityGate::new_preflight(),
849 });
850 let app_handle = app.clone();
851 let emit = move |value: &Value| {
852 let _ = app_handle.emit(EVENT_NAME, value.clone());
855 };
856 std::thread::Builder::new()
857 .name("syncular-core".to_owned())
858 .spawn(move || run_owner_thread(config, tx, rx, emit))
859 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
860 Ok(())
861 })
862 .on_event(|app, event| {
863 if let RunEvent::Exit = event {
864 if let Some(state) = app.try_state::<SyncularState>() {
865 let _ = state.send(Request::Shutdown);
866 if let Ok(Some(reader)) = state.reader() {
867 let _ = reader.send(ReadRequest::Shutdown);
868 }
869 }
870 }
871 })
872 .build()
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878
879 #[test]
880 fn config_to_transport_json_shapes_fields() {
881 let config = SyncularConfig {
882 base_url: Some("https://api.example.com".to_owned()),
883 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
884 ..Default::default()
885 };
886 let json = config.to_transport_json();
887 assert_eq!(json["baseUrl"], "https://api.example.com");
888 assert_eq!(json["headers"]["authorization"], "Bearer x");
889 }
890
891 #[test]
892 fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
893 let gate = std::sync::Arc::new(SecurityGate::new_preflight());
894 gate.activate().expect("activate test gate");
895 let read = gate.enter_read().expect("active read");
896 let gate_for_barrier = std::sync::Arc::clone(&gate);
897 let (done_tx, done_rx) = std::sync::mpsc::channel();
898 let barrier = std::thread::spawn(move || {
899 gate_for_barrier.begin_preflight().expect("enter preflight");
900 done_tx.send(()).expect("barrier reply");
901 });
902
903 assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
904 drop(read);
905 done_rx
906 .recv_timeout(Duration::from_secs(1))
907 .expect("barrier drains after the read");
908 barrier.join().expect("barrier thread");
909 assert!(gate.enter_read().is_err());
910 }
911
912 #[test]
913 fn direct_set_headers_command_respects_the_native_preflight_gate() {
914 use std::collections::BTreeMap;
915 use tauri::test::{mock_builder, mock_context, noop_assets};
916
917 let app = mock_builder()
918 .plugin(init(SyncularConfig {
919 auto_sync: false,
920 ..Default::default()
921 }))
922 .build(mock_context(noop_assets()))
923 .expect("build mock app");
924
925 let blocked = tauri::async_runtime::block_on(syncular_set_headers(
926 app.handle().clone(),
927 BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
928 ))
929 .expect("preflight reply");
930 assert_eq!(
931 blocked["error"]["code"],
932 Value::from("client.security_preflight_required")
933 );
934
935 let created = tauri::async_runtime::block_on(syncular_command(
936 app.handle().clone(),
937 json!({
938 "method": "create",
939 "params": {
940 "clientId": "native-header-gate",
941 "schema": { "version": 1, "tables": [] }
942 }
943 }),
944 ))
945 .expect("create reply");
946 assert!(created.get("error").is_none(), "{created}");
947
948 let active = tauri::async_runtime::block_on(syncular_set_headers(
949 app.handle().clone(),
950 BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
951 ))
952 .expect("active reply");
953 assert_eq!(active["result"], Value::Null);
954 }
955
956 #[test]
957 fn activation_headers_extracts_only_the_activation_set() {
958 assert_eq!(
959 activation_headers(&json!({
960 "method": "activateSecurity",
961 "params": { "headers": { "authorization": "Bearer fresh" } }
962 })),
963 Some(vec![(
964 "authorization".to_owned(),
965 "Bearer fresh".to_owned()
966 )])
967 );
968 assert_eq!(
970 activation_headers(&json!({ "method": "activateSecurity", "params": {} })),
971 None
972 );
973 assert_eq!(
974 activation_headers(&json!({
975 "method": "setWindow",
976 "params": { "headers": { "authorization": "Bearer fresh" } }
977 })),
978 None
979 );
980 assert_eq!(
981 activation_headers(&json!({
982 "method": "activateSecurity",
983 "params": { "headers": { "authorization": 7 } }
984 })),
985 None
986 );
987 }
988
989 #[test]
990 fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
991 use tauri::test::{mock_builder, mock_context, noop_assets};
992
993 let app = mock_builder()
994 .plugin(init(SyncularConfig {
995 auto_sync: false,
996 ..Default::default()
997 }))
998 .build(mock_context(noop_assets()))
999 .expect("build mock app");
1000 let command = |value: Value| {
1001 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1002 .expect("command reply")
1003 };
1004
1005 let created = command(json!({
1006 "method": "create",
1007 "params": {
1008 "clientId": "native-preflight-escape",
1009 "schema": { "version": 1, "tables": [] },
1010 "securityPreflight": true
1011 }
1012 }));
1013 assert!(created.get("error").is_none(), "{created}");
1014
1015 let escape = command(json!({
1018 "method": "create",
1019 "params": {
1020 "clientId": "native-preflight-escape",
1021 "schema": { "version": 1, "tables": [] }
1022 }
1023 }));
1024 assert_eq!(
1025 escape["error"]["code"],
1026 Value::from("client.security_preflight_required"),
1027 "{escape}"
1028 );
1029 let read = tauri::async_runtime::block_on(syncular_query(
1030 app.handle().clone(),
1031 "SELECT 1".to_owned(),
1032 None,
1033 ))
1034 .expect("query reply");
1035 assert_eq!(
1036 read["error"]["code"],
1037 Value::from("client.security_preflight_required")
1038 );
1039
1040 let activated = command(json!({
1042 "method": "activateSecurity",
1043 "params": { "headers": { "authorization": "Bearer fresh" } }
1044 }));
1045 assert!(activated.get("error").is_none(), "{activated}");
1046 let read = tauri::async_runtime::block_on(syncular_query(
1047 app.handle().clone(),
1048 "SELECT 1 AS value".to_owned(),
1049 None,
1050 ))
1051 .expect("query reply");
1052 assert_eq!(read["result"]["rows"][0]["value"], 1);
1053 let recreated = command(json!({
1054 "method": "create",
1055 "params": {
1056 "clientId": "native-preflight-escape",
1057 "schema": { "version": 1, "tables": [] }
1058 }
1059 }));
1060 assert!(recreated.get("error").is_none(), "{recreated}");
1061 }
1062
1063 #[test]
1064 fn resolve_database_names_a_file_inside_the_configured_directory() {
1065 let dir =
1066 std::env::temp_dir().join(format!("syncular-tauri-resolve-{}", std::process::id()));
1067 let config = SyncularConfig {
1068 db_path: Some("/tmp/default.db".to_owned()),
1069 database_dir: Some(dir.to_string_lossy().into_owned()),
1070 ..Default::default()
1071 };
1072 assert_eq!(
1074 resolve_database(&config, &json!({ "clientId": "c1" })),
1075 Ok(Some("/tmp/default.db".to_owned()))
1076 );
1077 assert_eq!(
1079 resolve_database(&config, &json!({ "database": "app-actor-0f.v2" })),
1080 Ok(Some(
1081 dir.join("app-actor-0f.v2.db")
1082 .to_string_lossy()
1083 .into_owned()
1084 ))
1085 );
1086 assert!(dir.is_dir());
1087 for params in [
1090 json!({ "dbPath": "/etc/other.db" }),
1091 json!({ "database": "../escape" }),
1092 json!({ "database": "a/b" }),
1093 json!({ "database": "a\\b" }),
1094 json!({ "database": "/abs" }),
1095 json!({ "database": ".hidden" }),
1096 json!({ "database": "a..b" }),
1097 json!({ "database": "" }),
1098 json!({ "database": "x".repeat(129) }),
1099 json!({ "database": 7 }),
1100 ] {
1101 let refused = resolve_database(&config, ¶ms).expect_err("refused");
1102 assert_eq!(refused["error"]["code"], "sync.invalid_request", "{params}");
1103 }
1104 let refused = resolve_database(
1107 &SyncularConfig {
1108 database_dir: Some(dir.to_string_lossy().into_owned()),
1109 ..Default::default()
1110 },
1111 &json!({ "clientId": "c1" }),
1112 )
1113 .expect_err("name required");
1114 assert_eq!(refused["error"]["code"], "sync.invalid_request");
1115 let refused = resolve_database(&SyncularConfig::default(), &json!({ "database": "actor" }))
1118 .expect_err("no directory");
1119 assert_eq!(refused["error"]["code"], "sync.invalid_request");
1120 std::fs::remove_dir_all(dir).expect("remove temp directory");
1121 }
1122
1123 #[test]
1126 fn named_databases_keep_one_replica_and_client_id_per_actor() {
1127 use tauri::test::{mock_builder, mock_context, noop_assets};
1128
1129 let dir =
1130 std::env::temp_dir().join(format!("syncular-tauri-actors-{}", std::process::id()));
1131 let _ = std::fs::remove_dir_all(&dir);
1132 let app = mock_builder()
1133 .plugin(init(SyncularConfig {
1134 database_dir: Some(dir.to_string_lossy().into_owned()),
1135 auto_sync: false,
1136 ..Default::default()
1137 }))
1138 .build(mock_context(noop_assets()))
1139 .expect("build mock app");
1140 let command = |value: Value| {
1141 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1142 .expect("command reply")
1143 };
1144 let snapshot = |sql: &str| {
1145 tauri::async_runtime::block_on(syncular_query_snapshot(
1146 app.handle().clone(),
1147 sql.to_owned(),
1148 None,
1149 None,
1150 None,
1151 ))
1152 .expect("snapshot reply")
1153 };
1154 let schema = json!({
1155 "version": 1,
1156 "tables": [{
1157 "name": "todo", "primaryKey": "id",
1158 "columns": [
1159 { "name": "id", "type": "string", "nullable": false },
1160 { "name": "title", "type": "string", "nullable": false }
1161 ],
1162 "scopes": []
1163 }]
1164 });
1165 let create = |database: &str, client_id: &str| {
1166 command(json!({ "method": "create", "params": {
1167 "database": database, "clientId": client_id, "schema": schema
1168 } }))
1169 };
1170
1171 assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1172 command(json!({ "method": "mutate", "params": { "mutations": [{
1173 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "a" }
1174 }] } }));
1175 assert_eq!(
1176 snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1177 "a"
1178 );
1179 assert_eq!(
1180 command(json!({ "method": "shutdown" }))["result"],
1181 json!({})
1182 );
1183
1184 assert_eq!(create("actor-b", "client-b")["result"], json!({}));
1185 assert_eq!(
1186 snapshot("SELECT title FROM todo")["result"]["rows"],
1187 json!([]),
1188 "the reader follows the second actor's database"
1189 );
1190 assert!(dir.join("actor-a.db").is_file());
1191 assert!(dir.join("actor-b.db").is_file());
1192 assert_eq!(
1193 command(json!({ "method": "shutdown" }))["result"],
1194 json!({})
1195 );
1196
1197 assert_eq!(
1199 create("actor-a", "client-b")["error"]["code"],
1200 "client.identity_mismatch"
1201 );
1202 assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1203 assert_eq!(
1204 snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1205 "a"
1206 );
1207 assert_eq!(
1208 create("../actor-a", "client-a")["error"]["code"],
1209 "sync.invalid_request"
1210 );
1211 command(json!({ "method": "shutdown" }));
1212 std::fs::remove_dir_all(dir).expect("remove temp directory");
1213 }
1214
1215 #[test]
1216 fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
1217 let parsed = parse_coverage(Some(&json!([{
1218 "base": {
1219 "table": "tasks",
1220 "variable": "project_id",
1221 "fixedScopes": { "tenant_id": ["one", "two"] },
1222 "params": "opaque"
1223 },
1224 "units": ["a", "b"]
1225 }])))
1226 .expect("parse coverage");
1227 assert_eq!(parsed.len(), 1);
1228 let entry = &parsed[0];
1229 assert_eq!(entry.base.table, "tasks");
1230 assert_eq!(entry.base.variable, "project_id");
1231 assert_eq!(
1232 entry.base.fixed_scopes,
1233 vec![(
1234 "tenant_id".to_owned(),
1235 vec!["one".to_owned(), "two".to_owned()]
1236 )]
1237 );
1238 assert_eq!(entry.base.params.as_deref(), Some("opaque"));
1239 assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
1240 }
1241
1242 #[test]
1246 fn owner_thread_round_trips_over_mailbox() {
1247 use std::sync::mpsc::channel;
1248 use std::sync::{Arc, Mutex as StdMutex};
1249
1250 let (tx, rx) = channel::<Request>();
1251 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
1252 let events_for_thread = Arc::clone(&events);
1253 let config = SyncularConfig {
1254 auto_sync: false,
1255 ..Default::default()
1256 };
1257 let owner_tx = tx.clone();
1258 let handle = std::thread::spawn(move || {
1259 run_owner_thread(config, owner_tx, rx, move |v| {
1260 events_for_thread.lock().unwrap().push(v.clone());
1261 });
1262 });
1263
1264 let call = |command: Value| -> Value {
1265 let (rtx, rrx) = channel();
1266 tx.send(Request::Command {
1267 command,
1268 reply: rtx,
1269 })
1270 .unwrap();
1271 rrx.recv().unwrap()
1272 };
1273
1274 let schema = json!({
1275 "version": 1,
1276 "tables": [{
1277 "name": "todo", "primaryKey": "id",
1278 "columns": [
1279 { "name": "id", "type": "string", "nullable": false },
1280 { "name": "title", "type": "string", "nullable": false }
1281 ],
1282 "scopes": []
1283 }]
1284 });
1285 assert_eq!(
1286 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1287 ["result"],
1288 json!({})
1289 );
1290 call(json!({ "method": "mutate", "params": { "mutations": [{
1291 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1292 }] } }));
1293
1294 let (qtx, qrx) = channel();
1296 tx.send(Request::Query {
1297 sql: "SELECT title FROM todo".to_owned(),
1298 params: Value::Null,
1299 reply: qtx,
1300 })
1301 .unwrap();
1302 let rows = qrx.recv().unwrap();
1303 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1304
1305 let (htx, hrx) = channel();
1308 tx.send(Request::SetHeaders {
1309 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1310 reply: htx,
1311 })
1312 .unwrap();
1313 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1314
1315 tx.send(Request::Shutdown).unwrap();
1316 handle.join().unwrap();
1317
1318 let seen = events.lock().unwrap();
1319 let kinds: Vec<String> = seen
1320 .iter()
1321 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1322 .collect();
1323 assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1325 }
1326
1327 #[test]
1328 fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1329 use std::sync::mpsc::channel;
1330
1331 let path =
1332 std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1333 let config = SyncularConfig {
1334 db_path: Some(path.to_string_lossy().into_owned()),
1335 auto_sync: false,
1336 ..Default::default()
1337 };
1338 let (tx, rx) = channel::<Request>();
1339 let owner_tx = tx.clone();
1340 let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1341
1342 let (create_tx, create_rx) = channel();
1343 tx.send(Request::Command {
1344 command: json!({
1345 "method": "create",
1346 "params": {
1347 "clientId": "sidecar-client",
1348 "schema": { "version": 1, "tables": [] },
1349 "dbPath": path.to_string_lossy()
1350 }
1351 }),
1352 reply: create_tx,
1353 })
1354 .expect("post create");
1355 assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1356
1357 let (read_tx, read_rx) = channel::<ReadRequest>();
1358 let read_path = path.to_string_lossy().into_owned();
1359 let owner_tx = tx.clone();
1360 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
1361
1362 let (entered_tx, entered_rx) = channel();
1365 tx.send(Request::Block {
1366 duration: Duration::from_millis(200),
1367 entered: entered_tx,
1368 })
1369 .expect("block owner");
1370 entered_rx.recv().expect("owner entered blocking round");
1371 let (snapshot_tx, snapshot_rx) = channel();
1372 read_tx
1373 .send(ReadRequest::QuerySnapshot {
1374 sql: "SELECT 1 AS value".to_owned(),
1375 params: Vec::new(),
1376 coverage: Vec::new(),
1377 owner: None,
1378 reply: snapshot_tx,
1379 })
1380 .expect("post snapshot");
1381 let snapshot = snapshot_rx
1382 .recv_timeout(Duration::from_millis(50))
1383 .expect("local snapshot must not wait for the owner");
1384 assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
1385
1386 let (failed_tx, failed_rx) = channel();
1387 read_tx
1388 .send(ReadRequest::QuerySnapshot {
1389 sql: "SELECT private_value FROM missing_private_table".to_owned(),
1390 params: Vec::new(),
1391 coverage: Vec::new(),
1392 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1393 reply: failed_tx,
1394 })
1395 .expect("post failed snapshot");
1396 assert_eq!(
1397 failed_rx.recv().expect("failed snapshot reply")["error"]["code"],
1398 "client.failed"
1399 );
1400 let (diagnostics_tx, diagnostics_rx) = channel();
1401 tx.send(Request::Command {
1402 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1403 reply: diagnostics_tx,
1404 })
1405 .expect("post diagnostics");
1406 assert_eq!(
1407 diagnostics_rx.recv().expect("diagnostics reply")["result"]["queryFailures"][0]["id"],
1408 "queries:missing"
1409 );
1410
1411 let (recovered_tx, recovered_rx) = channel();
1412 read_tx
1413 .send(ReadRequest::QuerySnapshot {
1414 sql: "SELECT 1 AS value".to_owned(),
1415 params: Vec::new(),
1416 coverage: Vec::new(),
1417 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1418 reply: recovered_tx,
1419 })
1420 .expect("post recovered snapshot");
1421 assert!(recovered_rx.recv().expect("recovered snapshot reply")["result"].is_object());
1422 let (cleared_tx, cleared_rx) = channel();
1423 tx.send(Request::Command {
1424 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1425 reply: cleared_tx,
1426 })
1427 .expect("post cleared diagnostics");
1428 assert_eq!(
1429 cleared_rx.recv().expect("cleared diagnostics reply")["result"]["queryFailures"],
1430 json!([])
1431 );
1432
1433 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
1434 reader.join().expect("join reader");
1435 tx.send(Request::Shutdown).expect("stop owner");
1436 owner.join().expect("join owner");
1437 std::fs::remove_file(path).expect("remove temp database");
1438 }
1439}