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 pub authority_columns: std::collections::BTreeMap<String, Vec<String>>,
80}
81
82impl Default for SyncularConfig {
83 fn default() -> Self {
84 Self {
85 base_url: None,
86 ws_url: None,
87 headers: Vec::new(),
88 db_path: None,
89 database_dir: None,
90 auto_sync: true,
91 authority_columns: std::collections::BTreeMap::new(),
92 }
93 }
94}
95
96impl SyncularConfig {
97 fn to_transport_json(&self) -> Value {
99 let mut map = serde_json::Map::new();
100 if let Some(base) = &self.base_url {
101 map.insert("baseUrl".to_owned(), Value::from(base.clone()));
102 }
103 if let Some(ws) = &self.ws_url {
104 map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
105 }
106 if !self.headers.is_empty() {
107 let headers: serde_json::Map<String, Value> = self
108 .headers
109 .iter()
110 .map(|(k, v)| (k.clone(), Value::from(v.clone())))
111 .collect();
112 map.insert("headers".to_owned(), Value::Object(headers));
113 }
114 Value::Object(map)
115 }
116}
117
118enum Request {
121 Command {
122 command: Value,
123 reply: Sender<Value>,
124 },
125 Query {
126 sql: String,
127 params: Value,
128 reply: Sender<Value>,
129 },
130 SetHeaders {
134 headers: Vec<(String, String)>,
135 reply: Sender<Value>,
136 },
137 TransportWake,
139 RoundFinished(
140 Box<(
141 syncular_client::CompletedSyncRound,
142 crate::transport::HostTransport,
143 )>,
144 ),
145 QueryRead {
147 id: String,
148 tables: Vec<String>,
149 failure: Option<QueryReadFailure>,
150 },
151 #[cfg(test)]
152 Block {
153 duration: Duration,
154 entered: Sender<()>,
155 },
156 Shutdown,
157}
158
159enum ReadRequest {
163 QuerySnapshot {
164 sql: String,
165 params: Vec<Value>,
166 coverage: Vec<WindowCoverage>,
167 owner: Option<(String, Vec<String>)>,
168 reply: Sender<Value>,
169 },
170 Shutdown,
171}
172
173struct SyncularState {
176 sender: Mutex<Sender<Request>>,
177 reader: Mutex<Option<Sender<ReadRequest>>>,
182 config: SyncularConfig,
183 security_gate: SecurityGate,
184}
185
186struct SecurityGateState {
187 preflight: bool,
188 active_reads: usize,
189}
190
191struct SecurityGate {
192 state: Mutex<SecurityGateState>,
193 idle: Condvar,
194}
195
196struct SecurityReadGuard<'a> {
197 gate: &'a SecurityGate,
198}
199
200impl SecurityGate {
201 fn new_preflight() -> Self {
202 Self {
203 state: Mutex::new(SecurityGateState {
204 preflight: true,
205 active_reads: 0,
206 }),
207 idle: Condvar::new(),
208 }
209 }
210
211 fn begin_preflight(&self) -> Result<(), String> {
212 let mut state = self
213 .state
214 .lock()
215 .map_err(|_| "syncular security gate poisoned".to_owned())?;
216 state.preflight = true;
217 while state.active_reads > 0 {
218 state = self
219 .idle
220 .wait(state)
221 .map_err(|_| "syncular security gate poisoned".to_owned())?;
222 }
223 Ok(())
224 }
225
226 fn activate(&self) -> Result<(), String> {
227 let mut state = self
228 .state
229 .lock()
230 .map_err(|_| "syncular security gate poisoned".to_owned())?;
231 state.preflight = false;
232 Ok(())
233 }
234
235 fn enter_read(&self) -> Result<SecurityReadGuard<'_>, Value> {
236 let mut state = self
237 .state
238 .lock()
239 .map_err(|_| client_error("syncular security gate poisoned"))?;
240 if state.preflight {
241 return Err(security_preflight_error());
242 }
243 state.active_reads += 1;
244 Ok(SecurityReadGuard { gate: self })
245 }
246}
247
248impl Drop for SecurityReadGuard<'_> {
249 fn drop(&mut self) {
250 let Ok(mut state) = self.gate.state.lock() else {
251 return;
252 };
253 state.active_reads = state.active_reads.saturating_sub(1);
254 if state.active_reads == 0 {
255 self.gate.idle.notify_all();
256 }
257 }
258}
259
260impl SyncularState {
261 fn send(&self, request: Request) -> Result<(), String> {
262 self.sender
263 .lock()
264 .map_err(|_| "syncular mailbox poisoned".to_owned())?
265 .send(request)
266 .map_err(|_| "the syncular core thread has stopped".to_owned())
267 }
268
269 fn reader(&self) -> Result<Option<Sender<ReadRequest>>, String> {
270 Ok(self
271 .reader
272 .lock()
273 .map_err(|_| "syncular read mailbox poisoned".to_owned())?
274 .clone())
275 }
276}
277
278fn spawn_reader(path: String, owner_tx: Sender<Request>) -> Result<Sender<ReadRequest>, String> {
279 let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
280 std::thread::Builder::new()
281 .name("syncular-read".to_owned())
282 .spawn(move || run_reader_thread(path, reader_rx, owner_tx))
283 .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
284 Ok(reader_tx)
285}
286
287fn run_reader_thread(path: String, rx: Receiver<ReadRequest>, owner_tx: Sender<Request>) {
288 let mut reader = FileQuerySnapshotReader::new(path);
289 let mut failing = std::collections::HashSet::<String>::new();
292 while let Ok(request) = rx.recv() {
293 match request {
294 ReadRequest::QuerySnapshot {
295 sql,
296 params,
297 coverage,
298 owner,
299 reply,
300 } => {
301 let result = reader.query_snapshot(&sql, ¶ms, &coverage);
302 let value = match &result {
303 Ok(snapshot) => json!({ "result": snapshot }),
304 Err(failure) => json!({
305 "error": {
306 "code": failure.code.unwrap_or("client.failed"),
307 "message": failure.message,
308 "details": failure.details(),
309 }
310 }),
311 };
312 if let Some((id, tables)) = owner {
313 let failure = result.err();
314 let report = if failure.is_some() {
315 failing.insert(id.clone());
316 true
317 } else {
318 failing.remove(&id)
319 };
320 if report {
321 let _ = owner_tx.send(Request::QueryRead {
322 id,
323 tables,
324 failure,
325 });
326 }
327 }
328 let _ = reply.send(value);
329 }
330 ReadRequest::Shutdown => return,
331 }
332 }
333}
334
335enum NetworkWork {
339 Round(
340 Box<syncular_client::PreparedSyncRound>,
341 crate::transport::HostTransport,
342 ),
343 Controls(Vec<String>, crate::transport::HostTransport),
344}
345
346struct RoundRun {
347 reply: Option<Sender<Value>>,
348 until_idle: bool,
349 max_rounds: Option<u32>,
350 spent: u32,
351 aggregate: syncular_client::SyncReport,
352}
353
354fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
355where
356 F: Fn(&Value) + Send + Sync + 'static,
357{
358 let emit = std::sync::Arc::new(emit);
359 let wake_tx = tx.clone();
360 let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
361 let _ = wake_tx.send(Request::TransportWake);
362 });
363 let mut core = match SyncularCore::new_with_notify(&config.to_transport_json(), Some(notify)) {
364 Ok(core) => core,
365 Err(message) => {
366 emit(&json!({ "type": "error", "message": message }));
367 return;
368 }
369 };
370 let progress_emit = emit.clone();
371 core.progress_listener = Some(std::sync::Arc::new(move |progress| {
372 progress_emit(&json!({ "type": "progress", "progress": progress }));
373 }));
374 let (network_tx, network_rx) = std::sync::mpsc::channel();
375 let completed_tx = tx.clone();
376 let network = std::thread::spawn(move || {
377 use syncular_client::Transport;
378 while let Ok(work) = network_rx.recv() {
379 match work {
380 NetworkWork::Round(prepared, mut transport) => {
381 let completed = (*prepared).exchange(&mut transport);
382 if completed_tx
383 .send(Request::RoundFinished(Box::new((completed, transport))))
384 .is_err()
385 {
386 return;
387 }
388 }
389 NetworkWork::Controls(controls, mut transport) => {
390 for text in controls {
391 let _ = transport.realtime_send(&text);
392 }
393 }
394 }
395 }
396 });
397 let mut active: Option<RoundRun> = None;
398 let mut queued = std::collections::VecDeque::new();
399 let mut background_deadline: Option<Instant> = None;
400 loop {
401 if !core.transport_enabled() {
402 background_deadline = None;
403 }
404 if let Some((controls, transport)) = core.take_controls() {
405 if network_tx
406 .send(NetworkWork::Controls(controls, transport))
407 .is_err()
408 {
409 break;
410 }
411 }
412 if active.is_none() {
415 if let Some(run) = queued.pop_front() {
416 active = Some(run);
417 } else if config.auto_sync {
418 match core.take_sync_intent() {
419 syncular_client::SyncIntent::Interactive => {
420 background_deadline = None;
421 active = Some(RoundRun {
422 reply: None,
423 until_idle: true,
424 max_rounds: None,
425 spent: 0,
426 aggregate: Default::default(),
427 });
428 }
429 syncular_client::SyncIntent::Background { delay_ms } => {
430 let candidate = Instant::now()
431 .checked_add(Duration::from_millis(delay_ms))
432 .unwrap_or_else(Instant::now);
433 background_deadline = Some(
434 background_deadline.map_or(candidate, |current| current.min(candidate)),
435 );
436 }
437 syncular_client::SyncIntent::None => {}
438 }
439 }
440 if active.is_some() {
441 match core.prepare_round() {
442 Ok((prepared, transport)) => {
443 active.as_mut().expect("active run").spent += 1;
444 if network_tx
445 .send(NetworkWork::Round(Box::new(prepared), transport))
446 .is_err()
447 {
448 break;
449 }
450 }
451 Err(outcome) => {
452 if let Some(reply) = active.take().and_then(|run| run.reply) {
453 let _ = reply.send(json!({ "result": outcome.to_json() }));
454 }
455 }
456 }
457 pump_events(&mut core, &*emit);
458 if active.is_none() {
459 continue;
460 }
461 }
462 }
463 let request = if active.is_none() {
464 if let Some(deadline) = background_deadline {
465 match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
466 Ok(request) => request,
467 Err(RecvTimeoutError::Timeout) => {
468 background_deadline = None;
469 queued.push_back(RoundRun {
470 reply: None,
471 until_idle: true,
472 max_rounds: None,
473 spent: 0,
474 aggregate: Default::default(),
475 });
476 continue;
477 }
478 Err(RecvTimeoutError::Disconnected) => break,
479 }
480 } else {
481 match rx.recv() {
482 Ok(request) => request,
483 Err(_) => break,
484 }
485 }
486 } else {
487 match rx.recv() {
488 Ok(request) => request,
489 Err(_) => break,
490 }
491 };
492 match request {
493 Request::RoundFinished(completed) => {
494 let (completed, transport) = *completed;
495 let applied = core.apply_round(completed);
496 let (mut outcome, more, bootstrap_advanced) = match applied {
497 syncular_client::AppliedSyncRound::Continue(prepared) => {
498 if network_tx
499 .send(NetworkWork::Round(Box::new(prepared), transport))
500 .is_err()
501 {
502 break;
503 }
504 pump_events(&mut core, &*emit);
505 continue;
506 }
507 syncular_client::AppliedSyncRound::Complete {
508 outcome,
509 more,
510 bootstrap_advanced,
511 ..
512 } => (outcome, more, bootstrap_advanced),
513 };
514 let Some(mut run) = active.take() else {
515 continue;
516 };
517 let mut keep_running = false;
518 if run.until_idle {
519 if let syncular_client::SyncOutcome::Ok(report) = &outcome {
520 run.aggregate.merge(report);
521 if bootstrap_advanced && run.max_rounds.is_none() {
522 run.spent = 0;
523 }
524 keep_running = more && core.transport_enabled();
525 if keep_running && run.spent >= run.max_rounds.unwrap_or(20).max(1) {
526 keep_running = false;
527 outcome = syncular_client::SyncOutcome::Failed {
528 error_code: "sync.invalid_request".into(),
529 message: "sync did not reach idle within the round budget".into(),
530 details: None,
531 };
532 } else if !keep_running {
533 outcome = syncular_client::SyncOutcome::Ok(run.aggregate.clone());
534 }
535 }
536 }
537 if keep_running {
538 queued.push_front(run);
539 } else if let Some(reply) = run.reply {
540 let _ = reply.send(json!({ "result": outcome.to_json() }));
541 }
542 pump_events(&mut core, &*emit);
543 }
544 Request::Command { command, reply } => {
545 let method = command.get("method").and_then(Value::as_str);
546 if matches!(method, Some("sync" | "syncUntilIdle")) && core.transport_enabled() {
547 queued.push_back(RoundRun {
548 reply: Some(reply),
549 until_idle: method == Some("syncUntilIdle"),
550 max_rounds: command
551 .pointer("/params/maxRounds")
552 .and_then(Value::as_u64)
553 .map(|n| n as u32),
554 spent: 0,
555 aggregate: Default::default(),
556 });
557 continue;
558 }
559 let result = core.command(&command);
560 let _ = reply.send(result);
561 pump_events(&mut core, &*emit);
562 }
563 Request::Query { sql, params, reply } => {
564 let _ = reply.send(core.query(&sql, params));
565 pump_events(&mut core, &*emit);
566 }
567 Request::SetHeaders { headers, reply } => {
568 core.set_headers(headers);
569 let _ = reply.send(json!({ "result": null }));
570 }
571 Request::TransportWake => {
572 core.poll_transport();
573 pump_events(&mut core, &*emit);
574 }
575 Request::QueryRead {
576 id,
577 tables,
578 failure,
579 } => {
580 core.record_query_read(&id, &tables, failure.as_ref());
581 pump_events(&mut core, &*emit);
582 }
583 #[cfg(test)]
584 Request::Block { duration, entered } => {
585 let _ = entered.send(());
586 std::thread::sleep(duration);
587 }
588 Request::Shutdown => break,
589 }
590 }
591 core.shutdown();
592 drop(network_tx);
593 let _ = network.join();
596 for run in active.into_iter().chain(queued) {
597 if let Some(reply) = run.reply {
598 let _ = reply.send(json!({ "error": { "code": "client.closed", "message": "client owner has closed" } }));
599 }
600 }
601}
602
603fn resolve_database(config: &SyncularConfig, params: &Value) -> Result<Option<String>, Value> {
610 if params.get("dbPath").is_some() {
611 return Err(invalid_request(
612 "sync.invalid_request: create.dbPath is host configuration; name a database with create.database",
613 ));
614 }
615 let Some(database) = params.get("database") else {
616 if config.db_path.is_none() && config.database_dir.is_some() {
617 return Err(invalid_request(
618 "sync.invalid_request: create.database is required when SyncularConfig sets database_dir without db_path",
619 ));
620 }
621 return Ok(config.db_path.clone());
622 };
623 let Some(name) = database.as_str().filter(|name| {
624 (1..=128).contains(&name.len())
625 && name.starts_with(|c: char| c.is_ascii_alphanumeric())
626 && !name.contains("..")
627 && name
628 .chars()
629 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
630 }) else {
631 return Err(invalid_request(
632 "sync.invalid_request: create.database must be 1 to 128 ASCII letters, digits, '-', '_' or '.', start with a letter or digit, and contain no '..'",
633 ));
634 };
635 let Some(dir) = &config.database_dir else {
636 return Err(invalid_request(
637 "sync.invalid_request: create.database needs SyncularConfig.database_dir",
638 ));
639 };
640 std::fs::create_dir_all(dir)
641 .map_err(|error| client_error(format!("cannot create the database directory: {error}")))?;
642 Ok(Some(
643 std::path::Path::new(dir)
644 .join(format!("{name}.db"))
645 .to_string_lossy()
646 .into_owned(),
647 ))
648}
649
650fn invalid_request(message: &str) -> Value {
651 json!({ "error": { "code": "sync.invalid_request", "message": message } })
652}
653
654fn client_error(message: impl Into<String>) -> Value {
655 json!({ "error": { "code": "client.failed", "message": message.into() } })
656}
657
658fn security_preflight_error() -> Value {
659 json!({
660 "error": {
661 "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
662 "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
663 }
664 })
665}
666
667fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
668 let object = value
669 .and_then(Value::as_object)
670 .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
671 let table = object
672 .get("table")
673 .and_then(Value::as_str)
674 .ok_or_else(|| "window base missing table".to_owned())?
675 .to_owned();
676 let variable = object
677 .get("variable")
678 .and_then(Value::as_str)
679 .ok_or_else(|| "window base missing variable".to_owned())?
680 .to_owned();
681 let fixed_scopes = match object.get("fixedScopes") {
682 Some(value) => syncular_client::values::json_to_scope_map(value)?,
683 None => Vec::new(),
684 };
685 let params = object
686 .get("params")
687 .and_then(Value::as_str)
688 .map(str::to_owned);
689 Ok(WindowBase {
690 table,
691 variable,
692 fixed_scopes,
693 params,
694 })
695}
696
697fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
698 let Some(value) = value else {
699 return Ok(Vec::new());
700 };
701 if value.is_null() {
702 return Ok(Vec::new());
703 }
704 let entries = value
705 .as_array()
706 .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
707 entries
708 .iter()
709 .map(|entry| {
710 let units = entry
711 .get("units")
712 .and_then(Value::as_array)
713 .map(|values| {
714 values
715 .iter()
716 .filter_map(|value| value.as_str().map(str::to_owned))
717 .collect()
718 })
719 .unwrap_or_default();
720 Ok(WindowCoverage {
721 base: parse_window_base(entry.get("base"))?,
722 units,
723 })
724 })
725 .collect()
726}
727
728fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
729 for event in core.drain_events() {
730 emit(&event.json);
731 }
732}
733
734#[tauri::command]
737async fn syncular_command<R: Runtime>(
738 app: tauri::AppHandle<R>,
739 command: Value,
740) -> Result<Value, String> {
741 let state = app.state::<SyncularState>();
742 let method = command
743 .get("method")
744 .and_then(Value::as_str)
745 .unwrap_or("")
746 .to_owned();
747 if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
748 state.security_gate.begin_preflight()?;
751 }
752 let mut reader = if method == "create" || method == "shutdown" {
754 Some(
755 state
756 .reader
757 .lock()
758 .map_err(|_| "syncular read mailbox poisoned".to_owned())?,
759 )
760 } else {
761 None
762 };
763 let mut command = command;
764 let mut database = None;
765 if method == "create" {
766 if let Some(reads) = command.pointer("/params/authorityReads") {
767 let declarations: Vec<syncular_client::AuthorityReadDeclaration> =
768 match serde_json::from_value(reads.clone()) {
769 Ok(value) => value,
770 Err(_) => {
771 return Ok(
772 json!({"error": {"code": "client.authority_read_forbidden", "message": "invalid authority declaration"}}),
773 )
774 }
775 };
776 if !declarations.iter().all(|read| {
777 state
778 .config
779 .authority_columns
780 .get(&read.table)
781 .is_some_and(|columns| read.columns.iter().all(|c| columns.contains(c)))
782 }) {
783 return Ok(
784 json!({"error": {"code": "client.authority_read_forbidden", "message": "authority declaration exceeds the native app policy"}}),
785 );
786 }
787 }
788 database =
789 match resolve_database(&state.config, command.get("params").unwrap_or(&Value::Null)) {
790 Ok(path) => path,
791 Err(reply) => return Ok(reply),
792 };
793 if let Some(path) = &database {
794 match command.get_mut("params").and_then(Value::as_object_mut) {
795 Some(params) => {
796 params.insert("dbPath".to_owned(), Value::from(path.clone()));
797 }
798 None => {
799 if let Some(object) = command.as_object_mut() {
800 object.insert("params".to_owned(), json!({ "dbPath": path }));
801 }
802 }
803 }
804 }
805 }
806 let create_preflight = method == "create"
807 && command
808 .pointer("/params/securityPreflight")
809 .and_then(Value::as_bool)
810 .unwrap_or(false);
811 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
812 state.send(Request::Command {
813 command,
814 reply: reply_tx,
815 })?;
816 let reply = reply_rx
817 .recv()
818 .map_err(|_| "the syncular core dropped the reply".to_owned())?;
819 let succeeded = reply.get("error").is_none();
820 if let (true, Some(slot)) = (succeeded, reader.as_mut()) {
821 let next = match database.filter(|path| path.as_str() != ":memory:") {
822 Some(path) => Some(spawn_reader(
823 path,
824 state
825 .sender
826 .lock()
827 .map_err(|_| "syncular mailbox poisoned".to_owned())?
828 .clone(),
829 )?),
830 None => None,
831 };
832 if let Some(previous) = std::mem::replace(&mut **slot, next) {
833 let _ = previous.send(ReadRequest::Shutdown);
834 }
835 }
836 drop(reader);
837 if succeeded && method == "create" {
838 if !create_preflight {
839 state.security_gate.activate()?;
840 }
841 } else if succeeded && method == "activateSecurity" {
842 state.security_gate.activate()?;
843 }
844 Ok(reply)
845}
846
847#[tauri::command]
852async fn syncular_set_headers<R: Runtime>(
853 app: tauri::AppHandle<R>,
854 headers: std::collections::BTreeMap<String, String>,
855) -> Result<Value, String> {
856 let state = app.state::<SyncularState>();
857 let _active_guard = match state.security_gate.enter_read() {
861 Ok(guard) => guard,
862 Err(reply) => return Ok(reply),
863 };
864 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
865 state.send(Request::SetHeaders {
866 headers: headers.into_iter().collect(),
867 reply: reply_tx,
868 })?;
869 reply_rx
870 .recv()
871 .map_err(|_| "the syncular core dropped the reply".to_owned())
872}
873
874#[tauri::command]
875async fn syncular_query<R: Runtime>(
876 app: tauri::AppHandle<R>,
877 sql: String,
878 params: Option<Value>,
879) -> Result<Value, String> {
880 let state = app.state::<SyncularState>();
881 let _read_guard = match state.security_gate.enter_read() {
882 Ok(guard) => guard,
883 Err(reply) => return Ok(reply),
884 };
885 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
886 state.send(Request::Query {
887 sql,
888 params: params.unwrap_or(Value::Null),
889 reply: reply_tx,
890 })?;
891 reply_rx
892 .recv()
893 .map_err(|_| "the syncular core dropped the reply".to_owned())
894}
895
896#[tauri::command]
900async fn syncular_query_snapshot<R: Runtime>(
901 app: tauri::AppHandle<R>,
902 sql: String,
903 params: Option<Value>,
904 coverage: Option<Value>,
905 owner: Option<Value>,
906) -> Result<Value, String> {
907 let state = app.state::<SyncularState>();
908 let _read_guard = match state.security_gate.enter_read() {
909 Ok(guard) => guard,
910 Err(reply) => return Ok(reply),
911 };
912 let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
913 let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
914
915 if let Some(reader) = state.reader()? {
916 let bind = match params_value.as_array() {
917 Some(values) => values.clone(),
918 None => return Ok(client_error("querySnapshot params must be a list")),
919 };
920 let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
921 Ok(value) => value,
922 Err(message) => return Ok(client_error(message)),
923 };
924 let parsed_owner = match &owner {
926 None | Some(Value::Null) => None,
927 Some(value) => {
928 let id = value
929 .get("id")
930 .and_then(Value::as_str)
931 .filter(|id| !id.is_empty());
932 let tables = value
933 .get("tables")
934 .and_then(Value::as_array)
935 .and_then(|list| {
936 list.iter()
937 .map(|table| table.as_str().map(str::to_owned))
938 .collect::<Option<Vec<String>>>()
939 });
940 match (id, tables) {
941 (Some(id), Some(tables)) => Some((id.to_owned(), tables)),
942 _ => {
943 return Ok(json!({ "error": {
944 "code": "sync.invalid_request",
945 "message": "sync.invalid_request: querySnapshot owner must be {id: non-empty string, tables: string[]}",
946 } }))
947 }
948 }
949 }
950 };
951 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
952 reader
953 .send(ReadRequest::QuerySnapshot {
954 sql,
955 params: bind,
956 coverage: parsed_coverage,
957 owner: parsed_owner,
958 reply: reply_tx,
959 })
960 .map_err(|_| "the syncular read thread has stopped".to_owned())?;
961 return reply_rx
962 .recv()
963 .map_err(|_| "the syncular read thread dropped the reply".to_owned());
964 }
965
966 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
967 state.send(Request::Command {
968 command: json!({
969 "method": "querySnapshot",
970 "params": {
971 "sql": sql,
972 "params": params_value,
973 "coverage": coverage_value,
974 "owner": owner.unwrap_or(Value::Null),
975 }
976 }),
977 reply: reply_tx,
978 })?;
979 reply_rx
980 .recv()
981 .map_err(|_| "the syncular core dropped the reply".to_owned())
982}
983
984pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
992 Builder::<R>::new("syncular")
993 .invoke_handler(tauri::generate_handler![
994 syncular_command,
995 syncular_query,
996 syncular_query_snapshot,
997 syncular_set_headers
998 ])
999 .setup(move |app, _api| {
1000 let (tx, rx) = std::sync::mpsc::channel::<Request>();
1001 app.manage(SyncularState {
1002 sender: Mutex::new(tx.clone()),
1003 reader: Mutex::new(None),
1004 config: config.clone(),
1005 security_gate: SecurityGate::new_preflight(),
1008 });
1009 let app_handle = app.clone();
1010 let emit = move |value: &Value| {
1011 let _ = app_handle.emit(EVENT_NAME, value.clone());
1014 };
1015 std::thread::Builder::new()
1016 .name("syncular-core".to_owned())
1017 .spawn(move || run_owner_thread(config, tx, rx, emit))
1018 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
1019 Ok(())
1020 })
1021 .on_event(|app, event| {
1022 if let RunEvent::Exit = event {
1023 if let Some(state) = app.try_state::<SyncularState>() {
1024 let _ = state.send(Request::Shutdown);
1025 if let Ok(Some(reader)) = state.reader() {
1026 let _ = reader.send(ReadRequest::Shutdown);
1027 }
1028 }
1029 }
1030 })
1031 .build()
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use super::*;
1037
1038 #[test]
1039 fn config_to_transport_json_shapes_fields() {
1040 let config = SyncularConfig {
1041 base_url: Some("https://api.example.com".to_owned()),
1042 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
1043 ..Default::default()
1044 };
1045 let json = config.to_transport_json();
1046 assert_eq!(json["baseUrl"], "https://api.example.com");
1047 assert_eq!(json["headers"]["authorization"], "Bearer x");
1048 }
1049
1050 #[test]
1051 fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
1052 let gate = std::sync::Arc::new(SecurityGate::new_preflight());
1053 gate.activate().expect("activate test gate");
1054 let read = gate.enter_read().expect("active read");
1055 let gate_for_barrier = std::sync::Arc::clone(&gate);
1056 let (done_tx, done_rx) = std::sync::mpsc::channel();
1057 let barrier = std::thread::spawn(move || {
1058 gate_for_barrier.begin_preflight().expect("enter preflight");
1059 done_tx.send(()).expect("barrier reply");
1060 });
1061
1062 assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
1063 drop(read);
1064 done_rx
1065 .recv_timeout(Duration::from_secs(1))
1066 .expect("barrier drains after the read");
1067 barrier.join().expect("barrier thread");
1068 assert!(gate.enter_read().is_err());
1069 }
1070
1071 #[test]
1072 fn direct_set_headers_command_respects_the_native_preflight_gate() {
1073 use std::collections::BTreeMap;
1074 use tauri::test::{mock_builder, mock_context, noop_assets};
1075
1076 let app = mock_builder()
1077 .plugin(init(SyncularConfig {
1078 auto_sync: false,
1079 ..Default::default()
1080 }))
1081 .build(mock_context(noop_assets()))
1082 .expect("build mock app");
1083
1084 let blocked = tauri::async_runtime::block_on(syncular_set_headers(
1085 app.handle().clone(),
1086 BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
1087 ))
1088 .expect("preflight reply");
1089 assert_eq!(
1090 blocked["error"]["code"],
1091 Value::from("client.security_preflight_required")
1092 );
1093
1094 let created = tauri::async_runtime::block_on(syncular_command(
1095 app.handle().clone(),
1096 json!({
1097 "method": "create",
1098 "params": {
1099 "clientId": "native-header-gate",
1100 "schema": { "version": 1, "tables": [] }
1101 }
1102 }),
1103 ))
1104 .expect("create reply");
1105 assert!(created.get("error").is_none(), "{created}");
1106
1107 let active = tauri::async_runtime::block_on(syncular_set_headers(
1108 app.handle().clone(),
1109 BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
1110 ))
1111 .expect("active reply");
1112 assert_eq!(active["result"], Value::Null);
1113 }
1114
1115 #[test]
1116 fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
1117 use tauri::test::{mock_builder, mock_context, noop_assets};
1118
1119 let app = mock_builder()
1120 .plugin(init(SyncularConfig {
1121 auto_sync: false,
1122 ..Default::default()
1123 }))
1124 .build(mock_context(noop_assets()))
1125 .expect("build mock app");
1126 let command = |value: Value| {
1127 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1128 .expect("command reply")
1129 };
1130
1131 let created = command(json!({
1132 "method": "create",
1133 "params": {
1134 "clientId": "native-preflight-escape",
1135 "schema": { "version": 1, "tables": [] },
1136 "securityPreflight": true
1137 }
1138 }));
1139 assert!(created.get("error").is_none(), "{created}");
1140
1141 let escape = command(json!({
1144 "method": "create",
1145 "params": {
1146 "clientId": "native-preflight-escape",
1147 "schema": { "version": 1, "tables": [] }
1148 }
1149 }));
1150 assert_eq!(
1151 escape["error"]["code"],
1152 Value::from("client.security_preflight_required"),
1153 "{escape}"
1154 );
1155 let read = tauri::async_runtime::block_on(syncular_query(
1156 app.handle().clone(),
1157 "SELECT 1".to_owned(),
1158 None,
1159 ))
1160 .expect("query reply");
1161 assert_eq!(
1162 read["error"]["code"],
1163 Value::from("client.security_preflight_required")
1164 );
1165
1166 let activated = command(json!({
1168 "method": "activateSecurity",
1169 "params": { "headers": { "authorization": "Bearer fresh" } }
1170 }));
1171 assert!(activated.get("error").is_none(), "{activated}");
1172 let read = tauri::async_runtime::block_on(syncular_query(
1173 app.handle().clone(),
1174 "SELECT 1 AS value".to_owned(),
1175 None,
1176 ))
1177 .expect("query reply");
1178 assert_eq!(read["result"]["rows"][0]["value"], 1);
1179 let recreated = command(json!({
1180 "method": "create",
1181 "params": {
1182 "clientId": "native-preflight-escape",
1183 "schema": { "version": 1, "tables": [] }
1184 }
1185 }));
1186 assert!(recreated.get("error").is_none(), "{recreated}");
1187 }
1188
1189 #[test]
1190 fn authority_snapshot_raw_ipc_obeys_native_columns_and_never_opens_protected_reads() {
1191 use tauri::test::{mock_builder, mock_context, noop_assets};
1192 let path =
1193 std::env::temp_dir().join(format!("syncular-authority-ipc-{}.db", std::process::id()));
1194 let app = mock_builder()
1195 .plugin(init(SyncularConfig {
1196 auto_sync: false,
1197 db_path: Some(path.to_string_lossy().into_owned()),
1198 authority_columns: [(
1199 "authority".into(),
1200 vec!["id".into(), "actor_id".into(), "role".into()],
1201 )]
1202 .into(),
1203 ..Default::default()
1204 }))
1205 .build(mock_context(noop_assets()))
1206 .expect("mock app");
1207 let handle = app.handle().clone();
1208 let command = |value: Value| {
1209 tauri::async_runtime::block_on(syncular_command(handle.clone(), value))
1210 .expect("IPC reply")
1211 };
1212 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
1213 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "role", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
1214 ]}]});
1215 let policy = json!([{"table": "authority", "columns": ["id", "actor_id", "role"], "scopes": {"actor_id": ["a", "b"]}}]);
1216 let create = |reads: Value| json!({"method": "create", "params": {"schema": schema, "securityPreflight": true, "transportEnabled": false, "authorityReads": reads}});
1217 let created = command(create(policy.clone()));
1218 assert!(created.get("error").is_none(), "{created}");
1219 let conn = rusqlite::Connection::open(&path).expect("fixture db");
1220 conn.execute_batch("INSERT INTO _syncular_base_authority(id,actor_id,role,secret,_syncular_version)VALUES('one','a','accepted','clinical plaintext',7); INSERT INTO authority SELECT * FROM _syncular_base_authority;").expect("fixture base");
1221 for actor in ["a", "b"] {
1222 let state = json!({"requested": {"actor_id": [actor]}, "effectiveScopes": {"actor_id": [actor]}, "status": "active", "cursor": 7});
1223 conn.execute(
1224 "INSERT INTO _syncular_subscriptions(id,tbl,state_json)VALUES(?1,'authority',?2)",
1225 rusqlite::params![actor, state.to_string()],
1226 )
1227 .expect("fixture subscription");
1228 }
1229 let snapshot = command(json!({"method": "authoritySnapshot", "params": {}}));
1230 assert_eq!(snapshot["result"]["complete"], true, "{snapshot}");
1231 assert_eq!(
1232 snapshot["result"]["tables"][0]["rows"][0]["values"],
1233 json!({"id": "one", "actor_id": "a", "role": "accepted"})
1234 );
1235 assert!(!snapshot.to_string().contains("clinical plaintext"));
1236 assert_eq!(
1237 command(json!({"method": "securityLifecycle", "params": {}}))["result"]["state"],
1238 "preflight"
1239 );
1240 for params in [
1241 json!({"sql": "SELECT secret FROM authority"}),
1242 json!({"columns": ["secret"]}),
1243 json!({"table": "_syncular_meta"}),
1244 json!({"scopes": {"actor_id": ["outside"]}}),
1245 json!({"authorityReads": policy}),
1246 ] {
1247 assert_eq!(
1248 command(json!({"method": "authoritySnapshot", "params": params}))["error"]["code"],
1249 "client.authority_read_forbidden"
1250 );
1251 }
1252 for columns in [
1253 json!(["id", "actor_id", "secret"]),
1254 json!([
1255 "id",
1256 "actor_id",
1257 "role FROM authority; DELETE FROM authority"
1258 ]),
1259 ] {
1260 assert_eq!(
1261 command(create(
1262 json!([{"table": "authority", "columns": columns, "scopes": {"actor_id": ["a"]}}])
1263 ))["error"]["code"],
1264 "client.authority_read_forbidden"
1265 );
1266 }
1267 for method in [
1268 "query",
1269 "querySnapshot",
1270 "mutate",
1271 "setHeaders",
1272 "subscribe",
1273 "sync",
1274 ] {
1275 assert_eq!(
1276 command(json!({"method": method, "params": {"sql": "SELECT id FROM authority"}}))
1277 ["error"]["code"],
1278 "client.security_preflight_required"
1279 );
1280 }
1281 assert_eq!(
1282 tauri::async_runtime::block_on(syncular_query(
1283 app.handle().clone(),
1284 "SELECT id FROM authority".into(),
1285 None
1286 ))
1287 .unwrap()["error"]["code"],
1288 "client.security_preflight_required"
1289 );
1290 assert_eq!(
1291 tauri::async_runtime::block_on(syncular_query_snapshot(
1292 app.handle().clone(),
1293 "SELECT id FROM authority".into(),
1294 None,
1295 None,
1296 None
1297 ))
1298 .unwrap()["error"]["code"],
1299 "client.security_preflight_required"
1300 );
1301 let before = snapshot["result"]["revision"].clone();
1302 assert_eq!(
1303 command(json!({"method": "authoritySnapshot", "params": {}}))["result"]["revision"],
1304 before
1305 );
1306 conn.execute("UPDATE _syncular_subscriptions SET state_json=?1 WHERE id='b'", [json!({"requested": {"actor_id": ["b"]}, "effectiveScopes": {"actor_id": ["b"]}, "status": "active", "cursor": 7, "bootstrapState": "credential-do-not-expose"}).to_string()]).unwrap();
1307 let partial = command(json!({"method": "authoritySnapshot", "params": {}}));
1308 assert_eq!(partial["result"]["complete"], false);
1309 assert!(!partial.to_string().contains("credential-do-not-expose"));
1310 conn.execute(
1311 "UPDATE _syncular_subscriptions SET state_json='credential-do-not-expose' WHERE id='b'",
1312 [],
1313 )
1314 .unwrap();
1315 let corrupt = command(json!({"method": "authoritySnapshot", "params": {}}));
1316 assert_eq!(corrupt["error"]["code"], "sync.local_corrupt");
1317 assert!(!corrupt.to_string().contains("credential-do-not-expose"));
1318 conn.execute("DELETE FROM _syncular_subscriptions WHERE id='b'", [])
1319 .unwrap();
1320 command(json!({"method": "beginSecurityPreflight", "params": {}}));
1321 assert!(
1322 command(json!({"method": "authoritySnapshot", "params": {}}))
1323 .get("error")
1324 .is_none()
1325 );
1326 let barrier = std::sync::Barrier::new(2);
1328 std::thread::scope(|scope| {
1329 let locking = scope.spawn(|| {
1330 barrier.wait();
1331 command(json!({"method": "beginSecurityPreflight", "params": {}}))
1332 });
1333 barrier.wait();
1334 let reading = command(json!({"method": "authoritySnapshot", "params": {}}));
1335 assert_eq!(reading["result"]["revision"], before);
1336 assert!(locking.join().unwrap().get("error").is_none());
1337 });
1338 let barrier = std::sync::Barrier::new(2);
1340 std::thread::scope(|scope| {
1341 let closing = scope.spawn(|| {
1342 barrier.wait();
1343 command(json!({"method": "shutdown", "params": {}}))
1344 });
1345 barrier.wait();
1346 let reading = command(json!({"method": "authoritySnapshot", "params": {}}));
1347 assert!(
1348 reading["result"]["revision"] == before
1349 || reading["error"]["code"] == "client.closed",
1350 "{reading}"
1351 );
1352 assert!(closing.join().unwrap().get("error").is_none());
1353 });
1354 assert_eq!(
1355 command(json!({"method": "authoritySnapshot", "params": {}}))["error"]["code"],
1356 "client.closed"
1357 );
1358 drop(conn);
1359 let _ = std::fs::remove_file(path);
1360 }
1361
1362 #[test]
1363 fn resolve_database_names_a_file_inside_the_configured_directory() {
1364 let dir =
1365 std::env::temp_dir().join(format!("syncular-tauri-resolve-{}", std::process::id()));
1366 let config = SyncularConfig {
1367 db_path: Some("/tmp/default.db".to_owned()),
1368 database_dir: Some(dir.to_string_lossy().into_owned()),
1369 ..Default::default()
1370 };
1371 assert_eq!(
1373 resolve_database(&config, &json!({ "clientId": "c1" })),
1374 Ok(Some("/tmp/default.db".to_owned()))
1375 );
1376 assert_eq!(
1378 resolve_database(&config, &json!({ "database": "app-actor-0f.v2" })),
1379 Ok(Some(
1380 dir.join("app-actor-0f.v2.db")
1381 .to_string_lossy()
1382 .into_owned()
1383 ))
1384 );
1385 assert!(dir.is_dir());
1386 for params in [
1389 json!({ "dbPath": "/etc/other.db" }),
1390 json!({ "database": "../escape" }),
1391 json!({ "database": "a/b" }),
1392 json!({ "database": "a\\b" }),
1393 json!({ "database": "/abs" }),
1394 json!({ "database": ".hidden" }),
1395 json!({ "database": "a..b" }),
1396 json!({ "database": "" }),
1397 json!({ "database": "x".repeat(129) }),
1398 json!({ "database": 7 }),
1399 ] {
1400 let refused = resolve_database(&config, ¶ms).expect_err("refused");
1401 assert_eq!(refused["error"]["code"], "sync.invalid_request", "{params}");
1402 }
1403 let refused = resolve_database(
1406 &SyncularConfig {
1407 database_dir: Some(dir.to_string_lossy().into_owned()),
1408 ..Default::default()
1409 },
1410 &json!({ "clientId": "c1" }),
1411 )
1412 .expect_err("name required");
1413 assert_eq!(refused["error"]["code"], "sync.invalid_request");
1414 let refused = resolve_database(&SyncularConfig::default(), &json!({ "database": "actor" }))
1417 .expect_err("no directory");
1418 assert_eq!(refused["error"]["code"], "sync.invalid_request");
1419 std::fs::remove_dir_all(dir).expect("remove temp directory");
1420 }
1421
1422 #[test]
1425 fn named_databases_keep_one_replica_and_client_id_per_actor() {
1426 use tauri::test::{mock_builder, mock_context, noop_assets};
1427
1428 let dir =
1429 std::env::temp_dir().join(format!("syncular-tauri-actors-{}", std::process::id()));
1430 let _ = std::fs::remove_dir_all(&dir);
1431 let app = mock_builder()
1432 .plugin(init(SyncularConfig {
1433 database_dir: Some(dir.to_string_lossy().into_owned()),
1434 auto_sync: false,
1435 ..Default::default()
1436 }))
1437 .build(mock_context(noop_assets()))
1438 .expect("build mock app");
1439 let command = |value: Value| {
1440 tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1441 .expect("command reply")
1442 };
1443 let snapshot = |sql: &str| {
1444 tauri::async_runtime::block_on(syncular_query_snapshot(
1445 app.handle().clone(),
1446 sql.to_owned(),
1447 None,
1448 None,
1449 None,
1450 ))
1451 .expect("snapshot reply")
1452 };
1453 let schema = json!({
1454 "version": 1,
1455 "tables": [{
1456 "name": "todo", "primaryKey": "id",
1457 "columns": [
1458 { "name": "id", "type": "string", "nullable": false },
1459 { "name": "title", "type": "string", "nullable": false }
1460 ],
1461 "scopes": []
1462 }]
1463 });
1464 let create = |database: &str, client_id: &str| {
1465 command(json!({ "method": "create", "params": {
1466 "database": database, "clientId": client_id, "schema": schema
1467 } }))
1468 };
1469
1470 assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1471 command(json!({ "method": "mutate", "params": { "mutations": [{
1472 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "a" }
1473 }] } }));
1474 assert_eq!(
1475 snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1476 "a"
1477 );
1478 assert_eq!(
1479 command(json!({ "method": "shutdown" }))["result"],
1480 json!({})
1481 );
1482
1483 assert_eq!(create("actor-b", "client-b")["result"], json!({}));
1484 assert_eq!(
1485 snapshot("SELECT title FROM todo")["result"]["rows"],
1486 json!([]),
1487 "the reader follows the second actor's database"
1488 );
1489 assert!(dir.join("actor-a.db").is_file());
1490 assert!(dir.join("actor-b.db").is_file());
1491 assert_eq!(
1492 command(json!({ "method": "shutdown" }))["result"],
1493 json!({})
1494 );
1495
1496 assert_eq!(
1498 create("actor-a", "client-b")["error"]["code"],
1499 "client.identity_mismatch"
1500 );
1501 assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1502 assert_eq!(
1503 snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1504 "a"
1505 );
1506 assert_eq!(
1507 create("../actor-a", "client-a")["error"]["code"],
1508 "sync.invalid_request"
1509 );
1510 command(json!({ "method": "shutdown" }));
1511 std::fs::remove_dir_all(dir).expect("remove temp directory");
1512 }
1513
1514 #[test]
1515 fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
1516 let parsed = parse_coverage(Some(&json!([{
1517 "base": {
1518 "table": "tasks",
1519 "variable": "project_id",
1520 "fixedScopes": { "tenant_id": ["one", "two"] },
1521 "params": "opaque"
1522 },
1523 "units": ["a", "b"]
1524 }])))
1525 .expect("parse coverage");
1526 assert_eq!(parsed.len(), 1);
1527 let entry = &parsed[0];
1528 assert_eq!(entry.base.table, "tasks");
1529 assert_eq!(entry.base.variable, "project_id");
1530 assert_eq!(
1531 entry.base.fixed_scopes,
1532 vec![(
1533 "tenant_id".to_owned(),
1534 vec!["one".to_owned(), "two".to_owned()]
1535 )]
1536 );
1537 assert_eq!(entry.base.params.as_deref(), Some("opaque"));
1538 assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
1539 }
1540
1541 #[test]
1545 fn owner_thread_round_trips_over_mailbox() {
1546 use std::sync::mpsc::channel;
1547 use std::sync::{Arc, Mutex as StdMutex};
1548
1549 let (tx, rx) = channel::<Request>();
1550 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
1551 let events_for_thread = Arc::clone(&events);
1552 let config = SyncularConfig {
1553 auto_sync: false,
1554 ..Default::default()
1555 };
1556 let owner_tx = tx.clone();
1557 let handle = std::thread::spawn(move || {
1558 run_owner_thread(config, owner_tx, rx, move |v| {
1559 events_for_thread.lock().unwrap().push(v.clone());
1560 });
1561 });
1562
1563 let call = |command: Value| -> Value {
1564 let (rtx, rrx) = channel();
1565 tx.send(Request::Command {
1566 command,
1567 reply: rtx,
1568 })
1569 .unwrap();
1570 rrx.recv().unwrap()
1571 };
1572
1573 let schema = json!({
1574 "version": 1,
1575 "tables": [{
1576 "name": "todo", "primaryKey": "id",
1577 "columns": [
1578 { "name": "id", "type": "string", "nullable": false },
1579 { "name": "title", "type": "string", "nullable": false }
1580 ],
1581 "scopes": []
1582 }]
1583 });
1584 assert_eq!(
1585 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1586 ["result"],
1587 json!({})
1588 );
1589 call(json!({ "method": "mutate", "params": { "mutations": [{
1590 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1591 }] } }));
1592
1593 let (qtx, qrx) = channel();
1595 tx.send(Request::Query {
1596 sql: "SELECT title FROM todo".to_owned(),
1597 params: Value::Null,
1598 reply: qtx,
1599 })
1600 .unwrap();
1601 let rows = qrx.recv().unwrap();
1602 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1603
1604 let (htx, hrx) = channel();
1607 tx.send(Request::SetHeaders {
1608 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1609 reply: htx,
1610 })
1611 .unwrap();
1612 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1613
1614 tx.send(Request::Shutdown).unwrap();
1615 handle.join().unwrap();
1616
1617 let seen = events.lock().unwrap();
1618 let kinds: Vec<String> = seen
1619 .iter()
1620 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1621 .collect();
1622 assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1624 }
1625
1626 #[cfg(feature = "native-transport")]
1627 #[test]
1628 fn pending_network_round_keeps_local_mutations_and_queries_responsive() {
1629 use ssp2::model::{Change, Frame, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
1630 use ssp2::segment::{encode_row, Column, ColumnType, ColumnValue};
1631 use std::io::{Read, Write};
1632 use std::net::TcpListener;
1633 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1634 use std::sync::mpsc::channel;
1635 use std::sync::Arc;
1636
1637 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1638 let address = listener.local_addr().unwrap();
1639 let (entered_tx, entered_rx) = channel();
1640 let (release_tx, release_rx) = channel();
1641 let (delivered_tx, delivered_rx) = channel();
1642 let (ack_tx, ack_rx) = channel();
1643 let image_sent = Arc::new(AtomicBool::new(false));
1644 let server_image_sent = image_sent.clone();
1645 let requests = Arc::new(AtomicUsize::new(0));
1646 let server_requests = requests.clone();
1647 let server = std::thread::spawn(move || {
1648 let mut pushed = Vec::new();
1649 loop {
1650 let (mut socket, _) = listener.accept().unwrap();
1651 server_requests.fetch_add(1, Ordering::SeqCst);
1652 let mut header = Vec::new();
1653 while !header.ends_with(b"\r\n\r\n") {
1654 let mut byte = [0];
1655 socket.read_exact(&mut byte).unwrap();
1656 header.push(byte[0]);
1657 }
1658 let header = String::from_utf8(header).unwrap();
1659 assert!(header
1660 .to_ascii_lowercase()
1661 .contains("authorization: bearer fresh"));
1662 let length: usize = header
1663 .lines()
1664 .find_map(|line| {
1665 let (name, value) = line.split_once(':')?;
1666 name.eq_ignore_ascii_case("content-length")
1667 .then(|| value.trim().parse().unwrap())
1668 })
1669 .unwrap();
1670 let mut bytes = vec![0; length];
1671 socket.read_exact(&mut bytes).unwrap();
1672 let request = ssp2::decode_message(&bytes).unwrap();
1673 let reset = matches!(
1674 &request.frames[0],
1675 Frame::ReqHeader {
1676 log_epoch: None,
1677 ..
1678 }
1679 );
1680 let deliver = !reset && pushed.len() == 3;
1681 let mut frames = vec![Frame::RespHeader {
1682 required_schema_version: None,
1683 latest_schema_version: None,
1684 log_epoch: Some("epoch".into()),
1685 reset_required: Some(reset),
1686 }];
1687 if !reset {
1688 let commits: Vec<_> = request
1689 .frames
1690 .iter()
1691 .filter_map(|frame| {
1692 if let Frame::PushCommit {
1693 client_commit_id,
1694 operations,
1695 } = frame
1696 {
1697 Some((client_commit_id.clone(), operations.len()))
1698 } else {
1699 None
1700 }
1701 })
1702 .collect();
1703 if !commits.is_empty() && pushed.is_empty() {
1704 entered_tx.send(()).unwrap();
1705 release_rx.recv().unwrap();
1706 }
1707 for (id, count) in commits {
1708 pushed.push(id.clone());
1709 frames.push(Frame::PushResult {
1710 client_commit_id: id,
1711 status: PushStatus::Applied,
1712 commit_seq: Some(pushed.len() as i64),
1713 results: (0..count)
1714 .map(|i| OpResult::Applied { op_index: i as i32 })
1715 .collect(),
1716 });
1717 }
1718 }
1719 if !reset
1720 && request
1721 .frames
1722 .iter()
1723 .any(|frame| matches!(frame, Frame::Subscription { id, .. } if id == "own"))
1724 {
1725 frames.push(Frame::SubStart {
1726 id: "own".into(),
1727 status: SubStatus::Active,
1728 reason_code: String::new(),
1729 effective_scopes: vec![("id".into(), vec!["one".into()])],
1730 bootstrap: false,
1731 });
1732 if deliver {
1733 assert!(request
1734 .frames
1735 .iter()
1736 .any(|frame| matches!(frame, Frame::PullHeader { .. })));
1737 assert!(!request
1738 .frames
1739 .iter()
1740 .any(|frame| matches!(frame, Frame::PushCommit { .. })));
1741 let columns = ["id", "title"].map(|name| Column {
1742 name: name.into(),
1743 ty: ColumnType::String,
1744 nullable: false,
1745 });
1746 let mut row = ssp2::primitives::Writer::new();
1747 encode_row(
1748 &mut row,
1749 &columns,
1750 &vec![
1751 Some(ColumnValue::String("one".into())),
1752 Some(ColumnValue::String("300/v3".into())),
1753 ],
1754 );
1755 frames.push(Frame::Commit {
1756 commit_seq: 3,
1757 created_at_ms: 0,
1758 actor_id: "actor".into(),
1759 tables: vec!["todo".into()],
1760 changes: vec![Change {
1761 table_index: 0,
1762 row_id: "one".into(),
1763 op: Op::Upsert,
1764 row_version: Some(3),
1765 scopes: vec![("id".into(), "one".into())],
1766 row: Some(row.into_bytes()),
1767 }],
1768 });
1769 server_image_sent.store(true, Ordering::SeqCst);
1770 }
1771 frames.push(Frame::SubEnd {
1772 next_cursor: if deliver { 3 } else { 0 },
1773 bootstrap_state: None,
1774 });
1775 }
1776 let response = ssp2::encode_message(&Message {
1777 wire_version: ssp2::decode::WIRE_VERSION,
1778 msg_kind: MsgKind::Response,
1779 frames,
1780 });
1781 write!(
1782 socket,
1783 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1784 response.len()
1785 )
1786 .unwrap();
1787 socket.write_all(&response).unwrap();
1788 if deliver {
1789 break;
1790 }
1791 }
1792 pushed
1793 });
1794 let (tx, rx) = channel();
1795 let owner_tx = tx.clone();
1796 let owner = std::thread::spawn(move || {
1797 run_owner_thread(
1798 SyncularConfig {
1799 base_url: Some(format!("http://{address}")),
1800 auto_sync: true,
1801 ..Default::default()
1802 },
1803 owner_tx,
1804 rx,
1805 move |event| {
1806 if event["type"] == "change" && event["batch"]["outcomesChanged"] == true {
1807 let _ = ack_tx.send(());
1808 }
1809 if image_sent.load(Ordering::SeqCst) && event["type"] == "change" {
1810 let _ = delivered_tx.send(());
1811 }
1812 },
1813 )
1814 });
1815 let call = |command: Value| {
1816 let (reply, result) = channel();
1817 tx.send(Request::Command { command, reply }).unwrap();
1818 result
1819 };
1820 let created = call(json!({ "method": "create", "params": {
1821 "clientId": "pending-round", "transportEnabled": false, "securityPreflight": true, "schema": { "version": 1, "tables": [{
1822 "name": "todo", "primaryKey": "id", "columns": [
1823 { "name": "id", "type": "string", "nullable": false },
1824 { "name": "title", "type": "string", "nullable": false }
1825 ], "scopes": [{ "pattern": "todo:{id}" }]
1826 }] }
1827 } }));
1828 assert!(created.recv().unwrap().get("error").is_none());
1829 assert!(call(json!({ "method": "activateSecurity", "params": {} }))
1830 .recv()
1831 .unwrap()
1832 .get("error")
1833 .is_none());
1834 assert!(call(json!({ "method": "subscribe", "params": { "id": "own", "table": "todo", "scopes": { "id": ["one"] } } })).recv().unwrap().get("error").is_none());
1835 let first = call(json!({ "method": "mutate", "params": { "mutations": [{
1836 "op": "upsert", "table": "todo", "values": { "id": "one", "title": "100/v1" }
1837 }] } }))
1838 .recv()
1839 .unwrap();
1840 let queued_second = call(json!({ "method": "mutate", "params": { "mutations": [{
1841 "op": "patch", "table": "todo", "values": { "id": "one", "title": "200/v2" }
1842 }] } }))
1843 .recv()
1844 .unwrap();
1845 assert_eq!(
1846 call(json!({ "method": "sync" })).recv().unwrap()["result"]["errorCode"],
1847 "sync.offline"
1848 );
1849 assert_eq!(
1850 call(json!({ "method": "connectRealtime" })).recv().unwrap()["error"]["code"],
1851 "sync.offline"
1852 );
1853 assert_eq!(requests.load(Ordering::SeqCst), 0);
1854 let (reply, result) = channel();
1855 tx.send(Request::SetHeaders {
1856 headers: vec![("authorization".into(), "Bearer fresh".into())],
1857 reply,
1858 })
1859 .unwrap();
1860 assert_eq!(result.recv().unwrap()["result"], Value::Null);
1861 assert!(
1862 call(json!({ "method": "setTransportEnabled", "params": { "enabled": true } }))
1863 .recv()
1864 .unwrap()
1865 .get("error")
1866 .is_none()
1867 );
1868 entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
1869 assert!(
1870 call(json!({ "method": "setTransportEnabled", "params": { "enabled": false } }))
1871 .recv()
1872 .unwrap()
1873 .get("error")
1874 .is_none()
1875 );
1876 let started = Instant::now();
1877 let second = call(json!({ "method": "mutate", "params": { "mutations": [{
1878 "op": "patch", "table": "todo", "values": { "id": "one", "title": "300/v3" }
1879 }] } }));
1880 let local_reply = second.recv_timeout(Duration::from_millis(100));
1881 let elapsed = started.elapsed();
1882 let (query_tx, query_rx) = channel();
1883 tx.send(Request::Query {
1884 sql: "SELECT title FROM todo ORDER BY id".into(),
1885 params: Value::Null,
1886 reply: query_tx,
1887 })
1888 .unwrap();
1889 let local_rows = query_rx.recv_timeout(Duration::from_millis(100));
1890 release_tx.send(()).unwrap();
1893 let second_reply = local_reply
1894 .clone()
1895 .unwrap_or_else(|_| second.recv().unwrap());
1896 let rows = local_rows
1897 .clone()
1898 .unwrap_or_else(|_| query_rx.recv().unwrap());
1899 loop {
1901 ack_rx.recv_timeout(Duration::from_secs(10)).unwrap();
1902 let outcomes = call(json!({ "method": "commitOutcomes" })).recv().unwrap();
1903 if outcomes["result"]["outcomes"]
1904 .as_array()
1905 .unwrap()
1906 .iter()
1907 .filter(|o| o["status"] == "applied")
1908 .count()
1909 == 2
1910 {
1911 break;
1912 }
1913 }
1914 let paused_count = requests.load(Ordering::SeqCst);
1915 assert_eq!(
1916 call(json!({ "method": "sync" })).recv().unwrap()["result"]["errorCode"],
1917 "sync.offline"
1918 );
1919 assert_eq!(requests.load(Ordering::SeqCst), paused_count);
1920 assert!(
1921 call(json!({ "method": "setTransportEnabled", "params": { "enabled": true } }))
1922 .recv()
1923 .unwrap()
1924 .get("error")
1925 .is_none()
1926 );
1927 delivered_rx
1928 .recv_timeout(Duration::from_secs(10))
1929 .expect("ACK schedules the own-image pull without realtime or a manual round");
1930 let (query_tx, query_rx) = channel();
1931 tx.send(Request::Query {
1932 sql: "SELECT title,_sync_version AS version FROM todo WHERE id='one'".into(),
1933 params: Value::Null,
1934 reply: query_tx,
1935 })
1936 .unwrap();
1937 let delivered = query_rx.recv().unwrap();
1938 assert_eq!(delivered["result"]["rows"][0]["title"], "300/v3");
1939 assert_eq!(delivered["result"]["rows"][0]["version"], 3);
1940 let pushed = server.join().unwrap();
1941 tx.send(Request::Shutdown).unwrap();
1942 owner.join().unwrap();
1943 assert!(
1944 local_reply.is_ok(),
1945 "local mutation waited for network reply"
1946 );
1947 assert!(local_rows.is_ok(), "local query waited for network reply");
1948 eprintln!("second local mutation while network pending: {elapsed:?}");
1949 assert_eq!(rows["result"]["rows"].as_array().unwrap().len(), 1);
1950 assert_eq!(
1951 pushed,
1952 vec![
1953 first["result"]["clientCommitId"].as_str().unwrap(),
1954 queued_second["result"]["clientCommitId"].as_str().unwrap(),
1955 second_reply["result"]["clientCommitId"].as_str().unwrap()
1956 ]
1957 );
1958 }
1959
1960 #[test]
1961 fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1962 use std::sync::mpsc::channel;
1963
1964 let path =
1965 std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1966 let config = SyncularConfig {
1967 db_path: Some(path.to_string_lossy().into_owned()),
1968 auto_sync: false,
1969 ..Default::default()
1970 };
1971 let (tx, rx) = channel::<Request>();
1972 let owner_tx = tx.clone();
1973 let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1974
1975 let (create_tx, create_rx) = channel();
1976 tx.send(Request::Command {
1977 command: json!({
1978 "method": "create",
1979 "params": {
1980 "clientId": "sidecar-client",
1981 "schema": { "version": 1, "tables": [] },
1982 "dbPath": path.to_string_lossy()
1983 }
1984 }),
1985 reply: create_tx,
1986 })
1987 .expect("post create");
1988 assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1989
1990 let (read_tx, read_rx) = channel::<ReadRequest>();
1991 let read_path = path.to_string_lossy().into_owned();
1992 let owner_tx = tx.clone();
1993 let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
1994
1995 let (entered_tx, entered_rx) = channel();
1998 tx.send(Request::Block {
1999 duration: Duration::from_millis(200),
2000 entered: entered_tx,
2001 })
2002 .expect("block owner");
2003 entered_rx.recv().expect("owner entered blocking round");
2004 let (snapshot_tx, snapshot_rx) = channel();
2005 read_tx
2006 .send(ReadRequest::QuerySnapshot {
2007 sql: "SELECT 1 AS value".to_owned(),
2008 params: Vec::new(),
2009 coverage: Vec::new(),
2010 owner: None,
2011 reply: snapshot_tx,
2012 })
2013 .expect("post snapshot");
2014 let snapshot = snapshot_rx
2015 .recv_timeout(Duration::from_millis(50))
2016 .expect("local snapshot must not wait for the owner");
2017 assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
2018
2019 let (failed_tx, failed_rx) = channel();
2020 read_tx
2021 .send(ReadRequest::QuerySnapshot {
2022 sql: "SELECT private_value FROM missing_private_table".to_owned(),
2023 params: Vec::new(),
2024 coverage: Vec::new(),
2025 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
2026 reply: failed_tx,
2027 })
2028 .expect("post failed snapshot");
2029 assert_eq!(
2030 failed_rx.recv().expect("failed snapshot reply")["error"]["code"],
2031 "client.failed"
2032 );
2033 let (diagnostics_tx, diagnostics_rx) = channel();
2034 tx.send(Request::Command {
2035 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
2036 reply: diagnostics_tx,
2037 })
2038 .expect("post diagnostics");
2039 assert_eq!(
2040 diagnostics_rx.recv().expect("diagnostics reply")["result"]["queryFailures"][0]["id"],
2041 "queries:missing"
2042 );
2043
2044 let (recovered_tx, recovered_rx) = channel();
2045 read_tx
2046 .send(ReadRequest::QuerySnapshot {
2047 sql: "SELECT 1 AS value".to_owned(),
2048 params: Vec::new(),
2049 coverage: Vec::new(),
2050 owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
2051 reply: recovered_tx,
2052 })
2053 .expect("post recovered snapshot");
2054 assert!(recovered_rx.recv().expect("recovered snapshot reply")["result"].is_object());
2055 let (cleared_tx, cleared_rx) = channel();
2056 tx.send(Request::Command {
2057 command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
2058 reply: cleared_tx,
2059 })
2060 .expect("post cleared diagnostics");
2061 assert_eq!(
2062 cleared_rx.recv().expect("cleared diagnostics reply")["result"]["queryFailures"],
2063 json!([])
2064 );
2065
2066 read_tx.send(ReadRequest::Shutdown).expect("stop reader");
2067 reader.join().expect("join reader");
2068 tx.send(Request::Shutdown).expect("stop owner");
2069 owner.join().expect("join owner");
2070 std::fs::remove_file(path).expect("remove temp database");
2071 }
2072}