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