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    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
539    state.send(Request::SetHeaders {
540        headers: headers.into_iter().collect(),
541        reply: reply_tx,
542    })?;
543    reply_rx
544        .recv()
545        .map_err(|_| "the syncular core dropped the reply".to_owned())
546}
547
548#[tauri::command]
549async fn syncular_query<R: Runtime>(
550    app: tauri::AppHandle<R>,
551    sql: String,
552    params: Option<Value>,
553) -> Result<Value, String> {
554    let state = app.state::<SyncularState>();
555    let _read_guard = match state.security_gate.enter_read() {
556        Ok(guard) => guard,
557        Err(reply) => return Ok(reply),
558    };
559    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
560    state.send(Request::Query {
561        sql,
562        params: params.unwrap_or(Value::Null),
563        reply: reply_tx,
564    })?;
565    reply_rx
566        .recv()
567        .map_err(|_| "the syncular core dropped the reply".to_owned())
568}
569
570/// Atomic rows + revision + window coverage on the independent read-only
571/// connection. In-memory configurations fall back to the core owner because
572/// SQLite cannot share an anonymous database across connections.
573#[tauri::command]
574async fn syncular_query_snapshot<R: Runtime>(
575    app: tauri::AppHandle<R>,
576    sql: String,
577    params: Option<Value>,
578    coverage: Option<Value>,
579) -> Result<Value, String> {
580    let state = app.state::<SyncularState>();
581    let _read_guard = match state.security_gate.enter_read() {
582        Ok(guard) => guard,
583        Err(reply) => return Ok(reply),
584    };
585    let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
586    let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
587
588    if state.reader.is_some() {
589        let bind = match params_value.as_array() {
590            Some(values) => values.clone(),
591            None => return Ok(client_error("querySnapshot params must be a list")),
592        };
593        let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
594            Ok(value) => value,
595            Err(message) => return Ok(client_error(message)),
596        };
597        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
598        state.send_read(ReadRequest::QuerySnapshot {
599            sql,
600            params: bind,
601            coverage: parsed_coverage,
602            reply: reply_tx,
603        })?;
604        return reply_rx
605            .recv()
606            .map_err(|_| "the syncular read thread dropped the reply".to_owned());
607    }
608
609    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
610    state.send(Request::Command {
611        command: json!({
612            "method": "querySnapshot",
613            "params": { "sql": sql, "params": params_value, "coverage": coverage_value }
614        }),
615        reply: reply_tx,
616    })?;
617    reply_rx
618        .recv()
619        .map_err(|_| "the syncular core dropped the reply".to_owned())
620}
621
622/// Initialize the plugin with a config. Register with
623/// `tauri::Builder::default().plugin(tauri_plugin_syncular::init(config))`.
624///
625/// The owning thread is spawned in `setup`; it builds the core (native
626/// transport if `base_url` + the `native-transport` feature), pumps events onto
627/// [`EVENT_NAME`], and runs the §8.4 host loop. The mailbox `Sender` is managed
628/// as plugin state and torn down on `RunEvent::Exit`.
629pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
630    Builder::<R>::new("syncular")
631        .invoke_handler(tauri::generate_handler![
632            syncular_command,
633            syncular_query,
634            syncular_query_snapshot,
635            syncular_set_headers
636        ])
637        .setup(move |app, _api| {
638            let (tx, rx) = std::sync::mpsc::channel::<Request>();
639            let reader = match config
640                .db_path
641                .as_ref()
642                .filter(|path| path.as_str() != ":memory:")
643            {
644                Some(path) => {
645                    let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
646                    let path = path.clone();
647                    std::thread::Builder::new()
648                        .name("syncular-read".to_owned())
649                        .spawn(move || run_reader_thread(path, reader_rx))
650                        .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
651                    Some(Mutex::new(reader_tx))
652                }
653                None => None,
654            };
655            app.manage(SyncularState {
656                sender: Mutex::new(tx.clone()),
657                reader,
658                // Fail closed until the first successful `create` declares
659                // whether this process starts active or in preflight.
660                security_gate: SecurityGate::new_preflight(),
661            });
662            let app_handle = app.clone();
663            let emit = move |value: &Value| {
664                // Best-effort: a webview that has gone away must not crash the
665                // owning thread. Emit to all windows on the syncular channel.
666                let _ = app_handle.emit(EVENT_NAME, value.clone());
667            };
668            std::thread::Builder::new()
669                .name("syncular-core".to_owned())
670                .spawn(move || run_owner_thread(config, tx, rx, emit))
671                .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
672            Ok(())
673        })
674        .on_event(|app, event| {
675            if let RunEvent::Exit = event {
676                if let Some(state) = app.try_state::<SyncularState>() {
677                    let _ = state.send(Request::Shutdown);
678                    if state.reader.is_some() {
679                        let _ = state.send_read(ReadRequest::Shutdown);
680                    }
681                }
682            }
683        })
684        .build()
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn config_to_transport_json_shapes_fields() {
693        let config = SyncularConfig {
694            base_url: Some("https://api.example.com".to_owned()),
695            headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
696            ..Default::default()
697        };
698        let json = config.to_transport_json();
699        assert_eq!(json["baseUrl"], "https://api.example.com");
700        assert_eq!(json["headers"]["authorization"], "Bearer x");
701    }
702
703    #[test]
704    fn security_gate_blocks_new_reads_and_waits_for_in_flight_snapshots() {
705        let gate = std::sync::Arc::new(SecurityGate::new_preflight());
706        gate.activate().expect("activate test gate");
707        let read = gate.enter_read().expect("active read");
708        let gate_for_barrier = std::sync::Arc::clone(&gate);
709        let (done_tx, done_rx) = std::sync::mpsc::channel();
710        let barrier = std::thread::spawn(move || {
711            gate_for_barrier.begin_preflight().expect("enter preflight");
712            done_tx.send(()).expect("barrier reply");
713        });
714
715        assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
716        drop(read);
717        done_rx
718            .recv_timeout(Duration::from_secs(1))
719            .expect("barrier drains after the read");
720        barrier.join().expect("barrier thread");
721        assert!(gate.enter_read().is_err());
722    }
723
724    #[test]
725    fn inject_db_path_adds_to_create_only() {
726        let config = SyncularConfig {
727            db_path: Some("/tmp/app.db".to_owned()),
728            ..Default::default()
729        };
730        // create gains the path…
731        let created = inject_db_path(
732            json!({ "method": "create", "params": { "clientId": "c1" } }),
733            &config,
734        );
735        assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
736        // …a create with no params object gets one…
737        let created2 = inject_db_path(json!({ "method": "create" }), &config);
738        assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
739        // …an explicit dbPath is preserved…
740        let explicit = inject_db_path(
741            json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
742            &config,
743        );
744        assert_eq!(explicit["params"]["dbPath"], "/other.db");
745        // …and a non-create command is untouched.
746        let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
747        assert!(mutate["params"].get("dbPath").is_none());
748    }
749
750    #[test]
751    fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
752        let parsed = parse_coverage(Some(&json!([{
753            "base": {
754                "table": "tasks",
755                "variable": "project_id",
756                "fixedScopes": { "tenant_id": ["one", "two"] },
757                "params": "opaque"
758            },
759            "units": ["a", "b"]
760        }])))
761        .expect("parse coverage");
762        assert_eq!(parsed.len(), 1);
763        let entry = &parsed[0];
764        assert_eq!(entry.base.table, "tasks");
765        assert_eq!(entry.base.variable, "project_id");
766        assert_eq!(
767            entry.base.fixed_scopes,
768            vec![(
769                "tenant_id".to_owned(),
770                vec!["one".to_owned(), "two".to_owned()]
771            )]
772        );
773        assert_eq!(entry.base.params.as_deref(), Some("opaque"));
774        assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
775    }
776
777    /// The owner-thread mailbox loop end-to-end, without any Tauri window: post
778    /// commands, collect emitted events. This is the real host path — the Tauri
779    /// commands are a two-line channel forward over exactly this.
780    #[test]
781    fn owner_thread_round_trips_over_mailbox() {
782        use std::sync::mpsc::channel;
783        use std::sync::{Arc, Mutex as StdMutex};
784
785        let (tx, rx) = channel::<Request>();
786        let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
787        let events_for_thread = Arc::clone(&events);
788        let config = SyncularConfig {
789            auto_sync: false,
790            ..Default::default()
791        };
792        let owner_tx = tx.clone();
793        let handle = std::thread::spawn(move || {
794            run_owner_thread(config, owner_tx, rx, move |v| {
795                events_for_thread.lock().unwrap().push(v.clone());
796            });
797        });
798
799        let call = |command: Value| -> Value {
800            let (rtx, rrx) = channel();
801            tx.send(Request::Command {
802                command,
803                reply: rtx,
804            })
805            .unwrap();
806            rrx.recv().unwrap()
807        };
808
809        let schema = json!({
810            "version": 1,
811            "tables": [{
812                "name": "todo", "primaryKey": "id",
813                "columns": [
814                    { "name": "id", "type": "string", "nullable": false },
815                    { "name": "title", "type": "string", "nullable": false }
816                ],
817                "scopes": []
818            }]
819        });
820        assert_eq!(
821            call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
822                ["result"],
823            json!({})
824        );
825        call(json!({ "method": "mutate", "params": { "mutations": [{
826            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
827        }] } }));
828
829        // A query over the mailbox.
830        let (qtx, qrx) = channel();
831        tx.send(Request::Query {
832            sql: "SELECT title FROM todo".to_owned(),
833            params: Value::Null,
834            reply: qtx,
835        })
836        .unwrap();
837        let rows = qrx.recv().unwrap();
838        assert_eq!(rows["result"]["rows"][0]["title"], "hi");
839
840        // RFC 0002 §2.3: header rotation rides the same mailbox; a
841        // client-local (Null-transport) core accepts and ignores the set.
842        let (htx, hrx) = channel();
843        tx.send(Request::SetHeaders {
844            headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
845            reply: htx,
846        })
847        .unwrap();
848        assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
849
850        tx.send(Request::Shutdown).unwrap();
851        handle.join().unwrap();
852
853        let seen = events.lock().unwrap();
854        let kinds: Vec<String> = seen
855            .iter()
856            .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
857            .collect();
858        // The local mutate emits the exact revisioned batch onto the channel.
859        assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
860    }
861
862    #[test]
863    fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
864        use std::sync::mpsc::channel;
865
866        let path =
867            std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
868        let config = SyncularConfig {
869            db_path: Some(path.to_string_lossy().into_owned()),
870            auto_sync: false,
871            ..Default::default()
872        };
873        let (tx, rx) = channel::<Request>();
874        let owner_tx = tx.clone();
875        let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
876
877        let (create_tx, create_rx) = channel();
878        tx.send(Request::Command {
879            command: json!({
880                "method": "create",
881                "params": {
882                    "clientId": "sidecar-client",
883                    "schema": { "version": 1, "tables": [] },
884                    "dbPath": path.to_string_lossy()
885                }
886            }),
887            reply: create_tx,
888        })
889        .expect("post create");
890        assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
891
892        let (read_tx, read_rx) = channel::<ReadRequest>();
893        let read_path = path.to_string_lossy().into_owned();
894        let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx));
895
896        // Model a slow HTTP/WS round on the mutable owner. The dedicated read
897        // mailbox must still return the durable local snapshot immediately.
898        let (entered_tx, entered_rx) = channel();
899        tx.send(Request::Block {
900            duration: Duration::from_millis(200),
901            entered: entered_tx,
902        })
903        .expect("block owner");
904        entered_rx.recv().expect("owner entered blocking round");
905        let (snapshot_tx, snapshot_rx) = channel();
906        read_tx
907            .send(ReadRequest::QuerySnapshot {
908                sql: "SELECT 1 AS value".to_owned(),
909                params: Vec::new(),
910                coverage: Vec::new(),
911                reply: snapshot_tx,
912            })
913            .expect("post snapshot");
914        let snapshot = snapshot_rx
915            .recv_timeout(Duration::from_millis(50))
916            .expect("local snapshot must not wait for the owner");
917        assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
918
919        read_tx.send(ReadRequest::Shutdown).expect("stop reader");
920        reader.join().expect("join reader");
921        tx.send(Request::Shutdown).expect("stop owner");
922        owner.join().expect("join owner");
923        std::fs::remove_file(path).expect("remove temp database");
924    }
925}