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::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}
155
156impl SyncularState {
157    fn send(&self, request: Request) -> Result<(), String> {
158        self.sender
159            .lock()
160            .map_err(|_| "syncular mailbox poisoned".to_owned())?
161            .send(request)
162            .map_err(|_| "the syncular core thread has stopped".to_owned())
163    }
164
165    fn send_read(&self, request: ReadRequest) -> Result<(), String> {
166        let Some(reader) = &self.reader else {
167            return Err("this syncular client has no file snapshot reader".to_owned());
168        };
169        reader
170            .lock()
171            .map_err(|_| "syncular read mailbox poisoned".to_owned())?
172            .send(request)
173            .map_err(|_| "the syncular read thread has stopped".to_owned())?;
174        Ok(())
175    }
176}
177
178fn run_reader_thread(path: String, rx: Receiver<ReadRequest>) {
179    let mut reader = FileQuerySnapshotReader::new(path);
180    while let Ok(request) = rx.recv() {
181        match request {
182            ReadRequest::QuerySnapshot {
183                sql,
184                params,
185                coverage,
186                reply,
187            } => {
188                let value = match reader.query_snapshot(&sql, &params, &coverage) {
189                    Ok(snapshot) => json!({ "result": snapshot }),
190                    Err(message) => json!({
191                        "error": { "code": "client.failed", "message": message }
192                    }),
193                };
194                let _ = reply.send(value);
195            }
196            ReadRequest::Shutdown => return,
197        }
198    }
199}
200
201/// The owning thread: builds the core, then loops over the mailbox and the
202/// background host policy. `emit` pushes drained events onto the Tauri channel.
203fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
204where
205    F: Fn(&Value) + Send + 'static,
206{
207    let transport_json = config.to_transport_json();
208    let wake_tx = tx.clone();
209    let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
210        let _ = wake_tx.send(Request::TransportWake);
211    });
212    let mut core = match SyncularCore::new_with_notify(&transport_json, Some(notify)) {
213        Ok(core) => core,
214        Err(message) => {
215            // A construction failure is terminal for this instance; surface it
216            // once on the channel so the webview can show it, then stop.
217            emit(&json!({ "type": "error", "message": message }));
218            return;
219        }
220    };
221
222    // No idle poll: commands/realtime wake the mailbox, while a retryable
223    // transport failure contributes one real monotonic deadline.
224    let mut background_deadline: Option<Instant> = None;
225    loop {
226        if config.auto_sync {
227            match core.take_sync_intent() {
228                syncular_client::SyncIntent::Interactive => {
229                    background_deadline = None;
230                    core.sync_until_idle();
231                    pump_events(&mut core, &emit);
232                    continue;
233                }
234                syncular_client::SyncIntent::Background { delay_ms } => {
235                    let candidate = Instant::now()
236                        .checked_add(Duration::from_millis(delay_ms))
237                        .unwrap_or_else(Instant::now);
238                    background_deadline = Some(
239                        background_deadline.map_or(candidate, |current| current.min(candidate)),
240                    );
241                }
242                syncular_client::SyncIntent::None => {}
243            }
244        }
245
246        let request = if let Some(deadline) = background_deadline {
247            let now = Instant::now();
248            if deadline <= now {
249                background_deadline = None;
250                core.sync_until_idle();
251                pump_events(&mut core, &emit);
252                continue;
253            }
254            match rx.recv_timeout(deadline.saturating_duration_since(now)) {
255                Ok(request) => request,
256                Err(RecvTimeoutError::Timeout) => {
257                    background_deadline = None;
258                    core.sync_until_idle();
259                    pump_events(&mut core, &emit);
260                    continue;
261                }
262                Err(RecvTimeoutError::Disconnected) => {
263                    core.shutdown();
264                    return;
265                }
266            }
267        } else {
268            match rx.recv() {
269                Ok(request) => request,
270                Err(std::sync::mpsc::RecvError) => {
271                    core.shutdown();
272                    return;
273                }
274            }
275        };
276
277        match request {
278            Request::Command { command, reply } => {
279                let command = inject_db_path(command, &config);
280                let result = core.command(&command);
281                let _ = reply.send(result);
282                pump_events(&mut core, &emit);
283            }
284            Request::Query { sql, params, reply } => {
285                let result = core.query(&sql, params);
286                let _ = reply.send(result);
287                pump_events(&mut core, &emit);
288            }
289            Request::SetHeaders { headers, reply } => {
290                core.set_headers(headers);
291                let _ = reply.send(json!({ "result": null }));
292            }
293            Request::TransportWake => {
294                core.poll_transport();
295                pump_events(&mut core, &emit);
296            }
297            #[cfg(test)]
298            Request::Block { duration, entered } => {
299                let _ = entered.send(());
300                std::thread::sleep(duration);
301            }
302            Request::Shutdown => {
303                core.shutdown();
304                return;
305            }
306        }
307    }
308}
309
310/// Inject the configured `db_path` into a `create` command's params if the JS
311/// side did not already supply one — so persistence is a plugin-config concern,
312/// not something every app must thread through the bridge.
313fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
314    if command.get("method").and_then(Value::as_str) != Some("create") {
315        return command;
316    }
317    let Some(db_path) = &config.db_path else {
318        return command;
319    };
320    let params = command.get_mut("params").and_then(Value::as_object_mut);
321    if let Some(params) = params {
322        params
323            .entry("dbPath")
324            .or_insert_with(|| Value::from(db_path.clone()));
325    } else if let Some(obj) = command.as_object_mut() {
326        obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
327    }
328    command
329}
330
331fn client_error(message: impl Into<String>) -> Value {
332    json!({ "error": { "code": "client.failed", "message": message.into() } })
333}
334
335fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
336    let object = value
337        .and_then(Value::as_object)
338        .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
339    let table = object
340        .get("table")
341        .and_then(Value::as_str)
342        .ok_or_else(|| "window base missing table".to_owned())?
343        .to_owned();
344    let variable = object
345        .get("variable")
346        .and_then(Value::as_str)
347        .ok_or_else(|| "window base missing variable".to_owned())?
348        .to_owned();
349    let fixed_scopes = match object.get("fixedScopes") {
350        Some(value) => syncular_client::values::json_to_scope_map(value)?,
351        None => Vec::new(),
352    };
353    let params = object
354        .get("params")
355        .and_then(Value::as_str)
356        .map(str::to_owned);
357    Ok(WindowBase {
358        table,
359        variable,
360        fixed_scopes,
361        params,
362    })
363}
364
365fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
366    let Some(value) = value else {
367        return Ok(Vec::new());
368    };
369    if value.is_null() {
370        return Ok(Vec::new());
371    }
372    let entries = value
373        .as_array()
374        .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
375    entries
376        .iter()
377        .map(|entry| {
378            let units = entry
379                .get("units")
380                .and_then(Value::as_array)
381                .map(|values| {
382                    values
383                        .iter()
384                        .filter_map(|value| value.as_str().map(str::to_owned))
385                        .collect()
386                })
387                .unwrap_or_default();
388            Ok(WindowCoverage {
389                base: parse_window_base(entry.get("base"))?,
390                units,
391            })
392        })
393        .collect()
394}
395
396fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
397    for event in core.drain_events() {
398        emit(&event.json);
399    }
400}
401
402// -- Tauri commands (the thin shell) -----------------------------------------
403
404#[tauri::command]
405async fn syncular_command<R: Runtime>(
406    app: tauri::AppHandle<R>,
407    command: Value,
408) -> Result<Value, String> {
409    let state = app.state::<SyncularState>();
410    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
411    state.send(Request::Command {
412        command,
413        reply: reply_tx,
414    })?;
415    reply_rx
416        .recv()
417        .map_err(|_| "the syncular core dropped the reply".to_owned())
418}
419
420/// Replace the native transport's request headers at runtime — the auth
421/// rotation path (RFC 0002 §2.3): a fresh JWT reaches the transport without
422/// re-registering the plugin. HTTP requests use the new set from the next
423/// call; the realtime socket applies it on its next (re)connect.
424#[tauri::command]
425async fn syncular_set_headers<R: Runtime>(
426    app: tauri::AppHandle<R>,
427    headers: std::collections::BTreeMap<String, String>,
428) -> Result<Value, String> {
429    let state = app.state::<SyncularState>();
430    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
431    state.send(Request::SetHeaders {
432        headers: headers.into_iter().collect(),
433        reply: reply_tx,
434    })?;
435    reply_rx
436        .recv()
437        .map_err(|_| "the syncular core dropped the reply".to_owned())
438}
439
440#[tauri::command]
441async fn syncular_query<R: Runtime>(
442    app: tauri::AppHandle<R>,
443    sql: String,
444    params: Option<Value>,
445) -> Result<Value, String> {
446    let state = app.state::<SyncularState>();
447    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
448    state.send(Request::Query {
449        sql,
450        params: params.unwrap_or(Value::Null),
451        reply: reply_tx,
452    })?;
453    reply_rx
454        .recv()
455        .map_err(|_| "the syncular core dropped the reply".to_owned())
456}
457
458/// Atomic rows + revision + window coverage on the independent read-only
459/// connection. In-memory configurations fall back to the core owner because
460/// SQLite cannot share an anonymous database across connections.
461#[tauri::command]
462async fn syncular_query_snapshot<R: Runtime>(
463    app: tauri::AppHandle<R>,
464    sql: String,
465    params: Option<Value>,
466    coverage: Option<Value>,
467) -> Result<Value, String> {
468    let state = app.state::<SyncularState>();
469    let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
470    let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
471
472    if state.reader.is_some() {
473        let bind = match params_value.as_array() {
474            Some(values) => values.clone(),
475            None => return Ok(client_error("querySnapshot params must be a list")),
476        };
477        let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
478            Ok(value) => value,
479            Err(message) => return Ok(client_error(message)),
480        };
481        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
482        state.send_read(ReadRequest::QuerySnapshot {
483            sql,
484            params: bind,
485            coverage: parsed_coverage,
486            reply: reply_tx,
487        })?;
488        return reply_rx
489            .recv()
490            .map_err(|_| "the syncular read thread dropped the reply".to_owned());
491    }
492
493    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
494    state.send(Request::Command {
495        command: json!({
496            "method": "querySnapshot",
497            "params": { "sql": sql, "params": params_value, "coverage": coverage_value }
498        }),
499        reply: reply_tx,
500    })?;
501    reply_rx
502        .recv()
503        .map_err(|_| "the syncular core dropped the reply".to_owned())
504}
505
506/// Initialize the plugin with a config. Register with
507/// `tauri::Builder::default().plugin(tauri_plugin_syncular::init(config))`.
508///
509/// The owning thread is spawned in `setup`; it builds the core (native
510/// transport if `base_url` + the `native-transport` feature), pumps events onto
511/// [`EVENT_NAME`], and runs the §8.4 host loop. The mailbox `Sender` is managed
512/// as plugin state and torn down on `RunEvent::Exit`.
513pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
514    Builder::<R>::new("syncular")
515        .invoke_handler(tauri::generate_handler![
516            syncular_command,
517            syncular_query,
518            syncular_query_snapshot,
519            syncular_set_headers
520        ])
521        .setup(move |app, _api| {
522            let (tx, rx) = std::sync::mpsc::channel::<Request>();
523            let reader = match config
524                .db_path
525                .as_ref()
526                .filter(|path| path.as_str() != ":memory:")
527            {
528                Some(path) => {
529                    let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
530                    let path = path.clone();
531                    std::thread::Builder::new()
532                        .name("syncular-read".to_owned())
533                        .spawn(move || run_reader_thread(path, reader_rx))
534                        .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
535                    Some(Mutex::new(reader_tx))
536                }
537                None => None,
538            };
539            app.manage(SyncularState {
540                sender: Mutex::new(tx.clone()),
541                reader,
542            });
543            let app_handle = app.clone();
544            let emit = move |value: &Value| {
545                // Best-effort: a webview that has gone away must not crash the
546                // owning thread. Emit to all windows on the syncular channel.
547                let _ = app_handle.emit(EVENT_NAME, value.clone());
548            };
549            std::thread::Builder::new()
550                .name("syncular-core".to_owned())
551                .spawn(move || run_owner_thread(config, tx, rx, emit))
552                .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
553            Ok(())
554        })
555        .on_event(|app, event| {
556            if let RunEvent::Exit = event {
557                if let Some(state) = app.try_state::<SyncularState>() {
558                    let _ = state.send(Request::Shutdown);
559                    if state.reader.is_some() {
560                        let _ = state.send_read(ReadRequest::Shutdown);
561                    }
562                }
563            }
564        })
565        .build()
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn config_to_transport_json_shapes_fields() {
574        let config = SyncularConfig {
575            base_url: Some("https://api.example.com".to_owned()),
576            headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
577            ..Default::default()
578        };
579        let json = config.to_transport_json();
580        assert_eq!(json["baseUrl"], "https://api.example.com");
581        assert_eq!(json["headers"]["authorization"], "Bearer x");
582    }
583
584    #[test]
585    fn inject_db_path_adds_to_create_only() {
586        let config = SyncularConfig {
587            db_path: Some("/tmp/app.db".to_owned()),
588            ..Default::default()
589        };
590        // create gains the path…
591        let created = inject_db_path(
592            json!({ "method": "create", "params": { "clientId": "c1" } }),
593            &config,
594        );
595        assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
596        // …a create with no params object gets one…
597        let created2 = inject_db_path(json!({ "method": "create" }), &config);
598        assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
599        // …an explicit dbPath is preserved…
600        let explicit = inject_db_path(
601            json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
602            &config,
603        );
604        assert_eq!(explicit["params"]["dbPath"], "/other.db");
605        // …and a non-create command is untouched.
606        let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
607        assert!(mutate["params"].get("dbPath").is_none());
608    }
609
610    #[test]
611    fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
612        let parsed = parse_coverage(Some(&json!([{
613            "base": {
614                "table": "tasks",
615                "variable": "project_id",
616                "fixedScopes": { "tenant_id": ["one", "two"] },
617                "params": "opaque"
618            },
619            "units": ["a", "b"]
620        }])))
621        .expect("parse coverage");
622        assert_eq!(parsed.len(), 1);
623        let entry = &parsed[0];
624        assert_eq!(entry.base.table, "tasks");
625        assert_eq!(entry.base.variable, "project_id");
626        assert_eq!(
627            entry.base.fixed_scopes,
628            vec![(
629                "tenant_id".to_owned(),
630                vec!["one".to_owned(), "two".to_owned()]
631            )]
632        );
633        assert_eq!(entry.base.params.as_deref(), Some("opaque"));
634        assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
635    }
636
637    /// The owner-thread mailbox loop end-to-end, without any Tauri window: post
638    /// commands, collect emitted events. This is the real host path — the Tauri
639    /// commands are a two-line channel forward over exactly this.
640    #[test]
641    fn owner_thread_round_trips_over_mailbox() {
642        use std::sync::mpsc::channel;
643        use std::sync::{Arc, Mutex as StdMutex};
644
645        let (tx, rx) = channel::<Request>();
646        let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
647        let events_for_thread = Arc::clone(&events);
648        let config = SyncularConfig {
649            auto_sync: false,
650            ..Default::default()
651        };
652        let owner_tx = tx.clone();
653        let handle = std::thread::spawn(move || {
654            run_owner_thread(config, owner_tx, rx, move |v| {
655                events_for_thread.lock().unwrap().push(v.clone());
656            });
657        });
658
659        let call = |command: Value| -> Value {
660            let (rtx, rrx) = channel();
661            tx.send(Request::Command {
662                command,
663                reply: rtx,
664            })
665            .unwrap();
666            rrx.recv().unwrap()
667        };
668
669        let schema = json!({
670            "version": 1,
671            "tables": [{
672                "name": "todo", "primaryKey": "id",
673                "columns": [
674                    { "name": "id", "type": "string", "nullable": false },
675                    { "name": "title", "type": "string", "nullable": false }
676                ],
677                "scopes": []
678            }]
679        });
680        assert_eq!(
681            call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
682                ["result"],
683            json!({})
684        );
685        call(json!({ "method": "mutate", "params": { "mutations": [{
686            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
687        }] } }));
688
689        // A query over the mailbox.
690        let (qtx, qrx) = channel();
691        tx.send(Request::Query {
692            sql: "SELECT title FROM todo".to_owned(),
693            params: Value::Null,
694            reply: qtx,
695        })
696        .unwrap();
697        let rows = qrx.recv().unwrap();
698        assert_eq!(rows["result"]["rows"][0]["title"], "hi");
699
700        // RFC 0002 §2.3: header rotation rides the same mailbox; a
701        // client-local (Null-transport) core accepts and ignores the set.
702        let (htx, hrx) = channel();
703        tx.send(Request::SetHeaders {
704            headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
705            reply: htx,
706        })
707        .unwrap();
708        assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
709
710        tx.send(Request::Shutdown).unwrap();
711        handle.join().unwrap();
712
713        let seen = events.lock().unwrap();
714        let kinds: Vec<String> = seen
715            .iter()
716            .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
717            .collect();
718        // The local mutate emits the exact revisioned batch onto the channel.
719        assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
720    }
721
722    #[test]
723    fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
724        use std::sync::mpsc::channel;
725
726        let path =
727            std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
728        let config = SyncularConfig {
729            db_path: Some(path.to_string_lossy().into_owned()),
730            auto_sync: false,
731            ..Default::default()
732        };
733        let (tx, rx) = channel::<Request>();
734        let owner_tx = tx.clone();
735        let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
736
737        let (create_tx, create_rx) = channel();
738        tx.send(Request::Command {
739            command: json!({
740                "method": "create",
741                "params": {
742                    "clientId": "sidecar-client",
743                    "schema": { "version": 1, "tables": [] },
744                    "dbPath": path.to_string_lossy()
745                }
746            }),
747            reply: create_tx,
748        })
749        .expect("post create");
750        assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
751
752        let (read_tx, read_rx) = channel::<ReadRequest>();
753        let read_path = path.to_string_lossy().into_owned();
754        let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx));
755
756        // Model a slow HTTP/WS round on the mutable owner. The dedicated read
757        // mailbox must still return the durable local snapshot immediately.
758        let (entered_tx, entered_rx) = channel();
759        tx.send(Request::Block {
760            duration: Duration::from_millis(200),
761            entered: entered_tx,
762        })
763        .expect("block owner");
764        entered_rx.recv().expect("owner entered blocking round");
765        let (snapshot_tx, snapshot_rx) = channel();
766        read_tx
767            .send(ReadRequest::QuerySnapshot {
768                sql: "SELECT 1 AS value".to_owned(),
769                params: Vec::new(),
770                coverage: Vec::new(),
771                reply: snapshot_tx,
772            })
773            .expect("post snapshot");
774        let snapshot = snapshot_rx
775            .recv_timeout(Duration::from_millis(50))
776            .expect("local snapshot must not wait for the owner");
777        assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
778
779        read_tx.send(ReadRequest::Shutdown).expect("stop reader");
780        reader.join().expect("join reader");
781        tx.send(Request::Shutdown).expect("stop owner");
782        owner.join().expect("join owner");
783        std::fs::remove_file(path).expect("remove temp database");
784    }
785}