1use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
38use std::sync::Mutex;
39use std::time::{Duration, Instant};
40
41use serde_json::{json, Value};
42use tauri::plugin::{Builder, TauriPlugin};
43use tauri::{Emitter, Manager, RunEvent, Runtime};
44
45pub mod core;
46pub mod transport;
47
48use core::SyncularCore;
49use syncular_client::{FileQuerySnapshotReader, WindowBase, WindowCoverage};
50
51pub const EVENT_NAME: &str = "syncular://event";
53
54#[derive(Debug, Clone)]
58pub struct SyncularConfig {
59 pub base_url: Option<String>,
62 pub ws_url: Option<String>,
64 pub headers: Vec<(String, String)>,
66 pub db_path: Option<String>,
69 pub auto_sync: bool,
71}
72
73impl Default for SyncularConfig {
74 fn default() -> Self {
75 Self {
76 base_url: None,
77 ws_url: None,
78 headers: Vec::new(),
79 db_path: None,
80 auto_sync: true,
81 }
82 }
83}
84
85impl SyncularConfig {
86 fn to_transport_json(&self) -> Value {
88 let mut map = serde_json::Map::new();
89 if let Some(base) = &self.base_url {
90 map.insert("baseUrl".to_owned(), Value::from(base.clone()));
91 }
92 if let Some(ws) = &self.ws_url {
93 map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
94 }
95 if !self.headers.is_empty() {
96 let headers: serde_json::Map<String, Value> = self
97 .headers
98 .iter()
99 .map(|(k, v)| (k.clone(), Value::from(v.clone())))
100 .collect();
101 map.insert("headers".to_owned(), Value::Object(headers));
102 }
103 Value::Object(map)
104 }
105}
106
107enum Request {
110 Command {
111 command: Value,
112 reply: Sender<Value>,
113 },
114 Query {
115 sql: String,
116 params: Value,
117 reply: Sender<Value>,
118 },
119 SetHeaders {
123 headers: Vec<(String, String)>,
124 reply: Sender<Value>,
125 },
126 TransportWake,
128 #[cfg(test)]
129 Block {
130 duration: Duration,
131 entered: Sender<()>,
132 },
133 Shutdown,
134}
135
136enum ReadRequest {
140 QuerySnapshot {
141 sql: String,
142 params: Vec<Value>,
143 coverage: Vec<WindowCoverage>,
144 reply: Sender<Value>,
145 },
146 Shutdown,
147}
148
149struct SyncularState {
152 sender: Mutex<Sender<Request>>,
153 reader: Option<Mutex<Sender<ReadRequest>>>,
154}
155
156impl SyncularState {
157 fn send(&self, request: Request) -> Result<(), String> {
158 self.sender
159 .lock()
160 .map_err(|_| "syncular mailbox poisoned".to_owned())?
161 .send(request)
162 .map_err(|_| "the syncular core thread has stopped".to_owned())
163 }
164
165 fn send_read(&self, request: ReadRequest) -> Result<(), String> {
166 let Some(reader) = &self.reader else {
167 return Err("this syncular client has no file snapshot reader".to_owned());
168 };
169 reader
170 .lock()
171 .map_err(|_| "syncular read mailbox poisoned".to_owned())?
172 .send(request)
173 .map_err(|_| "the syncular read thread has stopped".to_owned())?;
174 Ok(())
175 }
176}
177
178fn run_reader_thread(path: String, rx: Receiver<ReadRequest>) {
179 let mut reader = FileQuerySnapshotReader::new(path);
180 while let Ok(request) = rx.recv() {
181 match request {
182 ReadRequest::QuerySnapshot {
183 sql,
184 params,
185 coverage,
186 reply,
187 } => {
188 let value = match reader.query_snapshot(&sql, ¶ms, &coverage) {
189 Ok(snapshot) => json!({ "result": snapshot }),
190 Err(message) => json!({
191 "error": { "code": "client.failed", "message": message }
192 }),
193 };
194 let _ = reply.send(value);
195 }
196 ReadRequest::Shutdown => return,
197 }
198 }
199}
200
201fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
204where
205 F: Fn(&Value) + Send + 'static,
206{
207 let transport_json = config.to_transport_json();
208 let wake_tx = tx.clone();
209 let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
210 let _ = wake_tx.send(Request::TransportWake);
211 });
212 let mut core = match SyncularCore::new_with_notify(&transport_json, Some(notify)) {
213 Ok(core) => core,
214 Err(message) => {
215 emit(&json!({ "type": "error", "message": message }));
218 return;
219 }
220 };
221
222 let mut background_deadline: Option<Instant> = None;
225 loop {
226 if config.auto_sync {
227 match core.take_sync_intent() {
228 syncular_client::SyncIntent::Interactive => {
229 background_deadline = None;
230 core.sync_until_idle();
231 pump_events(&mut core, &emit);
232 continue;
233 }
234 syncular_client::SyncIntent::Background { delay_ms } => {
235 let candidate = Instant::now()
236 .checked_add(Duration::from_millis(delay_ms))
237 .unwrap_or_else(Instant::now);
238 background_deadline = Some(
239 background_deadline.map_or(candidate, |current| current.min(candidate)),
240 );
241 }
242 syncular_client::SyncIntent::None => {}
243 }
244 }
245
246 let request = if let Some(deadline) = background_deadline {
247 let now = Instant::now();
248 if deadline <= now {
249 background_deadline = None;
250 core.sync_until_idle();
251 pump_events(&mut core, &emit);
252 continue;
253 }
254 match rx.recv_timeout(deadline.saturating_duration_since(now)) {
255 Ok(request) => request,
256 Err(RecvTimeoutError::Timeout) => {
257 background_deadline = None;
258 core.sync_until_idle();
259 pump_events(&mut core, &emit);
260 continue;
261 }
262 Err(RecvTimeoutError::Disconnected) => {
263 core.shutdown();
264 return;
265 }
266 }
267 } else {
268 match rx.recv() {
269 Ok(request) => request,
270 Err(std::sync::mpsc::RecvError) => {
271 core.shutdown();
272 return;
273 }
274 }
275 };
276
277 match request {
278 Request::Command { command, reply } => {
279 let command = inject_db_path(command, &config);
280 let result = core.command(&command);
281 let _ = reply.send(result);
282 pump_events(&mut core, &emit);
283 }
284 Request::Query { sql, params, reply } => {
285 let result = core.query(&sql, params);
286 let _ = reply.send(result);
287 pump_events(&mut core, &emit);
288 }
289 Request::SetHeaders { headers, reply } => {
290 core.set_headers(headers);
291 let _ = reply.send(json!({ "result": null }));
292 }
293 Request::TransportWake => {
294 core.poll_transport();
295 pump_events(&mut core, &emit);
296 }
297 #[cfg(test)]
298 Request::Block { duration, entered } => {
299 let _ = entered.send(());
300 std::thread::sleep(duration);
301 }
302 Request::Shutdown => {
303 core.shutdown();
304 return;
305 }
306 }
307 }
308}
309
310fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
314 if command.get("method").and_then(Value::as_str) != Some("create") {
315 return command;
316 }
317 let Some(db_path) = &config.db_path else {
318 return command;
319 };
320 let params = command.get_mut("params").and_then(Value::as_object_mut);
321 if let Some(params) = params {
322 params
323 .entry("dbPath")
324 .or_insert_with(|| Value::from(db_path.clone()));
325 } else if let Some(obj) = command.as_object_mut() {
326 obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
327 }
328 command
329}
330
331fn client_error(message: impl Into<String>) -> Value {
332 json!({ "error": { "code": "client.failed", "message": message.into() } })
333}
334
335fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
336 let object = value
337 .and_then(Value::as_object)
338 .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
339 let table = object
340 .get("table")
341 .and_then(Value::as_str)
342 .ok_or_else(|| "window base missing table".to_owned())?
343 .to_owned();
344 let variable = object
345 .get("variable")
346 .and_then(Value::as_str)
347 .ok_or_else(|| "window base missing variable".to_owned())?
348 .to_owned();
349 let fixed_scopes = match object.get("fixedScopes") {
350 Some(value) => syncular_client::values::json_to_scope_map(value)?,
351 None => Vec::new(),
352 };
353 let params = object
354 .get("params")
355 .and_then(Value::as_str)
356 .map(str::to_owned);
357 Ok(WindowBase {
358 table,
359 variable,
360 fixed_scopes,
361 params,
362 })
363}
364
365fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
366 let Some(value) = value else {
367 return Ok(Vec::new());
368 };
369 if value.is_null() {
370 return Ok(Vec::new());
371 }
372 let entries = value
373 .as_array()
374 .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
375 entries
376 .iter()
377 .map(|entry| {
378 let units = entry
379 .get("units")
380 .and_then(Value::as_array)
381 .map(|values| {
382 values
383 .iter()
384 .filter_map(|value| value.as_str().map(str::to_owned))
385 .collect()
386 })
387 .unwrap_or_default();
388 Ok(WindowCoverage {
389 base: parse_window_base(entry.get("base"))?,
390 units,
391 })
392 })
393 .collect()
394}
395
396fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
397 for event in core.drain_events() {
398 emit(&event.json);
399 }
400}
401
402#[tauri::command]
405async fn syncular_command<R: Runtime>(
406 app: tauri::AppHandle<R>,
407 command: Value,
408) -> Result<Value, String> {
409 let state = app.state::<SyncularState>();
410 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
411 state.send(Request::Command {
412 command,
413 reply: reply_tx,
414 })?;
415 reply_rx
416 .recv()
417 .map_err(|_| "the syncular core dropped the reply".to_owned())
418}
419
420#[tauri::command]
425async fn syncular_set_headers<R: Runtime>(
426 app: tauri::AppHandle<R>,
427 headers: std::collections::BTreeMap<String, String>,
428) -> Result<Value, String> {
429 let state = app.state::<SyncularState>();
430 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
431 state.send(Request::SetHeaders {
432 headers: headers.into_iter().collect(),
433 reply: reply_tx,
434 })?;
435 reply_rx
436 .recv()
437 .map_err(|_| "the syncular core dropped the reply".to_owned())
438}
439
440#[tauri::command]
441async fn syncular_query<R: Runtime>(
442 app: tauri::AppHandle<R>,
443 sql: String,
444 params: Option<Value>,
445) -> Result<Value, String> {
446 let state = app.state::<SyncularState>();
447 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
448 state.send(Request::Query {
449 sql,
450 params: params.unwrap_or(Value::Null),
451 reply: reply_tx,
452 })?;
453 reply_rx
454 .recv()
455 .map_err(|_| "the syncular core dropped the reply".to_owned())
456}
457
458#[tauri::command]
462async fn syncular_query_snapshot<R: Runtime>(
463 app: tauri::AppHandle<R>,
464 sql: String,
465 params: Option<Value>,
466 coverage: Option<Value>,
467) -> Result<Value, String> {
468 let state = app.state::<SyncularState>();
469 let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
470 let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
471
472 if state.reader.is_some() {
473 let bind = match params_value.as_array() {
474 Some(values) => values.clone(),
475 None => return Ok(client_error("querySnapshot params must be a list")),
476 };
477 let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
478 Ok(value) => value,
479 Err(message) => return Ok(client_error(message)),
480 };
481 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
482 state.send_read(ReadRequest::QuerySnapshot {
483 sql,
484 params: bind,
485 coverage: parsed_coverage,
486 reply: reply_tx,
487 })?;
488 return reply_rx
489 .recv()
490 .map_err(|_| "the syncular read thread dropped the reply".to_owned());
491 }
492
493 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
494 state.send(Request::Command {
495 command: json!({
496 "method": "querySnapshot",
497 "params": { "sql": sql, "params": params_value, "coverage": coverage_value }
498 }),
499 reply: reply_tx,
500 })?;
501 reply_rx
502 .recv()
503 .map_err(|_| "the syncular core dropped the reply".to_owned())
504}
505
506pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
514 Builder::<R>::new("syncular")
515 .invoke_handler(tauri::generate_handler![
516 syncular_command,
517 syncular_query,
518 syncular_query_snapshot,
519 syncular_set_headers
520 ])
521 .setup(move |app, _api| {
522 let (tx, rx) = std::sync::mpsc::channel::<Request>();
523 let reader = match config
524 .db_path
525 .as_ref()
526 .filter(|path| path.as_str() != ":memory:")
527 {
528 Some(path) => {
529 let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
530 let path = path.clone();
531 std::thread::Builder::new()
532 .name("syncular-read".to_owned())
533 .spawn(move || run_reader_thread(path, reader_rx))
534 .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
535 Some(Mutex::new(reader_tx))
536 }
537 None => None,
538 };
539 app.manage(SyncularState {
540 sender: Mutex::new(tx.clone()),
541 reader,
542 });
543 let app_handle = app.clone();
544 let emit = move |value: &Value| {
545 let _ = app_handle.emit(EVENT_NAME, value.clone());
548 };
549 std::thread::Builder::new()
550 .name("syncular-core".to_owned())
551 .spawn(move || run_owner_thread(config, tx, rx, emit))
552 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
553 Ok(())
554 })
555 .on_event(|app, event| {
556 if let RunEvent::Exit = event {
557 if let Some(state) = app.try_state::<SyncularState>() {
558 let _ = state.send(Request::Shutdown);
559 if state.reader.is_some() {
560 let _ = state.send_read(ReadRequest::Shutdown);
561 }
562 }
563 }
564 })
565 .build()
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn config_to_transport_json_shapes_fields() {
574 let config = SyncularConfig {
575 base_url: Some("https://api.example.com".to_owned()),
576 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
577 ..Default::default()
578 };
579 let json = config.to_transport_json();
580 assert_eq!(json["baseUrl"], "https://api.example.com");
581 assert_eq!(json["headers"]["authorization"], "Bearer x");
582 }
583
584 #[test]
585 fn inject_db_path_adds_to_create_only() {
586 let config = SyncularConfig {
587 db_path: Some("/tmp/app.db".to_owned()),
588 ..Default::default()
589 };
590 let created = inject_db_path(
592 json!({ "method": "create", "params": { "clientId": "c1" } }),
593 &config,
594 );
595 assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
596 let created2 = inject_db_path(json!({ "method": "create" }), &config);
598 assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
599 let explicit = inject_db_path(
601 json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
602 &config,
603 );
604 assert_eq!(explicit["params"]["dbPath"], "/other.db");
605 let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
607 assert!(mutate["params"].get("dbPath").is_none());
608 }
609
610 #[test]
611 fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
612 let parsed = parse_coverage(Some(&json!([{
613 "base": {
614 "table": "tasks",
615 "variable": "project_id",
616 "fixedScopes": { "tenant_id": ["one", "two"] },
617 "params": "opaque"
618 },
619 "units": ["a", "b"]
620 }])))
621 .expect("parse coverage");
622 assert_eq!(parsed.len(), 1);
623 let entry = &parsed[0];
624 assert_eq!(entry.base.table, "tasks");
625 assert_eq!(entry.base.variable, "project_id");
626 assert_eq!(
627 entry.base.fixed_scopes,
628 vec![(
629 "tenant_id".to_owned(),
630 vec!["one".to_owned(), "two".to_owned()]
631 )]
632 );
633 assert_eq!(entry.base.params.as_deref(), Some("opaque"));
634 assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
635 }
636
637 #[test]
641 fn owner_thread_round_trips_over_mailbox() {
642 use std::sync::mpsc::channel;
643 use std::sync::{Arc, Mutex as StdMutex};
644
645 let (tx, rx) = channel::<Request>();
646 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
647 let events_for_thread = Arc::clone(&events);
648 let config = SyncularConfig {
649 auto_sync: false,
650 ..Default::default()
651 };
652 let owner_tx = tx.clone();
653 let handle = std::thread::spawn(move || {
654 run_owner_thread(config, owner_tx, rx, move |v| {
655 events_for_thread.lock().unwrap().push(v.clone());
656 });
657 });
658
659 let call = |command: Value| -> Value {
660 let (rtx, rrx) = channel();
661 tx.send(Request::Command {
662 command,
663 reply: rtx,
664 })
665 .unwrap();
666 rrx.recv().unwrap()
667 };
668
669 let schema = json!({
670 "version": 1,
671 "tables": [{
672 "name": "todo", "primaryKey": "id",
673 "columns": [
674 { "name": "id", "type": "string", "nullable": false },
675 { "name": "title", "type": "string", "nullable": false }
676 ],
677 "scopes": []
678 }]
679 });
680 assert_eq!(
681 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
682 ["result"],
683 json!({})
684 );
685 call(json!({ "method": "mutate", "params": { "mutations": [{
686 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
687 }] } }));
688
689 let (qtx, qrx) = channel();
691 tx.send(Request::Query {
692 sql: "SELECT title FROM todo".to_owned(),
693 params: Value::Null,
694 reply: qtx,
695 })
696 .unwrap();
697 let rows = qrx.recv().unwrap();
698 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
699
700 let (htx, hrx) = channel();
703 tx.send(Request::SetHeaders {
704 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
705 reply: htx,
706 })
707 .unwrap();
708 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
709
710 tx.send(Request::Shutdown).unwrap();
711 handle.join().unwrap();
712
713 let seen = events.lock().unwrap();
714 let kinds: Vec<String> = seen
715 .iter()
716 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
717 .collect();
718 assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
720 }
721
722 #[test]
723 fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
724 use std::sync::mpsc::channel;
725
726 let path =
727 std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
728 let config = SyncularConfig {
729 db_path: Some(path.to_string_lossy().into_owned()),
730 auto_sync: false,
731 ..Default::default()
732 };
733 let (tx, rx) = channel::<Request>();
734 let owner_tx = tx.clone();
735 let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
736
737 let (create_tx, create_rx) = channel();
738 tx.send(Request::Command {
739 command: json!({
740 "method": "create",
741 "params": {
742 "clientId": "sidecar-client",
743 "schema": { "version": 1, "tables": [] },
744 "dbPath": path.to_string_lossy()
745 }
746 }),
747 reply: create_tx,
748 })
749 .expect("post create");
750 assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
751
752 let (read_tx, read_rx) = channel::<ReadRequest>();
753 let read_path = path.to_string_lossy().into_owned();
754 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx));
755
756 let (entered_tx, entered_rx) = channel();
759 tx.send(Request::Block {
760 duration: Duration::from_millis(200),
761 entered: entered_tx,
762 })
763 .expect("block owner");
764 entered_rx.recv().expect("owner entered blocking round");
765 let (snapshot_tx, snapshot_rx) = channel();
766 read_tx
767 .send(ReadRequest::QuerySnapshot {
768 sql: "SELECT 1 AS value".to_owned(),
769 params: Vec::new(),
770 coverage: Vec::new(),
771 reply: snapshot_tx,
772 })
773 .expect("post snapshot");
774 let snapshot = snapshot_rx
775 .recv_timeout(Duration::from_millis(50))
776 .expect("local snapshot must not wait for the owner");
777 assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
778
779 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
780 reader.join().expect("join reader");
781 tx.send(Request::Shutdown).expect("stop owner");
782 owner.join().expect("join owner");
783 std::fs::remove_file(path).expect("remove temp database");
784 }
785}