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                let _ = reply.send(result);
357                pump_events(&mut core, &emit);
358            }
359            Request::Query { sql, params, reply } => {
360                let result = core.query(&sql, params);
361                let _ = reply.send(result);
362                pump_events(&mut core, &emit);
363            }
364            Request::SetHeaders { headers, reply } => {
365                core.set_headers(headers);
366                let _ = reply.send(json!({ "result": null }));
367            }
368            Request::TransportWake => {
369                core.poll_transport();
370                pump_events(&mut core, &emit);
371            }
372            #[cfg(test)]
373            Request::Block { duration, entered } => {
374                let _ = entered.send(());
375                std::thread::sleep(duration);
376            }
377            Request::Shutdown => {
378                core.shutdown();
379                return;
380            }
381        }
382    }
383}
384
385/// Inject the configured `db_path` into a `create` command's params if the JS
386/// side did not already supply one — so persistence is a plugin-config concern,
387/// not something every app must thread through the bridge.
388fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
389    if command.get("method").and_then(Value::as_str) != Some("create") {
390        return command;
391    }
392    let Some(db_path) = &config.db_path else {
393        return command;
394    };
395    let params = command.get_mut("params").and_then(Value::as_object_mut);
396    if let Some(params) = params {
397        params
398            .entry("dbPath")
399            .or_insert_with(|| Value::from(db_path.clone()));
400    } else if let Some(obj) = command.as_object_mut() {
401        obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
402    }
403    command
404}
405
406fn client_error(message: impl Into<String>) -> Value {
407    json!({ "error": { "code": "client.failed", "message": message.into() } })
408}
409
410fn security_preflight_error() -> Value {
411    json!({
412        "error": {
413            "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
414            "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
415        }
416    })
417}
418
419fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
420    let object = value
421        .and_then(Value::as_object)
422        .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
423    let table = object
424        .get("table")
425        .and_then(Value::as_str)
426        .ok_or_else(|| "window base missing table".to_owned())?
427        .to_owned();
428    let variable = object
429        .get("variable")
430        .and_then(Value::as_str)
431        .ok_or_else(|| "window base missing variable".to_owned())?
432        .to_owned();
433    let fixed_scopes = match object.get("fixedScopes") {
434        Some(value) => syncular_client::values::json_to_scope_map(value)?,
435        None => Vec::new(),
436    };
437    let params = object
438        .get("params")
439        .and_then(Value::as_str)
440        .map(str::to_owned);
441    Ok(WindowBase {
442        table,
443        variable,
444        fixed_scopes,
445        params,
446    })
447}
448
449fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
450    let Some(value) = value else {
451        return Ok(Vec::new());
452    };
453    if value.is_null() {
454        return Ok(Vec::new());
455    }
456    let entries = value
457        .as_array()
458        .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
459    entries
460        .iter()
461        .map(|entry| {
462            let units = entry
463                .get("units")
464                .and_then(Value::as_array)
465                .map(|values| {
466                    values
467                        .iter()
468                        .filter_map(|value| value.as_str().map(str::to_owned))
469                        .collect()
470                })
471                .unwrap_or_default();
472            Ok(WindowCoverage {
473                base: parse_window_base(entry.get("base"))?,
474                units,
475            })
476        })
477        .collect()
478}
479
480fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
481    for event in core.drain_events() {
482        emit(&event.json);
483    }
484}
485
486// -- Tauri commands (the thin shell) -----------------------------------------
487
488#[tauri::command]
489async fn syncular_command<R: Runtime>(
490    app: tauri::AppHandle<R>,
491    command: Value,
492) -> Result<Value, String> {
493    let state = app.state::<SyncularState>();
494    let method = command
495        .get("method")
496        .and_then(Value::as_str)
497        .unwrap_or("")
498        .to_owned();
499    if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
500        // Gate fast reads and wait for already-started sidecar snapshots before
501        // the owner-thread barrier is enqueued.
502        state.security_gate.begin_preflight()?;
503    }
504    let create_preflight = method == "create"
505        && command
506            .pointer("/params/securityPreflight")
507            .and_then(Value::as_bool)
508            .unwrap_or(false);
509    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
510    state.send(Request::Command {
511        command,
512        reply: reply_tx,
513    })?;
514    let reply = reply_rx
515        .recv()
516        .map_err(|_| "the syncular core dropped the reply".to_owned())?;
517    let succeeded = reply.get("error").is_none();
518    if succeeded && method == "create" {
519        if !create_preflight {
520            state.security_gate.activate()?;
521        }
522    } else if succeeded && method == "activateSecurity" {
523        state.security_gate.activate()?;
524    }
525    Ok(reply)
526}
527
528/// Replace the native transport's request headers at runtime — the auth
529/// rotation path (RFC 0002 §2.3): a fresh JWT reaches the transport without
530/// re-registering the plugin. HTTP requests use the new set from the next
531/// call; the realtime socket applies it on its next (re)connect.
532#[tauri::command]
533async fn syncular_set_headers<R: Runtime>(
534    app: tauri::AppHandle<R>,
535    headers: std::collections::BTreeMap<String, String>,
536) -> Result<Value, String> {
537    let state = app.state::<SyncularState>();
538    // Runtime bearer replacement is an active-session operation. Hold the
539    // same gate as fast reads so beginSecurityPreflight both rejects new
540    // replacements and waits for an already-started mailbox update.
541    let _active_guard = match state.security_gate.enter_read() {
542        Ok(guard) => guard,
543        Err(reply) => return Ok(reply),
544    };
545    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
546    state.send(Request::SetHeaders {
547        headers: headers.into_iter().collect(),
548        reply: reply_tx,
549    })?;
550    reply_rx
551        .recv()
552        .map_err(|_| "the syncular core dropped the reply".to_owned())
553}
554
555#[tauri::command]
556async fn syncular_query<R: Runtime>(
557    app: tauri::AppHandle<R>,
558    sql: String,
559    params: Option<Value>,
560) -> Result<Value, String> {
561    let state = app.state::<SyncularState>();
562    let _read_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::Query {
568        sql,
569        params: params.unwrap_or(Value::Null),
570        reply: reply_tx,
571    })?;
572    reply_rx
573        .recv()
574        .map_err(|_| "the syncular core dropped the reply".to_owned())
575}
576
577/// Atomic rows + revision + window coverage on the independent read-only
578/// connection. In-memory configurations fall back to the core owner because
579/// SQLite cannot share an anonymous database across connections.
580#[tauri::command]
581async fn syncular_query_snapshot<R: Runtime>(
582    app: tauri::AppHandle<R>,
583    sql: String,
584    params: Option<Value>,
585    coverage: Option<Value>,
586) -> Result<Value, String> {
587    let state = app.state::<SyncularState>();
588    let _read_guard = match state.security_gate.enter_read() {
589        Ok(guard) => guard,
590        Err(reply) => return Ok(reply),
591    };
592    let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
593    let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
594
595    if state.reader.is_some() {
596        let bind = match params_value.as_array() {
597            Some(values) => values.clone(),
598            None => return Ok(client_error("querySnapshot params must be a list")),
599        };
600        let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
601            Ok(value) => value,
602            Err(message) => return Ok(client_error(message)),
603        };
604        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
605        state.send_read(ReadRequest::QuerySnapshot {
606            sql,
607            params: bind,
608            coverage: parsed_coverage,
609            reply: reply_tx,
610        })?;
611        return reply_rx
612            .recv()
613            .map_err(|_| "the syncular read thread dropped the reply".to_owned());
614    }
615
616    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
617    state.send(Request::Command {
618        command: json!({
619            "method": "querySnapshot",
620            "params": { "sql": sql, "params": params_value, "coverage": coverage_value }
621        }),
622        reply: reply_tx,
623    })?;
624    reply_rx
625        .recv()
626        .map_err(|_| "the syncular core dropped the reply".to_owned())
627}
628
629/// Initialize the plugin with a config. Register with
630/// `tauri::Builder::default().plugin(tauri_plugin_syncular::init(config))`.
631///
632/// The owning thread is spawned in `setup`; it builds the core (native
633/// transport if `base_url` + the `native-transport` feature), pumps events onto
634/// [`EVENT_NAME`], and runs the §8.4 host loop. The mailbox `Sender` is managed
635/// as plugin state and torn down on `RunEvent::Exit`.
636pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
637    Builder::<R>::new("syncular")
638        .invoke_handler(tauri::generate_handler![
639            syncular_command,
640            syncular_query,
641            syncular_query_snapshot,
642            syncular_set_headers
643        ])
644        .setup(move |app, _api| {
645            let (tx, rx) = std::sync::mpsc::channel::<Request>();
646            let reader = match config
647                .db_path
648                .as_ref()
649                .filter(|path| path.as_str() != ":memory:")
650            {
651                Some(path) => {
652                    let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
653                    let path = path.clone();
654                    std::thread::Builder::new()
655                        .name("syncular-read".to_owned())
656                        .spawn(move || run_reader_thread(path, reader_rx))
657                        .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
658                    Some(Mutex::new(reader_tx))
659                }
660                None => None,
661            };
662            app.manage(SyncularState {
663                sender: Mutex::new(tx.clone()),
664                reader,
665                // Fail closed until the first successful `create` declares
666                // whether this process starts active or in preflight.
667                security_gate: SecurityGate::new_preflight(),
668            });
669            let app_handle = app.clone();
670            let emit = move |value: &Value| {
671                // Best-effort: a webview that has gone away must not crash the
672                // owning thread. Emit to all windows on the syncular channel.
673                let _ = app_handle.emit(EVENT_NAME, value.clone());
674            };
675            std::thread::Builder::new()
676                .name("syncular-core".to_owned())
677                .spawn(move || run_owner_thread(config, tx, rx, emit))
678                .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
679            Ok(())
680        })
681        .on_event(|app, event| {
682            if let RunEvent::Exit = event {
683                if let Some(state) = app.try_state::<SyncularState>() {
684                    let _ = state.send(Request::Shutdown);
685                    if state.reader.is_some() {
686                        let _ = state.send_read(ReadRequest::Shutdown);
687                    }
688                }
689            }
690        })
691        .build()
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697
698    #[test]
699    fn config_to_transport_json_shapes_fields() {
700        let config = SyncularConfig {
701            base_url: Some("https://api.example.com".to_owned()),
702            headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
703            ..Default::default()
704        };
705        let json = config.to_transport_json();
706        assert_eq!(json["baseUrl"], "https://api.example.com");
707        assert_eq!(json["headers"]["authorization"], "Bearer x");
708    }
709
710    #[test]
711    fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
712        let gate = std::sync::Arc::new(SecurityGate::new_preflight());
713        gate.activate().expect("activate test gate");
714        let read = gate.enter_read().expect("active read");
715        let gate_for_barrier = std::sync::Arc::clone(&gate);
716        let (done_tx, done_rx) = std::sync::mpsc::channel();
717        let barrier = std::thread::spawn(move || {
718            gate_for_barrier.begin_preflight().expect("enter preflight");
719            done_tx.send(()).expect("barrier reply");
720        });
721
722        assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
723        drop(read);
724        done_rx
725            .recv_timeout(Duration::from_secs(1))
726            .expect("barrier drains after the read");
727        barrier.join().expect("barrier thread");
728        assert!(gate.enter_read().is_err());
729    }
730
731    #[test]
732    fn direct_set_headers_command_respects_the_native_preflight_gate() {
733        use std::collections::BTreeMap;
734        use tauri::test::{mock_builder, mock_context, noop_assets};
735
736        let app = mock_builder()
737            .plugin(init(SyncularConfig {
738                auto_sync: false,
739                ..Default::default()
740            }))
741            .build(mock_context(noop_assets()))
742            .expect("build mock app");
743
744        let blocked = tauri::async_runtime::block_on(syncular_set_headers(
745            app.handle().clone(),
746            BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
747        ))
748        .expect("preflight reply");
749        assert_eq!(
750            blocked["error"]["code"],
751            Value::from("client.security_preflight_required")
752        );
753
754        let created = tauri::async_runtime::block_on(syncular_command(
755            app.handle().clone(),
756            json!({
757                "method": "create",
758                "params": {
759                    "clientId": "native-header-gate",
760                    "schema": { "version": 1, "tables": [] }
761                }
762            }),
763        ))
764        .expect("create reply");
765        assert!(created.get("error").is_none(), "{created}");
766
767        let active = tauri::async_runtime::block_on(syncular_set_headers(
768            app.handle().clone(),
769            BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
770        ))
771        .expect("active reply");
772        assert_eq!(active["result"], Value::Null);
773    }
774
775    #[test]
776    fn inject_db_path_adds_to_create_only() {
777        let config = SyncularConfig {
778            db_path: Some("/tmp/app.db".to_owned()),
779            ..Default::default()
780        };
781        // create gains the path…
782        let created = inject_db_path(
783            json!({ "method": "create", "params": { "clientId": "c1" } }),
784            &config,
785        );
786        assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
787        // …a create with no params object gets one…
788        let created2 = inject_db_path(json!({ "method": "create" }), &config);
789        assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
790        // …an explicit dbPath is preserved…
791        let explicit = inject_db_path(
792            json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
793            &config,
794        );
795        assert_eq!(explicit["params"]["dbPath"], "/other.db");
796        // …and a non-create command is untouched.
797        let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
798        assert!(mutate["params"].get("dbPath").is_none());
799    }
800
801    #[test]
802    fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
803        let parsed = parse_coverage(Some(&json!([{
804            "base": {
805                "table": "tasks",
806                "variable": "project_id",
807                "fixedScopes": { "tenant_id": ["one", "two"] },
808                "params": "opaque"
809            },
810            "units": ["a", "b"]
811        }])))
812        .expect("parse coverage");
813        assert_eq!(parsed.len(), 1);
814        let entry = &parsed[0];
815        assert_eq!(entry.base.table, "tasks");
816        assert_eq!(entry.base.variable, "project_id");
817        assert_eq!(
818            entry.base.fixed_scopes,
819            vec![(
820                "tenant_id".to_owned(),
821                vec!["one".to_owned(), "two".to_owned()]
822            )]
823        );
824        assert_eq!(entry.base.params.as_deref(), Some("opaque"));
825        assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
826    }
827
828    /// The owner-thread mailbox loop end-to-end, without any Tauri window: post
829    /// commands, collect emitted events. This is the real host path — the Tauri
830    /// commands are a two-line channel forward over exactly this.
831    #[test]
832    fn owner_thread_round_trips_over_mailbox() {
833        use std::sync::mpsc::channel;
834        use std::sync::{Arc, Mutex as StdMutex};
835
836        let (tx, rx) = channel::<Request>();
837        let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
838        let events_for_thread = Arc::clone(&events);
839        let config = SyncularConfig {
840            auto_sync: false,
841            ..Default::default()
842        };
843        let owner_tx = tx.clone();
844        let handle = std::thread::spawn(move || {
845            run_owner_thread(config, owner_tx, rx, move |v| {
846                events_for_thread.lock().unwrap().push(v.clone());
847            });
848        });
849
850        let call = |command: Value| -> Value {
851            let (rtx, rrx) = channel();
852            tx.send(Request::Command {
853                command,
854                reply: rtx,
855            })
856            .unwrap();
857            rrx.recv().unwrap()
858        };
859
860        let schema = json!({
861            "version": 1,
862            "tables": [{
863                "name": "todo", "primaryKey": "id",
864                "columns": [
865                    { "name": "id", "type": "string", "nullable": false },
866                    { "name": "title", "type": "string", "nullable": false }
867                ],
868                "scopes": []
869            }]
870        });
871        assert_eq!(
872            call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
873                ["result"],
874            json!({})
875        );
876        call(json!({ "method": "mutate", "params": { "mutations": [{
877            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
878        }] } }));
879
880        // A query over the mailbox.
881        let (qtx, qrx) = channel();
882        tx.send(Request::Query {
883            sql: "SELECT title FROM todo".to_owned(),
884            params: Value::Null,
885            reply: qtx,
886        })
887        .unwrap();
888        let rows = qrx.recv().unwrap();
889        assert_eq!(rows["result"]["rows"][0]["title"], "hi");
890
891        // RFC 0002 §2.3: header rotation rides the same mailbox; a
892        // client-local (Null-transport) core accepts and ignores the set.
893        let (htx, hrx) = channel();
894        tx.send(Request::SetHeaders {
895            headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
896            reply: htx,
897        })
898        .unwrap();
899        assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
900
901        tx.send(Request::Shutdown).unwrap();
902        handle.join().unwrap();
903
904        let seen = events.lock().unwrap();
905        let kinds: Vec<String> = seen
906            .iter()
907            .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
908            .collect();
909        // The local mutate emits the exact revisioned batch onto the channel.
910        assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
911    }
912
913    #[test]
914    fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
915        use std::sync::mpsc::channel;
916
917        let path =
918            std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
919        let config = SyncularConfig {
920            db_path: Some(path.to_string_lossy().into_owned()),
921            auto_sync: false,
922            ..Default::default()
923        };
924        let (tx, rx) = channel::<Request>();
925        let owner_tx = tx.clone();
926        let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
927
928        let (create_tx, create_rx) = channel();
929        tx.send(Request::Command {
930            command: json!({
931                "method": "create",
932                "params": {
933                    "clientId": "sidecar-client",
934                    "schema": { "version": 1, "tables": [] },
935                    "dbPath": path.to_string_lossy()
936                }
937            }),
938            reply: create_tx,
939        })
940        .expect("post create");
941        assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
942
943        let (read_tx, read_rx) = channel::<ReadRequest>();
944        let read_path = path.to_string_lossy().into_owned();
945        let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx));
946
947        // Model a slow HTTP/WS round on the mutable owner. The dedicated read
948        // mailbox must still return the durable local snapshot immediately.
949        let (entered_tx, entered_rx) = channel();
950        tx.send(Request::Block {
951            duration: Duration::from_millis(200),
952            entered: entered_tx,
953        })
954        .expect("block owner");
955        entered_rx.recv().expect("owner entered blocking round");
956        let (snapshot_tx, snapshot_rx) = channel();
957        read_tx
958            .send(ReadRequest::QuerySnapshot {
959                sql: "SELECT 1 AS value".to_owned(),
960                params: Vec::new(),
961                coverage: Vec::new(),
962                reply: snapshot_tx,
963            })
964            .expect("post snapshot");
965        let snapshot = snapshot_rx
966            .recv_timeout(Duration::from_millis(50))
967            .expect("local snapshot must not wait for the owner");
968        assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
969
970        read_tx.send(ReadRequest::Shutdown).expect("stop reader");
971        reader.join().expect("join reader");
972        tx.send(Request::Shutdown).expect("stop owner");
973        owner.join().expect("join owner");
974        std::fs::remove_file(path).expect("remove temp database");
975    }
976}