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 + Sync + 'static,
280{
281    let emit = std::sync::Arc::new(emit);
282    let transport_json = config.to_transport_json();
283    let wake_tx = tx.clone();
284    let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
285        let _ = wake_tx.send(Request::TransportWake);
286    });
287    let mut core = match SyncularCore::new_with_notify(&transport_json, Some(notify)) {
288        Ok(core) => core,
289        Err(message) => {
290            // A construction failure is terminal for this instance; surface it
291            // once on the channel so the webview can show it, then stop.
292            emit(&json!({ "type": "error", "message": message }));
293            return;
294        }
295    };
296    let progress_emit = emit.clone();
297    core.progress_listener = Some(std::sync::Arc::new(move |progress| {
298        progress_emit(&json!({ "type": "progress", "progress": progress }));
299    }));
300
301    // No idle poll: commands/realtime wake the mailbox, while a retryable
302    // transport failure contributes one real monotonic deadline.
303    let mut background_deadline: Option<Instant> = None;
304    loop {
305        if config.auto_sync {
306            match core.take_sync_intent() {
307                syncular_client::SyncIntent::Interactive => {
308                    background_deadline = None;
309                    core.sync_until_idle();
310                    pump_events(&mut core, &*emit);
311                    continue;
312                }
313                syncular_client::SyncIntent::Background { delay_ms } => {
314                    let candidate = Instant::now()
315                        .checked_add(Duration::from_millis(delay_ms))
316                        .unwrap_or_else(Instant::now);
317                    background_deadline = Some(
318                        background_deadline.map_or(candidate, |current| current.min(candidate)),
319                    );
320                }
321                syncular_client::SyncIntent::None => {}
322            }
323        }
324
325        let request = if let Some(deadline) = background_deadline {
326            let now = Instant::now();
327            if deadline <= now {
328                background_deadline = None;
329                core.sync_until_idle();
330                pump_events(&mut core, &*emit);
331                continue;
332            }
333            match rx.recv_timeout(deadline.saturating_duration_since(now)) {
334                Ok(request) => request,
335                Err(RecvTimeoutError::Timeout) => {
336                    background_deadline = None;
337                    core.sync_until_idle();
338                    pump_events(&mut core, &*emit);
339                    continue;
340                }
341                Err(RecvTimeoutError::Disconnected) => {
342                    core.shutdown();
343                    return;
344                }
345            }
346        } else {
347            match rx.recv() {
348                Ok(request) => request,
349                Err(std::sync::mpsc::RecvError) => {
350                    core.shutdown();
351                    return;
352                }
353            }
354        };
355
356        match request {
357            Request::Command { command, reply } => {
358                let command = inject_db_path(command, &config);
359                let result = core.command(&command);
360                if result.get("error").is_none() {
361                    if let Some(headers) = activation_headers(&command) {
362                        // A successful `activateSecurity` may carry a fresh
363                        // header set; apply it here, before this loop consumes
364                        // the startup sync intent the activation enqueued, so
365                        // a preflight that outlived the boot token starts its
366                        // first round with valid credentials.
367                        core.set_headers(headers);
368                    }
369                }
370                let _ = reply.send(result);
371                pump_events(&mut core, &*emit);
372            }
373            Request::Query { sql, params, reply } => {
374                let result = core.query(&sql, params);
375                let _ = reply.send(result);
376                pump_events(&mut core, &*emit);
377            }
378            Request::SetHeaders { headers, reply } => {
379                core.set_headers(headers);
380                let _ = reply.send(json!({ "result": null }));
381            }
382            Request::TransportWake => {
383                core.poll_transport();
384                pump_events(&mut core, &*emit);
385            }
386            #[cfg(test)]
387            Request::Block { duration, entered } => {
388                let _ = entered.send(());
389                std::thread::sleep(duration);
390            }
391            Request::Shutdown => {
392                core.shutdown();
393                return;
394            }
395        }
396    }
397}
398
399/// Inject the configured `db_path` into a `create` command's params if the JS
400/// side did not already supply one — so persistence is a plugin-config concern,
401/// not something every app must thread through the bridge.
402fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
403    if command.get("method").and_then(Value::as_str) != Some("create") {
404        return command;
405    }
406    let Some(db_path) = &config.db_path else {
407        return command;
408    };
409    let params = command.get_mut("params").and_then(Value::as_object_mut);
410    if let Some(params) = params {
411        params
412            .entry("dbPath")
413            .or_insert_with(|| Value::from(db_path.clone()));
414    } else if let Some(obj) = command.as_object_mut() {
415        obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
416    }
417    command
418}
419
420/// The header set an `activateSecurity` command carries (already validated by
421/// the shared command router; a shape failure surfaces as its error reply, so
422/// this extraction only sees well-formed sets on the success path).
423fn activation_headers(command: &Value) -> Option<Vec<(String, String)>> {
424    if command.get("method").and_then(Value::as_str) != Some("activateSecurity") {
425        return None;
426    }
427    let headers = command.pointer("/params/headers")?;
428    syncular_command::parse_headers(headers).ok()
429}
430
431fn client_error(message: impl Into<String>) -> Value {
432    json!({ "error": { "code": "client.failed", "message": message.into() } })
433}
434
435fn security_preflight_error() -> Value {
436    json!({
437        "error": {
438            "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
439            "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
440        }
441    })
442}
443
444fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
445    let object = value
446        .and_then(Value::as_object)
447        .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
448    let table = object
449        .get("table")
450        .and_then(Value::as_str)
451        .ok_or_else(|| "window base missing table".to_owned())?
452        .to_owned();
453    let variable = object
454        .get("variable")
455        .and_then(Value::as_str)
456        .ok_or_else(|| "window base missing variable".to_owned())?
457        .to_owned();
458    let fixed_scopes = match object.get("fixedScopes") {
459        Some(value) => syncular_client::values::json_to_scope_map(value)?,
460        None => Vec::new(),
461    };
462    let params = object
463        .get("params")
464        .and_then(Value::as_str)
465        .map(str::to_owned);
466    Ok(WindowBase {
467        table,
468        variable,
469        fixed_scopes,
470        params,
471    })
472}
473
474fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
475    let Some(value) = value else {
476        return Ok(Vec::new());
477    };
478    if value.is_null() {
479        return Ok(Vec::new());
480    }
481    let entries = value
482        .as_array()
483        .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
484    entries
485        .iter()
486        .map(|entry| {
487            let units = entry
488                .get("units")
489                .and_then(Value::as_array)
490                .map(|values| {
491                    values
492                        .iter()
493                        .filter_map(|value| value.as_str().map(str::to_owned))
494                        .collect()
495                })
496                .unwrap_or_default();
497            Ok(WindowCoverage {
498                base: parse_window_base(entry.get("base"))?,
499                units,
500            })
501        })
502        .collect()
503}
504
505fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
506    for event in core.drain_events() {
507        emit(&event.json);
508    }
509}
510
511// -- Tauri commands (the thin shell) -----------------------------------------
512
513#[tauri::command]
514async fn syncular_command<R: Runtime>(
515    app: tauri::AppHandle<R>,
516    command: Value,
517) -> Result<Value, String> {
518    let state = app.state::<SyncularState>();
519    let method = command
520        .get("method")
521        .and_then(Value::as_str)
522        .unwrap_or("")
523        .to_owned();
524    if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
525        // Gate fast reads and wait for already-started sidecar snapshots before
526        // the owner-thread barrier is enqueued.
527        state.security_gate.begin_preflight()?;
528    }
529    let create_preflight = method == "create"
530        && command
531            .pointer("/params/securityPreflight")
532            .and_then(Value::as_bool)
533            .unwrap_or(false);
534    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
535    state.send(Request::Command {
536        command,
537        reply: reply_tx,
538    })?;
539    let reply = reply_rx
540        .recv()
541        .map_err(|_| "the syncular core dropped the reply".to_owned())?;
542    let succeeded = reply.get("error").is_none();
543    if succeeded && method == "create" {
544        if !create_preflight {
545            state.security_gate.activate()?;
546        }
547    } else if succeeded && method == "activateSecurity" {
548        state.security_gate.activate()?;
549    }
550    Ok(reply)
551}
552
553/// Replace the native transport's request headers at runtime — the auth
554/// Header rotation path: a fresh JWT reaches the transport without
555/// re-registering the plugin. HTTP requests use the new set from the next
556/// call; the realtime socket applies it on its next (re)connect.
557#[tauri::command]
558async fn syncular_set_headers<R: Runtime>(
559    app: tauri::AppHandle<R>,
560    headers: std::collections::BTreeMap<String, String>,
561) -> Result<Value, String> {
562    let state = app.state::<SyncularState>();
563    // Runtime bearer replacement is an active-session operation. Hold the
564    // same gate as fast reads so beginSecurityPreflight both rejects new
565    // replacements and waits for an already-started mailbox update.
566    let _active_guard = match state.security_gate.enter_read() {
567        Ok(guard) => guard,
568        Err(reply) => return Ok(reply),
569    };
570    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
571    state.send(Request::SetHeaders {
572        headers: headers.into_iter().collect(),
573        reply: reply_tx,
574    })?;
575    reply_rx
576        .recv()
577        .map_err(|_| "the syncular core dropped the reply".to_owned())
578}
579
580#[tauri::command]
581async fn syncular_query<R: Runtime>(
582    app: tauri::AppHandle<R>,
583    sql: String,
584    params: Option<Value>,
585) -> Result<Value, String> {
586    let state = app.state::<SyncularState>();
587    let _read_guard = match state.security_gate.enter_read() {
588        Ok(guard) => guard,
589        Err(reply) => return Ok(reply),
590    };
591    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
592    state.send(Request::Query {
593        sql,
594        params: params.unwrap_or(Value::Null),
595        reply: reply_tx,
596    })?;
597    reply_rx
598        .recv()
599        .map_err(|_| "the syncular core dropped the reply".to_owned())
600}
601
602/// Atomic rows + revision + window coverage on the independent read-only
603/// connection. In-memory configurations fall back to the core owner because
604/// SQLite cannot share an anonymous database across connections.
605#[tauri::command]
606async fn syncular_query_snapshot<R: Runtime>(
607    app: tauri::AppHandle<R>,
608    sql: String,
609    params: Option<Value>,
610    coverage: Option<Value>,
611) -> Result<Value, String> {
612    let state = app.state::<SyncularState>();
613    let _read_guard = match state.security_gate.enter_read() {
614        Ok(guard) => guard,
615        Err(reply) => return Ok(reply),
616    };
617    let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
618    let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
619
620    if state.reader.is_some() {
621        let bind = match params_value.as_array() {
622            Some(values) => values.clone(),
623            None => return Ok(client_error("querySnapshot params must be a list")),
624        };
625        let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
626            Ok(value) => value,
627            Err(message) => return Ok(client_error(message)),
628        };
629        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
630        state.send_read(ReadRequest::QuerySnapshot {
631            sql,
632            params: bind,
633            coverage: parsed_coverage,
634            reply: reply_tx,
635        })?;
636        return reply_rx
637            .recv()
638            .map_err(|_| "the syncular read thread dropped the reply".to_owned());
639    }
640
641    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
642    state.send(Request::Command {
643        command: json!({
644            "method": "querySnapshot",
645            "params": { "sql": sql, "params": params_value, "coverage": coverage_value }
646        }),
647        reply: reply_tx,
648    })?;
649    reply_rx
650        .recv()
651        .map_err(|_| "the syncular core dropped the reply".to_owned())
652}
653
654/// Initialize the plugin with a config. Register with
655/// `tauri::Builder::default().plugin(tauri_plugin_syncular::init(config))`.
656///
657/// The owning thread is spawned in `setup`; it builds the core (native
658/// transport if `base_url` + the `native-transport` feature), pumps events onto
659/// [`EVENT_NAME`], and runs the §8.4 host loop. The mailbox `Sender` is managed
660/// as plugin state and torn down on `RunEvent::Exit`.
661pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
662    Builder::<R>::new("syncular")
663        .invoke_handler(tauri::generate_handler![
664            syncular_command,
665            syncular_query,
666            syncular_query_snapshot,
667            syncular_set_headers
668        ])
669        .setup(move |app, _api| {
670            let (tx, rx) = std::sync::mpsc::channel::<Request>();
671            let reader = match config
672                .db_path
673                .as_ref()
674                .filter(|path| path.as_str() != ":memory:")
675            {
676                Some(path) => {
677                    let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
678                    let path = path.clone();
679                    std::thread::Builder::new()
680                        .name("syncular-read".to_owned())
681                        .spawn(move || run_reader_thread(path, reader_rx))
682                        .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
683                    Some(Mutex::new(reader_tx))
684                }
685                None => None,
686            };
687            app.manage(SyncularState {
688                sender: Mutex::new(tx.clone()),
689                reader,
690                // Fail closed until the first successful `create` declares
691                // whether this process starts active or in preflight.
692                security_gate: SecurityGate::new_preflight(),
693            });
694            let app_handle = app.clone();
695            let emit = move |value: &Value| {
696                // Best-effort: a webview that has gone away must not crash the
697                // owning thread. Emit to all windows on the syncular channel.
698                let _ = app_handle.emit(EVENT_NAME, value.clone());
699            };
700            std::thread::Builder::new()
701                .name("syncular-core".to_owned())
702                .spawn(move || run_owner_thread(config, tx, rx, emit))
703                .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
704            Ok(())
705        })
706        .on_event(|app, event| {
707            if let RunEvent::Exit = event {
708                if let Some(state) = app.try_state::<SyncularState>() {
709                    let _ = state.send(Request::Shutdown);
710                    if state.reader.is_some() {
711                        let _ = state.send_read(ReadRequest::Shutdown);
712                    }
713                }
714            }
715        })
716        .build()
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    #[test]
724    fn config_to_transport_json_shapes_fields() {
725        let config = SyncularConfig {
726            base_url: Some("https://api.example.com".to_owned()),
727            headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
728            ..Default::default()
729        };
730        let json = config.to_transport_json();
731        assert_eq!(json["baseUrl"], "https://api.example.com");
732        assert_eq!(json["headers"]["authorization"], "Bearer x");
733    }
734
735    #[test]
736    fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
737        let gate = std::sync::Arc::new(SecurityGate::new_preflight());
738        gate.activate().expect("activate test gate");
739        let read = gate.enter_read().expect("active read");
740        let gate_for_barrier = std::sync::Arc::clone(&gate);
741        let (done_tx, done_rx) = std::sync::mpsc::channel();
742        let barrier = std::thread::spawn(move || {
743            gate_for_barrier.begin_preflight().expect("enter preflight");
744            done_tx.send(()).expect("barrier reply");
745        });
746
747        assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
748        drop(read);
749        done_rx
750            .recv_timeout(Duration::from_secs(1))
751            .expect("barrier drains after the read");
752        barrier.join().expect("barrier thread");
753        assert!(gate.enter_read().is_err());
754    }
755
756    #[test]
757    fn direct_set_headers_command_respects_the_native_preflight_gate() {
758        use std::collections::BTreeMap;
759        use tauri::test::{mock_builder, mock_context, noop_assets};
760
761        let app = mock_builder()
762            .plugin(init(SyncularConfig {
763                auto_sync: false,
764                ..Default::default()
765            }))
766            .build(mock_context(noop_assets()))
767            .expect("build mock app");
768
769        let blocked = tauri::async_runtime::block_on(syncular_set_headers(
770            app.handle().clone(),
771            BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
772        ))
773        .expect("preflight reply");
774        assert_eq!(
775            blocked["error"]["code"],
776            Value::from("client.security_preflight_required")
777        );
778
779        let created = tauri::async_runtime::block_on(syncular_command(
780            app.handle().clone(),
781            json!({
782                "method": "create",
783                "params": {
784                    "clientId": "native-header-gate",
785                    "schema": { "version": 1, "tables": [] }
786                }
787            }),
788        ))
789        .expect("create reply");
790        assert!(created.get("error").is_none(), "{created}");
791
792        let active = tauri::async_runtime::block_on(syncular_set_headers(
793            app.handle().clone(),
794            BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
795        ))
796        .expect("active reply");
797        assert_eq!(active["result"], Value::Null);
798    }
799
800    #[test]
801    fn activation_headers_extracts_only_the_activation_set() {
802        assert_eq!(
803            activation_headers(&json!({
804                "method": "activateSecurity",
805                "params": { "headers": { "authorization": "Bearer fresh" } }
806            })),
807            Some(vec![(
808                "authorization".to_owned(),
809                "Bearer fresh".to_owned()
810            )])
811        );
812        // Absent headers, other methods, and invalid shapes all yield nothing.
813        assert_eq!(
814            activation_headers(&json!({ "method": "activateSecurity", "params": {} })),
815            None
816        );
817        assert_eq!(
818            activation_headers(&json!({
819                "method": "setWindow",
820                "params": { "headers": { "authorization": "Bearer fresh" } }
821            })),
822            None
823        );
824        assert_eq!(
825            activation_headers(&json!({
826                "method": "activateSecurity",
827                "params": { "headers": { "authorization": 7 } }
828            })),
829            None
830        );
831    }
832
833    #[test]
834    fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
835        use tauri::test::{mock_builder, mock_context, noop_assets};
836
837        let app = mock_builder()
838            .plugin(init(SyncularConfig {
839                auto_sync: false,
840                ..Default::default()
841            }))
842            .build(mock_context(noop_assets()))
843            .expect("build mock app");
844        let command = |value: Value| {
845            tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
846                .expect("command reply")
847        };
848
849        let created = command(json!({
850            "method": "create",
851            "params": {
852                "clientId": "native-preflight-escape",
853                "schema": { "version": 1, "tables": [] },
854                "securityPreflight": true
855            }
856        }));
857        assert!(created.get("error").is_none(), "{created}");
858
859        // The escape attempt: a plain re-create must be refused by the shared
860        // router, and the plugin's fast-read gate must stay engaged.
861        let escape = command(json!({
862            "method": "create",
863            "params": {
864                "clientId": "native-preflight-escape",
865                "schema": { "version": 1, "tables": [] }
866            }
867        }));
868        assert_eq!(
869            escape["error"]["code"],
870            Value::from("client.security_preflight_required"),
871            "{escape}"
872        );
873        let read = tauri::async_runtime::block_on(syncular_query(
874            app.handle().clone(),
875            "SELECT 1".to_owned(),
876            None,
877        ))
878        .expect("query reply");
879        assert_eq!(
880            read["error"]["code"],
881            Value::from("client.security_preflight_required")
882        );
883
884        // Activation (with a fresh header set) releases both gates.
885        let activated = command(json!({
886            "method": "activateSecurity",
887            "params": { "headers": { "authorization": "Bearer fresh" } }
888        }));
889        assert!(activated.get("error").is_none(), "{activated}");
890        let read = tauri::async_runtime::block_on(syncular_query(
891            app.handle().clone(),
892            "SELECT 1 AS value".to_owned(),
893            None,
894        ))
895        .expect("query reply");
896        assert_eq!(read["result"]["rows"][0]["value"], 1);
897        let recreated = command(json!({
898            "method": "create",
899            "params": {
900                "clientId": "native-preflight-escape",
901                "schema": { "version": 1, "tables": [] }
902            }
903        }));
904        assert!(recreated.get("error").is_none(), "{recreated}");
905    }
906
907    #[test]
908    fn inject_db_path_adds_to_create_only() {
909        let config = SyncularConfig {
910            db_path: Some("/tmp/app.db".to_owned()),
911            ..Default::default()
912        };
913        // create gains the path…
914        let created = inject_db_path(
915            json!({ "method": "create", "params": { "clientId": "c1" } }),
916            &config,
917        );
918        assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
919        // …a create with no params object gets one…
920        let created2 = inject_db_path(json!({ "method": "create" }), &config);
921        assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
922        // …an explicit dbPath is preserved…
923        let explicit = inject_db_path(
924            json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
925            &config,
926        );
927        assert_eq!(explicit["params"]["dbPath"], "/other.db");
928        // …and a non-create command is untouched.
929        let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
930        assert!(mutate["params"].get("dbPath").is_none());
931    }
932
933    #[test]
934    fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
935        let parsed = parse_coverage(Some(&json!([{
936            "base": {
937                "table": "tasks",
938                "variable": "project_id",
939                "fixedScopes": { "tenant_id": ["one", "two"] },
940                "params": "opaque"
941            },
942            "units": ["a", "b"]
943        }])))
944        .expect("parse coverage");
945        assert_eq!(parsed.len(), 1);
946        let entry = &parsed[0];
947        assert_eq!(entry.base.table, "tasks");
948        assert_eq!(entry.base.variable, "project_id");
949        assert_eq!(
950            entry.base.fixed_scopes,
951            vec![(
952                "tenant_id".to_owned(),
953                vec!["one".to_owned(), "two".to_owned()]
954            )]
955        );
956        assert_eq!(entry.base.params.as_deref(), Some("opaque"));
957        assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
958    }
959
960    /// The owner-thread mailbox loop end-to-end, without any Tauri window: post
961    /// commands, collect emitted events. This is the real host path — the Tauri
962    /// commands are a two-line channel forward over exactly this.
963    #[test]
964    fn owner_thread_round_trips_over_mailbox() {
965        use std::sync::mpsc::channel;
966        use std::sync::{Arc, Mutex as StdMutex};
967
968        let (tx, rx) = channel::<Request>();
969        let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
970        let events_for_thread = Arc::clone(&events);
971        let config = SyncularConfig {
972            auto_sync: false,
973            ..Default::default()
974        };
975        let owner_tx = tx.clone();
976        let handle = std::thread::spawn(move || {
977            run_owner_thread(config, owner_tx, rx, move |v| {
978                events_for_thread.lock().unwrap().push(v.clone());
979            });
980        });
981
982        let call = |command: Value| -> Value {
983            let (rtx, rrx) = channel();
984            tx.send(Request::Command {
985                command,
986                reply: rtx,
987            })
988            .unwrap();
989            rrx.recv().unwrap()
990        };
991
992        let schema = json!({
993            "version": 1,
994            "tables": [{
995                "name": "todo", "primaryKey": "id",
996                "columns": [
997                    { "name": "id", "type": "string", "nullable": false },
998                    { "name": "title", "type": "string", "nullable": false }
999                ],
1000                "scopes": []
1001            }]
1002        });
1003        assert_eq!(
1004            call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1005                ["result"],
1006            json!({})
1007        );
1008        call(json!({ "method": "mutate", "params": { "mutations": [{
1009            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1010        }] } }));
1011
1012        // A query over the mailbox.
1013        let (qtx, qrx) = channel();
1014        tx.send(Request::Query {
1015            sql: "SELECT title FROM todo".to_owned(),
1016            params: Value::Null,
1017            reply: qtx,
1018        })
1019        .unwrap();
1020        let rows = qrx.recv().unwrap();
1021        assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1022
1023        // Header rotation rides the same mailbox; a
1024        // client-local (Null-transport) core accepts and ignores the set.
1025        let (htx, hrx) = channel();
1026        tx.send(Request::SetHeaders {
1027            headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1028            reply: htx,
1029        })
1030        .unwrap();
1031        assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1032
1033        tx.send(Request::Shutdown).unwrap();
1034        handle.join().unwrap();
1035
1036        let seen = events.lock().unwrap();
1037        let kinds: Vec<String> = seen
1038            .iter()
1039            .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1040            .collect();
1041        // The local mutate emits the exact revisioned batch onto the channel.
1042        assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1043    }
1044
1045    #[test]
1046    fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1047        use std::sync::mpsc::channel;
1048
1049        let path =
1050            std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1051        let config = SyncularConfig {
1052            db_path: Some(path.to_string_lossy().into_owned()),
1053            auto_sync: false,
1054            ..Default::default()
1055        };
1056        let (tx, rx) = channel::<Request>();
1057        let owner_tx = tx.clone();
1058        let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1059
1060        let (create_tx, create_rx) = channel();
1061        tx.send(Request::Command {
1062            command: json!({
1063                "method": "create",
1064                "params": {
1065                    "clientId": "sidecar-client",
1066                    "schema": { "version": 1, "tables": [] },
1067                    "dbPath": path.to_string_lossy()
1068                }
1069            }),
1070            reply: create_tx,
1071        })
1072        .expect("post create");
1073        assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1074
1075        let (read_tx, read_rx) = channel::<ReadRequest>();
1076        let read_path = path.to_string_lossy().into_owned();
1077        let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx));
1078
1079        // Model a slow HTTP/WS round on the mutable owner. The dedicated read
1080        // mailbox must still return the durable local snapshot immediately.
1081        let (entered_tx, entered_rx) = channel();
1082        tx.send(Request::Block {
1083            duration: Duration::from_millis(200),
1084            entered: entered_tx,
1085        })
1086        .expect("block owner");
1087        entered_rx.recv().expect("owner entered blocking round");
1088        let (snapshot_tx, snapshot_rx) = channel();
1089        read_tx
1090            .send(ReadRequest::QuerySnapshot {
1091                sql: "SELECT 1 AS value".to_owned(),
1092                params: Vec::new(),
1093                coverage: Vec::new(),
1094                reply: snapshot_tx,
1095            })
1096            .expect("post snapshot");
1097        let snapshot = snapshot_rx
1098            .recv_timeout(Duration::from_millis(50))
1099            .expect("local snapshot must not wait for the owner");
1100        assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
1101
1102        read_tx.send(ReadRequest::Shutdown).expect("stop reader");
1103        reader.join().expect("join reader");
1104        tx.send(Request::Shutdown).expect("stop owner");
1105        owner.join().expect("join owner");
1106        std::fs::remove_file(path).expect("remove temp database");
1107    }
1108}