Skip to main content

tauri_plugin_syncular/
lib.rs

1//! # tauri-plugin-syncular — a native syncular instance inside the Tauri process
2//!
3//! A NATIVE syncular client (the Rust `syncular-client` core, consumed
4//! DIRECTLY — no FFI) runs in the Tauri host process and is exposed to the
5//! webview as Tauri commands + events. The JS bridge (`@syncular/tauri`)
6//! implements the same `SyncClientLike` interface the React package
7//! normalizes, so the hooks work unchanged — the fourth host of one interface
8//! after direct / worker-leader / follower.
9//!
10//! The webview does not run JS syncular. Webview OPFS is eviction-prone and inconsistent across
11//! WKWebView/webkitgtk; the Rust core gives a real file DB and native perf.
12//!
13//! ## The surface (mirrors the FFI / conformance shim)
14//!
15//! - `syncular_command(command_json)` — the WHOLE command surface in one
16//!   command (`{"method","params"}`), dispatched through the shared
17//!   `syncular-command` router (the plugin is its THIRD consumer, so the
18//!   surface stays conformance-locked).
19//! - `syncular_query(sql, params)` — the React live-query fast path (arbitrary
20//!   read-only SQL); routed through the same `query` command.
21//! - `syncular_query_snapshot(sql, params, coverage)` — atomic reactive reads
22//!   on an independent read-only SQLite connection for file-backed clients.
23//! - `syncular://event` — exact revisioned `change` batches plus ephemeral
24//!   `presence`; command/realtime sync intents stay inside the event-driven
25//!   owner loop.
26//!
27//! ## Thread-safety, honestly
28//!
29//! [`core::SyncularCore`] owns a rusqlite connection and is NOT `Sync`. One
30//! owning thread holds the mutable client; every command arrives over a mailbox
31//! (mpsc). The background host loop (§8.4 wake-driven `syncUntilIdle` with
32//! deadlines) runs on that thread. File-backed clients add one read owner with
33//! an independent read-only SQLite connection for snapshots, so network work
34//! cannot block local views while the mutable client remains single-owned.
35
36use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
37use std::sync::{Condvar, Mutex};
38use std::time::{Duration, Instant};
39
40use serde_json::{json, Value};
41use tauri::plugin::{Builder, TauriPlugin};
42use tauri::{Emitter, Manager, RunEvent, Runtime};
43
44pub mod core;
45pub mod transport;
46
47use core::SyncularCore;
48use syncular_client::{FileQuerySnapshotReader, WindowBase, WindowCoverage};
49
50/// The Tauri event name carrying derived client-observable events.
51pub const EVENT_NAME: &str = "syncular://event";
52
53/// Plugin configuration. Passed to [`init`]; every field is optional except a
54/// caller almost always wants a `base_url` (for real network sync) and a
55/// `db_path` (for persistence — defaults to an in-memory core if absent).
56#[derive(Debug, Clone)]
57pub struct SyncularConfig {
58    /// Server base URL for the native HTTP+WS transport (needs the
59    /// `native-transport` feature). Absent → client-local only.
60    pub base_url: Option<String>,
61    /// Optional realtime WS URL; derived from `base_url` when absent.
62    pub ws_url: Option<String>,
63    /// Extra request headers (auth, actor/project ids) as (name, value).
64    pub headers: Vec<(String, String)>,
65    /// On-disk SQLite path. Absent → in-memory (nothing survives a restart).
66    /// Apps usually set this to a file under the app-data dir; see [`init`].
67    pub db_path: Option<String>,
68    /// Run the background host loop (§8.4). Default true.
69    pub auto_sync: bool,
70}
71
72impl Default for SyncularConfig {
73    fn default() -> Self {
74        Self {
75            base_url: None,
76            ws_url: None,
77            headers: Vec::new(),
78            db_path: None,
79            auto_sync: true,
80        }
81    }
82}
83
84impl SyncularConfig {
85    /// Build the JSON config the core's transport reads.
86    fn to_transport_json(&self) -> Value {
87        let mut map = serde_json::Map::new();
88        if let Some(base) = &self.base_url {
89            map.insert("baseUrl".to_owned(), Value::from(base.clone()));
90        }
91        if let Some(ws) = &self.ws_url {
92            map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
93        }
94        if !self.headers.is_empty() {
95            let headers: serde_json::Map<String, Value> = self
96                .headers
97                .iter()
98                .map(|(k, v)| (k.clone(), Value::from(v.clone())))
99                .collect();
100            map.insert("headers".to_owned(), Value::Object(headers));
101        }
102        Value::Object(map)
103    }
104}
105
106/// A request posted to the owning thread's mailbox. Each carries a one-shot
107/// reply channel; the Tauri command blocks on it (`spawn_blocking`-friendly).
108enum Request {
109    Command {
110        command: Value,
111        reply: Sender<Value>,
112    },
113    Query {
114        sql: String,
115        params: Value,
116        reply: Sender<Value>,
117    },
118    /// Replace the transport's request headers. Header state
119    /// lives on the core-owned transport, so mutation rides the same mailbox
120    /// as every other access — the one-owning-thread invariant holds.
121    SetHeaders {
122        headers: Vec<(String, String)>,
123        reply: Sender<Value>,
124    },
125    /// Native realtime reader wake; contains no data (the transport buffer does).
126    TransportWake,
127    #[cfg(test)]
128    Block {
129        duration: Duration,
130        entered: Sender<()>,
131    },
132    Shutdown,
133}
134
135/// Latency-critical reads use a second, read-only SQLite connection. This
136/// mailbox is deliberately independent from [`Request`]: a network round on
137/// the mutable owner must never head-of-line-block a local UI snapshot.
138enum ReadRequest {
139    QuerySnapshot {
140        sql: String,
141        params: Vec<Value>,
142        coverage: Vec<WindowCoverage>,
143        reply: Sender<Value>,
144    },
145    Shutdown,
146}
147
148/// The plugin's managed state: the mailbox sender the commands post to. Wrapped
149/// in a `Mutex` only to be `Sync` for Tauri state (the `Sender` is `Send`).
150struct SyncularState {
151    sender: Mutex<Sender<Request>>,
152    reader: Option<Mutex<Sender<ReadRequest>>>,
153    security_gate: SecurityGate,
154}
155
156struct SecurityGateState {
157    preflight: bool,
158    active_reads: usize,
159}
160
161struct SecurityGate {
162    state: Mutex<SecurityGateState>,
163    idle: Condvar,
164}
165
166struct SecurityReadGuard<'a> {
167    gate: &'a SecurityGate,
168}
169
170impl SecurityGate {
171    fn new_preflight() -> Self {
172        Self {
173            state: Mutex::new(SecurityGateState {
174                preflight: true,
175                active_reads: 0,
176            }),
177            idle: Condvar::new(),
178        }
179    }
180
181    fn begin_preflight(&self) -> Result<(), String> {
182        let mut state = self
183            .state
184            .lock()
185            .map_err(|_| "syncular security gate poisoned".to_owned())?;
186        state.preflight = true;
187        while state.active_reads > 0 {
188            state = self
189                .idle
190                .wait(state)
191                .map_err(|_| "syncular security gate poisoned".to_owned())?;
192        }
193        Ok(())
194    }
195
196    fn activate(&self) -> Result<(), String> {
197        let mut state = self
198            .state
199            .lock()
200            .map_err(|_| "syncular security gate poisoned".to_owned())?;
201        state.preflight = false;
202        Ok(())
203    }
204
205    fn enter_read(&self) -> Result<SecurityReadGuard<'_>, Value> {
206        let mut state = self
207            .state
208            .lock()
209            .map_err(|_| client_error("syncular security gate poisoned"))?;
210        if state.preflight {
211            return Err(security_preflight_error());
212        }
213        state.active_reads += 1;
214        Ok(SecurityReadGuard { gate: self })
215    }
216}
217
218impl Drop for SecurityReadGuard<'_> {
219    fn drop(&mut self) {
220        let Ok(mut state) = self.gate.state.lock() else {
221            return;
222        };
223        state.active_reads = state.active_reads.saturating_sub(1);
224        if state.active_reads == 0 {
225            self.gate.idle.notify_all();
226        }
227    }
228}
229
230impl SyncularState {
231    fn send(&self, request: Request) -> Result<(), String> {
232        self.sender
233            .lock()
234            .map_err(|_| "syncular mailbox poisoned".to_owned())?
235            .send(request)
236            .map_err(|_| "the syncular core thread has stopped".to_owned())
237    }
238
239    fn send_read(&self, request: ReadRequest) -> Result<(), String> {
240        let Some(reader) = &self.reader else {
241            return Err("this syncular client has no file snapshot reader".to_owned());
242        };
243        reader
244            .lock()
245            .map_err(|_| "syncular read mailbox poisoned".to_owned())?
246            .send(request)
247            .map_err(|_| "the syncular read thread has stopped".to_owned())?;
248        Ok(())
249    }
250}
251
252fn run_reader_thread(path: String, rx: Receiver<ReadRequest>) {
253    let mut reader = FileQuerySnapshotReader::new(path);
254    while let Ok(request) = rx.recv() {
255        match request {
256            ReadRequest::QuerySnapshot {
257                sql,
258                params,
259                coverage,
260                reply,
261            } => {
262                let value = match reader.query_snapshot(&sql, &params, &coverage) {
263                    Ok(snapshot) => json!({ "result": snapshot }),
264                    Err(message) => json!({
265                        "error": { "code": "client.failed", "message": message }
266                    }),
267                };
268                let _ = reply.send(value);
269            }
270            ReadRequest::Shutdown => return,
271        }
272    }
273}
274
275/// The owning thread: builds the core, then loops over the mailbox and the
276/// background host policy. `emit` pushes drained events onto the Tauri channel.
277fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
278where
279    F: Fn(&Value) + Send + 'static,
280{
281    let transport_json = config.to_transport_json();
282    let wake_tx = tx.clone();
283    let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
284        let _ = wake_tx.send(Request::TransportWake);
285    });
286    let mut core = match SyncularCore::new_with_notify(&transport_json, Some(notify)) {
287        Ok(core) => core,
288        Err(message) => {
289            // A construction failure is terminal for this instance; surface it
290            // once on the channel so the webview can show it, then stop.
291            emit(&json!({ "type": "error", "message": message }));
292            return;
293        }
294    };
295
296    // No idle poll: commands/realtime wake the mailbox, while a retryable
297    // transport failure contributes one real monotonic deadline.
298    let mut background_deadline: Option<Instant> = None;
299    loop {
300        if config.auto_sync {
301            match core.take_sync_intent() {
302                syncular_client::SyncIntent::Interactive => {
303                    background_deadline = None;
304                    core.sync_until_idle();
305                    pump_events(&mut core, &emit);
306                    continue;
307                }
308                syncular_client::SyncIntent::Background { delay_ms } => {
309                    let candidate = Instant::now()
310                        .checked_add(Duration::from_millis(delay_ms))
311                        .unwrap_or_else(Instant::now);
312                    background_deadline = Some(
313                        background_deadline.map_or(candidate, |current| current.min(candidate)),
314                    );
315                }
316                syncular_client::SyncIntent::None => {}
317            }
318        }
319
320        let request = if let Some(deadline) = background_deadline {
321            let now = Instant::now();
322            if deadline <= now {
323                background_deadline = None;
324                core.sync_until_idle();
325                pump_events(&mut core, &emit);
326                continue;
327            }
328            match rx.recv_timeout(deadline.saturating_duration_since(now)) {
329                Ok(request) => request,
330                Err(RecvTimeoutError::Timeout) => {
331                    background_deadline = None;
332                    core.sync_until_idle();
333                    pump_events(&mut core, &emit);
334                    continue;
335                }
336                Err(RecvTimeoutError::Disconnected) => {
337                    core.shutdown();
338                    return;
339                }
340            }
341        } else {
342            match rx.recv() {
343                Ok(request) => request,
344                Err(std::sync::mpsc::RecvError) => {
345                    core.shutdown();
346                    return;
347                }
348            }
349        };
350
351        match request {
352            Request::Command { command, reply } => {
353                let command = inject_db_path(command, &config);
354                let result = core.command(&command);
355                if result.get("error").is_none() {
356                    if let Some(headers) = activation_headers(&command) {
357                        // A successful `activateSecurity` may carry a fresh
358                        // header set; apply it here, before this loop consumes
359                        // the startup sync intent the activation enqueued, so
360                        // a preflight that outlived the boot token starts its
361                        // first round with valid credentials.
362                        core.set_headers(headers);
363                    }
364                }
365                let _ = reply.send(result);
366                pump_events(&mut core, &emit);
367            }
368            Request::Query { sql, params, reply } => {
369                let result = core.query(&sql, params);
370                let _ = reply.send(result);
371                pump_events(&mut core, &emit);
372            }
373            Request::SetHeaders { headers, reply } => {
374                core.set_headers(headers);
375                let _ = reply.send(json!({ "result": null }));
376            }
377            Request::TransportWake => {
378                core.poll_transport();
379                pump_events(&mut core, &emit);
380            }
381            #[cfg(test)]
382            Request::Block { duration, entered } => {
383                let _ = entered.send(());
384                std::thread::sleep(duration);
385            }
386            Request::Shutdown => {
387                core.shutdown();
388                return;
389            }
390        }
391    }
392}
393
394/// Inject the configured `db_path` into a `create` command's params if the JS
395/// side did not already supply one — so persistence is a plugin-config concern,
396/// not something every app must thread through the bridge.
397fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
398    if command.get("method").and_then(Value::as_str) != Some("create") {
399        return command;
400    }
401    let Some(db_path) = &config.db_path else {
402        return command;
403    };
404    let params = command.get_mut("params").and_then(Value::as_object_mut);
405    if let Some(params) = params {
406        params
407            .entry("dbPath")
408            .or_insert_with(|| Value::from(db_path.clone()));
409    } else if let Some(obj) = command.as_object_mut() {
410        obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
411    }
412    command
413}
414
415/// The header set an `activateSecurity` command carries (already validated by
416/// the shared command router; a shape failure surfaces as its error reply, so
417/// this extraction only sees well-formed sets on the success path).
418fn activation_headers(command: &Value) -> Option<Vec<(String, String)>> {
419    if command.get("method").and_then(Value::as_str) != Some("activateSecurity") {
420        return None;
421    }
422    let headers = command.pointer("/params/headers")?;
423    syncular_command::parse_headers(headers).ok()
424}
425
426fn client_error(message: impl Into<String>) -> Value {
427    json!({ "error": { "code": "client.failed", "message": message.into() } })
428}
429
430fn security_preflight_error() -> Value {
431    json!({
432        "error": {
433            "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
434            "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
435        }
436    })
437}
438
439fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
440    let object = value
441        .and_then(Value::as_object)
442        .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
443    let table = object
444        .get("table")
445        .and_then(Value::as_str)
446        .ok_or_else(|| "window base missing table".to_owned())?
447        .to_owned();
448    let variable = object
449        .get("variable")
450        .and_then(Value::as_str)
451        .ok_or_else(|| "window base missing variable".to_owned())?
452        .to_owned();
453    let fixed_scopes = match object.get("fixedScopes") {
454        Some(value) => syncular_client::values::json_to_scope_map(value)?,
455        None => Vec::new(),
456    };
457    let params = object
458        .get("params")
459        .and_then(Value::as_str)
460        .map(str::to_owned);
461    Ok(WindowBase {
462        table,
463        variable,
464        fixed_scopes,
465        params,
466    })
467}
468
469fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
470    let Some(value) = value else {
471        return Ok(Vec::new());
472    };
473    if value.is_null() {
474        return Ok(Vec::new());
475    }
476    let entries = value
477        .as_array()
478        .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
479    entries
480        .iter()
481        .map(|entry| {
482            let units = entry
483                .get("units")
484                .and_then(Value::as_array)
485                .map(|values| {
486                    values
487                        .iter()
488                        .filter_map(|value| value.as_str().map(str::to_owned))
489                        .collect()
490                })
491                .unwrap_or_default();
492            Ok(WindowCoverage {
493                base: parse_window_base(entry.get("base"))?,
494                units,
495            })
496        })
497        .collect()
498}
499
500fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
501    for event in core.drain_events() {
502        emit(&event.json);
503    }
504}
505
506// -- Tauri commands (the thin shell) -----------------------------------------
507
508#[tauri::command]
509async fn syncular_command<R: Runtime>(
510    app: tauri::AppHandle<R>,
511    command: Value,
512) -> Result<Value, String> {
513    let state = app.state::<SyncularState>();
514    let method = command
515        .get("method")
516        .and_then(Value::as_str)
517        .unwrap_or("")
518        .to_owned();
519    if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
520        // Gate fast reads and wait for already-started sidecar snapshots before
521        // the owner-thread barrier is enqueued.
522        state.security_gate.begin_preflight()?;
523    }
524    let create_preflight = method == "create"
525        && command
526            .pointer("/params/securityPreflight")
527            .and_then(Value::as_bool)
528            .unwrap_or(false);
529    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
530    state.send(Request::Command {
531        command,
532        reply: reply_tx,
533    })?;
534    let reply = reply_rx
535        .recv()
536        .map_err(|_| "the syncular core dropped the reply".to_owned())?;
537    let succeeded = reply.get("error").is_none();
538    if succeeded && method == "create" {
539        if !create_preflight {
540            state.security_gate.activate()?;
541        }
542    } else if succeeded && method == "activateSecurity" {
543        state.security_gate.activate()?;
544    }
545    Ok(reply)
546}
547
548/// Replace the native transport's request headers at runtime — the auth
549/// Header rotation path: a fresh JWT reaches the transport without
550/// re-registering the plugin. HTTP requests use the new set from the next
551/// call; the realtime socket applies it on its next (re)connect.
552#[tauri::command]
553async fn syncular_set_headers<R: Runtime>(
554    app: tauri::AppHandle<R>,
555    headers: std::collections::BTreeMap<String, String>,
556) -> Result<Value, String> {
557    let state = app.state::<SyncularState>();
558    // Runtime bearer replacement is an active-session operation. Hold the
559    // same gate as fast reads so beginSecurityPreflight both rejects new
560    // replacements and waits for an already-started mailbox update.
561    let _active_guard = match state.security_gate.enter_read() {
562        Ok(guard) => guard,
563        Err(reply) => return Ok(reply),
564    };
565    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
566    state.send(Request::SetHeaders {
567        headers: headers.into_iter().collect(),
568        reply: reply_tx,
569    })?;
570    reply_rx
571        .recv()
572        .map_err(|_| "the syncular core dropped the reply".to_owned())
573}
574
575#[tauri::command]
576async fn syncular_query<R: Runtime>(
577    app: tauri::AppHandle<R>,
578    sql: String,
579    params: Option<Value>,
580) -> Result<Value, String> {
581    let state = app.state::<SyncularState>();
582    let _read_guard = match state.security_gate.enter_read() {
583        Ok(guard) => guard,
584        Err(reply) => return Ok(reply),
585    };
586    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
587    state.send(Request::Query {
588        sql,
589        params: params.unwrap_or(Value::Null),
590        reply: reply_tx,
591    })?;
592    reply_rx
593        .recv()
594        .map_err(|_| "the syncular core dropped the reply".to_owned())
595}
596
597/// Atomic rows + revision + window coverage on the independent read-only
598/// connection. In-memory configurations fall back to the core owner because
599/// SQLite cannot share an anonymous database across connections.
600#[tauri::command]
601async fn syncular_query_snapshot<R: Runtime>(
602    app: tauri::AppHandle<R>,
603    sql: String,
604    params: Option<Value>,
605    coverage: Option<Value>,
606) -> Result<Value, String> {
607    let state = app.state::<SyncularState>();
608    let _read_guard = match state.security_gate.enter_read() {
609        Ok(guard) => guard,
610        Err(reply) => return Ok(reply),
611    };
612    let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
613    let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
614
615    if state.reader.is_some() {
616        let bind = match params_value.as_array() {
617            Some(values) => values.clone(),
618            None => return Ok(client_error("querySnapshot params must be a list")),
619        };
620        let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
621            Ok(value) => value,
622            Err(message) => return Ok(client_error(message)),
623        };
624        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
625        state.send_read(ReadRequest::QuerySnapshot {
626            sql,
627            params: bind,
628            coverage: parsed_coverage,
629            reply: reply_tx,
630        })?;
631        return reply_rx
632            .recv()
633            .map_err(|_| "the syncular read thread dropped the reply".to_owned());
634    }
635
636    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
637    state.send(Request::Command {
638        command: json!({
639            "method": "querySnapshot",
640            "params": { "sql": sql, "params": params_value, "coverage": coverage_value }
641        }),
642        reply: reply_tx,
643    })?;
644    reply_rx
645        .recv()
646        .map_err(|_| "the syncular core dropped the reply".to_owned())
647}
648
649/// Initialize the plugin with a config. Register with
650/// `tauri::Builder::default().plugin(tauri_plugin_syncular::init(config))`.
651///
652/// The owning thread is spawned in `setup`; it builds the core (native
653/// transport if `base_url` + the `native-transport` feature), pumps events onto
654/// [`EVENT_NAME`], and runs the §8.4 host loop. The mailbox `Sender` is managed
655/// as plugin state and torn down on `RunEvent::Exit`.
656pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
657    Builder::<R>::new("syncular")
658        .invoke_handler(tauri::generate_handler![
659            syncular_command,
660            syncular_query,
661            syncular_query_snapshot,
662            syncular_set_headers
663        ])
664        .setup(move |app, _api| {
665            let (tx, rx) = std::sync::mpsc::channel::<Request>();
666            let reader = match config
667                .db_path
668                .as_ref()
669                .filter(|path| path.as_str() != ":memory:")
670            {
671                Some(path) => {
672                    let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
673                    let path = path.clone();
674                    std::thread::Builder::new()
675                        .name("syncular-read".to_owned())
676                        .spawn(move || run_reader_thread(path, reader_rx))
677                        .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
678                    Some(Mutex::new(reader_tx))
679                }
680                None => None,
681            };
682            app.manage(SyncularState {
683                sender: Mutex::new(tx.clone()),
684                reader,
685                // Fail closed until the first successful `create` declares
686                // whether this process starts active or in preflight.
687                security_gate: SecurityGate::new_preflight(),
688            });
689            let app_handle = app.clone();
690            let emit = move |value: &Value| {
691                // Best-effort: a webview that has gone away must not crash the
692                // owning thread. Emit to all windows on the syncular channel.
693                let _ = app_handle.emit(EVENT_NAME, value.clone());
694            };
695            std::thread::Builder::new()
696                .name("syncular-core".to_owned())
697                .spawn(move || run_owner_thread(config, tx, rx, emit))
698                .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
699            Ok(())
700        })
701        .on_event(|app, event| {
702            if let RunEvent::Exit = event {
703                if let Some(state) = app.try_state::<SyncularState>() {
704                    let _ = state.send(Request::Shutdown);
705                    if state.reader.is_some() {
706                        let _ = state.send_read(ReadRequest::Shutdown);
707                    }
708                }
709            }
710        })
711        .build()
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn config_to_transport_json_shapes_fields() {
720        let config = SyncularConfig {
721            base_url: Some("https://api.example.com".to_owned()),
722            headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
723            ..Default::default()
724        };
725        let json = config.to_transport_json();
726        assert_eq!(json["baseUrl"], "https://api.example.com");
727        assert_eq!(json["headers"]["authorization"], "Bearer x");
728    }
729
730    #[test]
731    fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
732        let gate = std::sync::Arc::new(SecurityGate::new_preflight());
733        gate.activate().expect("activate test gate");
734        let read = gate.enter_read().expect("active read");
735        let gate_for_barrier = std::sync::Arc::clone(&gate);
736        let (done_tx, done_rx) = std::sync::mpsc::channel();
737        let barrier = std::thread::spawn(move || {
738            gate_for_barrier.begin_preflight().expect("enter preflight");
739            done_tx.send(()).expect("barrier reply");
740        });
741
742        assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
743        drop(read);
744        done_rx
745            .recv_timeout(Duration::from_secs(1))
746            .expect("barrier drains after the read");
747        barrier.join().expect("barrier thread");
748        assert!(gate.enter_read().is_err());
749    }
750
751    #[test]
752    fn direct_set_headers_command_respects_the_native_preflight_gate() {
753        use std::collections::BTreeMap;
754        use tauri::test::{mock_builder, mock_context, noop_assets};
755
756        let app = mock_builder()
757            .plugin(init(SyncularConfig {
758                auto_sync: false,
759                ..Default::default()
760            }))
761            .build(mock_context(noop_assets()))
762            .expect("build mock app");
763
764        let blocked = tauri::async_runtime::block_on(syncular_set_headers(
765            app.handle().clone(),
766            BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
767        ))
768        .expect("preflight reply");
769        assert_eq!(
770            blocked["error"]["code"],
771            Value::from("client.security_preflight_required")
772        );
773
774        let created = tauri::async_runtime::block_on(syncular_command(
775            app.handle().clone(),
776            json!({
777                "method": "create",
778                "params": {
779                    "clientId": "native-header-gate",
780                    "schema": { "version": 1, "tables": [] }
781                }
782            }),
783        ))
784        .expect("create reply");
785        assert!(created.get("error").is_none(), "{created}");
786
787        let active = tauri::async_runtime::block_on(syncular_set_headers(
788            app.handle().clone(),
789            BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
790        ))
791        .expect("active reply");
792        assert_eq!(active["result"], Value::Null);
793    }
794
795    #[test]
796    fn activation_headers_extracts_only_the_activation_set() {
797        assert_eq!(
798            activation_headers(&json!({
799                "method": "activateSecurity",
800                "params": { "headers": { "authorization": "Bearer fresh" } }
801            })),
802            Some(vec![(
803                "authorization".to_owned(),
804                "Bearer fresh".to_owned()
805            )])
806        );
807        // Absent headers, other methods, and invalid shapes all yield nothing.
808        assert_eq!(
809            activation_headers(&json!({ "method": "activateSecurity", "params": {} })),
810            None
811        );
812        assert_eq!(
813            activation_headers(&json!({
814                "method": "setWindow",
815                "params": { "headers": { "authorization": "Bearer fresh" } }
816            })),
817            None
818        );
819        assert_eq!(
820            activation_headers(&json!({
821                "method": "activateSecurity",
822                "params": { "headers": { "authorization": 7 } }
823            })),
824            None
825        );
826    }
827
828    #[test]
829    fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
830        use tauri::test::{mock_builder, mock_context, noop_assets};
831
832        let app = mock_builder()
833            .plugin(init(SyncularConfig {
834                auto_sync: false,
835                ..Default::default()
836            }))
837            .build(mock_context(noop_assets()))
838            .expect("build mock app");
839        let command = |value: Value| {
840            tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
841                .expect("command reply")
842        };
843
844        let created = command(json!({
845            "method": "create",
846            "params": {
847                "clientId": "native-preflight-escape",
848                "schema": { "version": 1, "tables": [] },
849                "securityPreflight": true
850            }
851        }));
852        assert!(created.get("error").is_none(), "{created}");
853
854        // The escape attempt: a plain re-create must be refused by the shared
855        // router, and the plugin's fast-read gate must stay engaged.
856        let escape = command(json!({
857            "method": "create",
858            "params": {
859                "clientId": "native-preflight-escape",
860                "schema": { "version": 1, "tables": [] }
861            }
862        }));
863        assert_eq!(
864            escape["error"]["code"],
865            Value::from("client.security_preflight_required"),
866            "{escape}"
867        );
868        let read = tauri::async_runtime::block_on(syncular_query(
869            app.handle().clone(),
870            "SELECT 1".to_owned(),
871            None,
872        ))
873        .expect("query reply");
874        assert_eq!(
875            read["error"]["code"],
876            Value::from("client.security_preflight_required")
877        );
878
879        // Activation (with a fresh header set) releases both gates.
880        let activated = command(json!({
881            "method": "activateSecurity",
882            "params": { "headers": { "authorization": "Bearer fresh" } }
883        }));
884        assert!(activated.get("error").is_none(), "{activated}");
885        let read = tauri::async_runtime::block_on(syncular_query(
886            app.handle().clone(),
887            "SELECT 1 AS value".to_owned(),
888            None,
889        ))
890        .expect("query reply");
891        assert_eq!(read["result"]["rows"][0]["value"], 1);
892        let recreated = command(json!({
893            "method": "create",
894            "params": {
895                "clientId": "native-preflight-escape",
896                "schema": { "version": 1, "tables": [] }
897            }
898        }));
899        assert!(recreated.get("error").is_none(), "{recreated}");
900    }
901
902    #[test]
903    fn inject_db_path_adds_to_create_only() {
904        let config = SyncularConfig {
905            db_path: Some("/tmp/app.db".to_owned()),
906            ..Default::default()
907        };
908        // create gains the path…
909        let created = inject_db_path(
910            json!({ "method": "create", "params": { "clientId": "c1" } }),
911            &config,
912        );
913        assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
914        // …a create with no params object gets one…
915        let created2 = inject_db_path(json!({ "method": "create" }), &config);
916        assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
917        // …an explicit dbPath is preserved…
918        let explicit = inject_db_path(
919            json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
920            &config,
921        );
922        assert_eq!(explicit["params"]["dbPath"], "/other.db");
923        // …and a non-create command is untouched.
924        let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
925        assert!(mutate["params"].get("dbPath").is_none());
926    }
927
928    #[test]
929    fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
930        let parsed = parse_coverage(Some(&json!([{
931            "base": {
932                "table": "tasks",
933                "variable": "project_id",
934                "fixedScopes": { "tenant_id": ["one", "two"] },
935                "params": "opaque"
936            },
937            "units": ["a", "b"]
938        }])))
939        .expect("parse coverage");
940        assert_eq!(parsed.len(), 1);
941        let entry = &parsed[0];
942        assert_eq!(entry.base.table, "tasks");
943        assert_eq!(entry.base.variable, "project_id");
944        assert_eq!(
945            entry.base.fixed_scopes,
946            vec![(
947                "tenant_id".to_owned(),
948                vec!["one".to_owned(), "two".to_owned()]
949            )]
950        );
951        assert_eq!(entry.base.params.as_deref(), Some("opaque"));
952        assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
953    }
954
955    /// The owner-thread mailbox loop end-to-end, without any Tauri window: post
956    /// commands, collect emitted events. This is the real host path — the Tauri
957    /// commands are a two-line channel forward over exactly this.
958    #[test]
959    fn owner_thread_round_trips_over_mailbox() {
960        use std::sync::mpsc::channel;
961        use std::sync::{Arc, Mutex as StdMutex};
962
963        let (tx, rx) = channel::<Request>();
964        let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
965        let events_for_thread = Arc::clone(&events);
966        let config = SyncularConfig {
967            auto_sync: false,
968            ..Default::default()
969        };
970        let owner_tx = tx.clone();
971        let handle = std::thread::spawn(move || {
972            run_owner_thread(config, owner_tx, rx, move |v| {
973                events_for_thread.lock().unwrap().push(v.clone());
974            });
975        });
976
977        let call = |command: Value| -> Value {
978            let (rtx, rrx) = channel();
979            tx.send(Request::Command {
980                command,
981                reply: rtx,
982            })
983            .unwrap();
984            rrx.recv().unwrap()
985        };
986
987        let schema = json!({
988            "version": 1,
989            "tables": [{
990                "name": "todo", "primaryKey": "id",
991                "columns": [
992                    { "name": "id", "type": "string", "nullable": false },
993                    { "name": "title", "type": "string", "nullable": false }
994                ],
995                "scopes": []
996            }]
997        });
998        assert_eq!(
999            call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1000                ["result"],
1001            json!({})
1002        );
1003        call(json!({ "method": "mutate", "params": { "mutations": [{
1004            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1005        }] } }));
1006
1007        // A query over the mailbox.
1008        let (qtx, qrx) = channel();
1009        tx.send(Request::Query {
1010            sql: "SELECT title FROM todo".to_owned(),
1011            params: Value::Null,
1012            reply: qtx,
1013        })
1014        .unwrap();
1015        let rows = qrx.recv().unwrap();
1016        assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1017
1018        // Header rotation rides the same mailbox; a
1019        // client-local (Null-transport) core accepts and ignores the set.
1020        let (htx, hrx) = channel();
1021        tx.send(Request::SetHeaders {
1022            headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1023            reply: htx,
1024        })
1025        .unwrap();
1026        assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1027
1028        tx.send(Request::Shutdown).unwrap();
1029        handle.join().unwrap();
1030
1031        let seen = events.lock().unwrap();
1032        let kinds: Vec<String> = seen
1033            .iter()
1034            .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1035            .collect();
1036        // The local mutate emits the exact revisioned batch onto the channel.
1037        assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1038    }
1039
1040    #[test]
1041    fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1042        use std::sync::mpsc::channel;
1043
1044        let path =
1045            std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1046        let config = SyncularConfig {
1047            db_path: Some(path.to_string_lossy().into_owned()),
1048            auto_sync: false,
1049            ..Default::default()
1050        };
1051        let (tx, rx) = channel::<Request>();
1052        let owner_tx = tx.clone();
1053        let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1054
1055        let (create_tx, create_rx) = channel();
1056        tx.send(Request::Command {
1057            command: json!({
1058                "method": "create",
1059                "params": {
1060                    "clientId": "sidecar-client",
1061                    "schema": { "version": 1, "tables": [] },
1062                    "dbPath": path.to_string_lossy()
1063                }
1064            }),
1065            reply: create_tx,
1066        })
1067        .expect("post create");
1068        assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1069
1070        let (read_tx, read_rx) = channel::<ReadRequest>();
1071        let read_path = path.to_string_lossy().into_owned();
1072        let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx));
1073
1074        // Model a slow HTTP/WS round on the mutable owner. The dedicated read
1075        // mailbox must still return the durable local snapshot immediately.
1076        let (entered_tx, entered_rx) = channel();
1077        tx.send(Request::Block {
1078            duration: Duration::from_millis(200),
1079            entered: entered_tx,
1080        })
1081        .expect("block owner");
1082        entered_rx.recv().expect("owner entered blocking round");
1083        let (snapshot_tx, snapshot_rx) = channel();
1084        read_tx
1085            .send(ReadRequest::QuerySnapshot {
1086                sql: "SELECT 1 AS value".to_owned(),
1087                params: Vec::new(),
1088                coverage: Vec::new(),
1089                reply: snapshot_tx,
1090            })
1091            .expect("post snapshot");
1092        let snapshot = snapshot_rx
1093            .recv_timeout(Duration::from_millis(50))
1094            .expect("local snapshot must not wait for the owner");
1095        assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
1096
1097        read_tx.send(ReadRequest::Shutdown).expect("stop reader");
1098        reader.join().expect("join reader");
1099        tx.send(Request::Shutdown).expect("stop owner");
1100        owner.join().expect("join owner");
1101        std::fs::remove_file(path).expect("remove temp database");
1102    }
1103}