Skip to main content

tauri_plugin_syncular/
lib.rs

1//! # tauri-plugin-syncular — a native syncular instance inside the Tauri process
2//!
3//! A NATIVE syncular client (the Rust `syncular-client` core, consumed
4//! DIRECTLY — no FFI) runs in the Tauri host process and is exposed to the
5//! webview as Tauri commands + events. The JS bridge (`@syncular/tauri`)
6//! implements the same `SyncClientLike` interface the React package
7//! normalizes, so the hooks work unchanged — the fourth host of one interface
8//! after direct / worker-leader / follower.
9//!
10//! The webview does not run JS syncular. Webview OPFS is eviction-prone and inconsistent across
11//! WKWebView/webkitgtk; the Rust core gives a real file DB and native perf.
12//!
13//! ## The surface (mirrors the FFI / conformance shim)
14//!
15//! - `syncular_command(command_json)` — the WHOLE command surface in one
16//!   command (`{"method","params"}`), dispatched through the shared
17//!   `syncular-command` router (the plugin is its THIRD consumer, so the
18//!   surface stays conformance-locked).
19//! - `syncular_query(sql, params)` — the React live-query fast path (arbitrary
20//!   read-only SQL); routed through the same `query` command.
21//! - `syncular_query_snapshot(sql, params, coverage)` — atomic reactive reads
22//!   on an independent read-only SQLite connection for file-backed clients.
23//! - `syncular://event` — exact revisioned `change` batches plus ephemeral
24//!   `presence`; command/realtime sync intents stay inside the event-driven
25//!   owner loop.
26//!
27//! ## Thread-safety, honestly
28//!
29//! [`core::SyncularCore`] owns a rusqlite connection and is NOT `Sync`. One
30//! owning thread holds the mutable client; every command arrives over a mailbox
31//! (mpsc). The background host loop (§8.4 wake-driven `syncUntilIdle` with
32//! deadlines) runs on that thread. File-backed clients add one read owner with
33//! an independent read-only SQLite connection for snapshots, so network work
34//! cannot block local views while the mutable client remains single-owned.
35
36use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
37use std::sync::{Condvar, Mutex};
38use std::time::{Duration, Instant};
39
40use serde_json::{json, Value};
41use tauri::plugin::{Builder, TauriPlugin};
42use tauri::{Emitter, Manager, RunEvent, Runtime};
43
44pub mod core;
45pub mod transport;
46
47use core::SyncularCore;
48use syncular_client::{FileQuerySnapshotReader, QueryReadFailure, WindowBase, WindowCoverage};
49
50/// The Tauri event name carrying derived client-observable events.
51pub const EVENT_NAME: &str = "syncular://event";
52
53/// Plugin configuration. Passed to [`init`]; every field is optional except a
54/// caller almost always wants a `base_url` (for real network sync) and a
55/// `database_dir` or `db_path` (for persistence — defaults to an in-memory core
56/// if both are 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 a `create` opens when it names no database. Absent →
67    /// in-memory (nothing survives a restart). Apps usually set this to a file
68    /// under the app-data dir; see [`init`].
69    pub db_path: Option<String>,
70    /// Directory of the named databases. A `create` that carries `database`
71    /// (for example one replica per signed-in actor) opens
72    /// `<database_dir>/<database>.db`; the plugin creates the directory. The
73    /// webview names a database and never supplies a path.
74    pub database_dir: Option<String>,
75    /// Run the background host loop (§8.4). Default true.
76    pub auto_sync: bool,
77    /// Native app-approved table/column ceiling. Webview create selects scopes
78    /// and a subset; raw IPC cannot expand this policy.
79    pub authority_columns: std::collections::BTreeMap<String, Vec<String>>,
80}
81
82impl Default for SyncularConfig {
83    fn default() -> Self {
84        Self {
85            base_url: None,
86            ws_url: None,
87            headers: Vec::new(),
88            db_path: None,
89            database_dir: None,
90            auto_sync: true,
91            authority_columns: std::collections::BTreeMap::new(),
92        }
93    }
94}
95
96impl SyncularConfig {
97    /// Build the JSON config the core's transport reads.
98    fn to_transport_json(&self) -> Value {
99        let mut map = serde_json::Map::new();
100        if let Some(base) = &self.base_url {
101            map.insert("baseUrl".to_owned(), Value::from(base.clone()));
102        }
103        if let Some(ws) = &self.ws_url {
104            map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
105        }
106        if !self.headers.is_empty() {
107            let headers: serde_json::Map<String, Value> = self
108                .headers
109                .iter()
110                .map(|(k, v)| (k.clone(), Value::from(v.clone())))
111                .collect();
112            map.insert("headers".to_owned(), Value::Object(headers));
113        }
114        Value::Object(map)
115    }
116}
117
118/// A request posted to the owning thread's mailbox. Each carries a one-shot
119/// reply channel; the Tauri command blocks on it (`spawn_blocking`-friendly).
120enum Request {
121    Command {
122        command: Value,
123        reply: Sender<Value>,
124    },
125    Query {
126        sql: String,
127        params: Value,
128        reply: Sender<Value>,
129    },
130    /// Replace the transport's request headers. Header state
131    /// lives on the core-owned transport, so mutation rides the same mailbox
132    /// as every other access — the one-owning-thread invariant holds.
133    SetHeaders {
134        headers: Vec<(String, String)>,
135        reply: Sender<Value>,
136    },
137    /// Native realtime reader wake; contains no data (the transport buffer does).
138    TransportWake,
139    RoundFinished(
140        Box<(
141            syncular_client::CompletedSyncRound,
142            crate::transport::HostTransport,
143        )>,
144    ),
145    /// §7.6: an owned read-sidecar failure, or the first success after one.
146    QueryRead {
147        id: String,
148        tables: Vec<String>,
149        failure: Option<QueryReadFailure>,
150    },
151    #[cfg(test)]
152    Block {
153        duration: Duration,
154        entered: Sender<()>,
155    },
156    Shutdown,
157}
158
159/// Latency-critical reads use a second, read-only SQLite connection. This
160/// mailbox is deliberately independent from [`Request`]: a network round on
161/// the mutable owner must never head-of-line-block a local UI snapshot.
162enum ReadRequest {
163    QuerySnapshot {
164        sql: String,
165        params: Vec<Value>,
166        coverage: Vec<WindowCoverage>,
167        owner: Option<(String, Vec<String>)>,
168        reply: Sender<Value>,
169    },
170    Shutdown,
171}
172
173/// The plugin's managed state: the mailbox sender the commands post to. Wrapped
174/// in a `Mutex` only to be `Sync` for Tauri state (the `Sender` is `Send`).
175struct SyncularState {
176    sender: Mutex<Sender<Request>>,
177    /// The snapshot reader of the file database the last successful `create`
178    /// opened; `None` before it, after `shutdown`, and for an in-memory client.
179    /// `create` and `shutdown` hold this lock until their reply, so the reader
180    /// always reads the database the owner has open.
181    reader: Mutex<Option<Sender<ReadRequest>>>,
182    config: SyncularConfig,
183    security_gate: SecurityGate,
184}
185
186struct SecurityGateState {
187    preflight: bool,
188    active_reads: usize,
189}
190
191struct SecurityGate {
192    state: Mutex<SecurityGateState>,
193    idle: Condvar,
194}
195
196struct SecurityReadGuard<'a> {
197    gate: &'a SecurityGate,
198}
199
200impl SecurityGate {
201    fn new_preflight() -> Self {
202        Self {
203            state: Mutex::new(SecurityGateState {
204                preflight: true,
205                active_reads: 0,
206            }),
207            idle: Condvar::new(),
208        }
209    }
210
211    fn begin_preflight(&self) -> Result<(), String> {
212        let mut state = self
213            .state
214            .lock()
215            .map_err(|_| "syncular security gate poisoned".to_owned())?;
216        state.preflight = true;
217        while state.active_reads > 0 {
218            state = self
219                .idle
220                .wait(state)
221                .map_err(|_| "syncular security gate poisoned".to_owned())?;
222        }
223        Ok(())
224    }
225
226    fn activate(&self) -> Result<(), String> {
227        let mut state = self
228            .state
229            .lock()
230            .map_err(|_| "syncular security gate poisoned".to_owned())?;
231        state.preflight = false;
232        Ok(())
233    }
234
235    fn enter_read(&self) -> Result<SecurityReadGuard<'_>, Value> {
236        let mut state = self
237            .state
238            .lock()
239            .map_err(|_| client_error("syncular security gate poisoned"))?;
240        if state.preflight {
241            return Err(security_preflight_error());
242        }
243        state.active_reads += 1;
244        Ok(SecurityReadGuard { gate: self })
245    }
246}
247
248impl Drop for SecurityReadGuard<'_> {
249    fn drop(&mut self) {
250        let Ok(mut state) = self.gate.state.lock() else {
251            return;
252        };
253        state.active_reads = state.active_reads.saturating_sub(1);
254        if state.active_reads == 0 {
255            self.gate.idle.notify_all();
256        }
257    }
258}
259
260impl SyncularState {
261    fn send(&self, request: Request) -> Result<(), String> {
262        self.sender
263            .lock()
264            .map_err(|_| "syncular mailbox poisoned".to_owned())?
265            .send(request)
266            .map_err(|_| "the syncular core thread has stopped".to_owned())
267    }
268
269    fn reader(&self) -> Result<Option<Sender<ReadRequest>>, String> {
270        Ok(self
271            .reader
272            .lock()
273            .map_err(|_| "syncular read mailbox poisoned".to_owned())?
274            .clone())
275    }
276}
277
278fn spawn_reader(path: String, owner_tx: Sender<Request>) -> Result<Sender<ReadRequest>, String> {
279    let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
280    std::thread::Builder::new()
281        .name("syncular-read".to_owned())
282        .spawn(move || run_reader_thread(path, reader_rx, owner_tx))
283        .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
284    Ok(reader_tx)
285}
286
287fn run_reader_thread(path: String, rx: Receiver<ReadRequest>, owner_tx: Sender<Request>) {
288    let mut reader = FileQuerySnapshotReader::new(path);
289    // §7.6: owner ids whose last sidecar read failed. Only a failure or the
290    // first success after one reaches the owning core's mailbox.
291    let mut failing = std::collections::HashSet::<String>::new();
292    while let Ok(request) = rx.recv() {
293        match request {
294            ReadRequest::QuerySnapshot {
295                sql,
296                params,
297                coverage,
298                owner,
299                reply,
300            } => {
301                let result = reader.query_snapshot(&sql, &params, &coverage);
302                let value = match &result {
303                    Ok(snapshot) => json!({ "result": snapshot }),
304                    Err(failure) => json!({
305                        "error": {
306                            "code": failure.code.unwrap_or("client.failed"),
307                            "message": failure.message,
308                            "details": failure.details(),
309                        }
310                    }),
311                };
312                if let Some((id, tables)) = owner {
313                    let failure = result.err();
314                    let report = if failure.is_some() {
315                        failing.insert(id.clone());
316                        true
317                    } else {
318                        failing.remove(&id)
319                    };
320                    if report {
321                        let _ = owner_tx.send(Request::QueryRead {
322                            id,
323                            tables,
324                            failure,
325                        });
326                    }
327                }
328                let _ = reply.send(value);
329            }
330            ReadRequest::Shutdown => return,
331        }
332    }
333}
334
335/// The owning thread: builds the core, then loops over the mailbox and the
336/// background host policy. `emit` pushes drained events onto the Tauri channel.
337/// One serialized I/O executor per owner. It never owns a SQLite connection.
338enum NetworkWork {
339    Round(
340        Box<syncular_client::PreparedSyncRound>,
341        crate::transport::HostTransport,
342    ),
343    Controls(Vec<String>, crate::transport::HostTransport),
344}
345
346struct RoundRun {
347    reply: Option<Sender<Value>>,
348    until_idle: bool,
349    max_rounds: Option<u32>,
350    spent: u32,
351    aggregate: syncular_client::SyncReport,
352}
353
354fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
355where
356    F: Fn(&Value) + Send + Sync + 'static,
357{
358    let emit = std::sync::Arc::new(emit);
359    let wake_tx = tx.clone();
360    let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
361        let _ = wake_tx.send(Request::TransportWake);
362    });
363    let mut core = match SyncularCore::new_with_notify(&config.to_transport_json(), Some(notify)) {
364        Ok(core) => core,
365        Err(message) => {
366            emit(&json!({ "type": "error", "message": message }));
367            return;
368        }
369    };
370    let progress_emit = emit.clone();
371    core.progress_listener = Some(std::sync::Arc::new(move |progress| {
372        progress_emit(&json!({ "type": "progress", "progress": progress }));
373    }));
374    let (network_tx, network_rx) = std::sync::mpsc::channel();
375    let completed_tx = tx.clone();
376    let network = std::thread::spawn(move || {
377        use syncular_client::Transport;
378        while let Ok(work) = network_rx.recv() {
379            match work {
380                NetworkWork::Round(prepared, mut transport) => {
381                    let completed = (*prepared).exchange(&mut transport);
382                    if completed_tx
383                        .send(Request::RoundFinished(Box::new((completed, transport))))
384                        .is_err()
385                    {
386                        return;
387                    }
388                }
389                NetworkWork::Controls(controls, mut transport) => {
390                    for text in controls {
391                        let _ = transport.realtime_send(&text);
392                    }
393                }
394            }
395        }
396    });
397    let mut active: Option<RoundRun> = None;
398    let mut queued = std::collections::VecDeque::new();
399    let mut background_deadline: Option<Instant> = None;
400    loop {
401        if !core.transport_enabled() {
402            background_deadline = None;
403        }
404        if let Some((controls, transport)) = core.take_controls() {
405            if network_tx
406                .send(NetworkWork::Controls(controls, transport))
407                .is_err()
408            {
409                break;
410            }
411        }
412        // Consume sync intent only while idle. Mutations arriving mid-round
413        // retain their coalesced intent and enter the next captured request.
414        if active.is_none() {
415            if let Some(run) = queued.pop_front() {
416                active = Some(run);
417            } else if config.auto_sync {
418                match core.take_sync_intent() {
419                    syncular_client::SyncIntent::Interactive => {
420                        background_deadline = None;
421                        active = Some(RoundRun {
422                            reply: None,
423                            until_idle: true,
424                            max_rounds: None,
425                            spent: 0,
426                            aggregate: Default::default(),
427                        });
428                    }
429                    syncular_client::SyncIntent::Background { delay_ms } => {
430                        let candidate = Instant::now()
431                            .checked_add(Duration::from_millis(delay_ms))
432                            .unwrap_or_else(Instant::now);
433                        background_deadline = Some(
434                            background_deadline.map_or(candidate, |current| current.min(candidate)),
435                        );
436                    }
437                    syncular_client::SyncIntent::None => {}
438                }
439            }
440            if active.is_some() {
441                match core.prepare_round() {
442                    Ok((prepared, transport)) => {
443                        active.as_mut().expect("active run").spent += 1;
444                        if network_tx
445                            .send(NetworkWork::Round(Box::new(prepared), transport))
446                            .is_err()
447                        {
448                            break;
449                        }
450                    }
451                    Err(outcome) => {
452                        if let Some(reply) = active.take().and_then(|run| run.reply) {
453                            let _ = reply.send(json!({ "result": outcome.to_json() }));
454                        }
455                    }
456                }
457                pump_events(&mut core, &*emit);
458                if active.is_none() {
459                    continue;
460                }
461            }
462        }
463        let request = if active.is_none() {
464            if let Some(deadline) = background_deadline {
465                match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
466                    Ok(request) => request,
467                    Err(RecvTimeoutError::Timeout) => {
468                        background_deadline = None;
469                        queued.push_back(RoundRun {
470                            reply: None,
471                            until_idle: true,
472                            max_rounds: None,
473                            spent: 0,
474                            aggregate: Default::default(),
475                        });
476                        continue;
477                    }
478                    Err(RecvTimeoutError::Disconnected) => break,
479                }
480            } else {
481                match rx.recv() {
482                    Ok(request) => request,
483                    Err(_) => break,
484                }
485            }
486        } else {
487            match rx.recv() {
488                Ok(request) => request,
489                Err(_) => break,
490            }
491        };
492        match request {
493            Request::RoundFinished(completed) => {
494                let (completed, transport) = *completed;
495                let applied = core.apply_round(completed);
496                let (mut outcome, more, bootstrap_advanced) = match applied {
497                    syncular_client::AppliedSyncRound::Continue(prepared) => {
498                        if network_tx
499                            .send(NetworkWork::Round(Box::new(prepared), transport))
500                            .is_err()
501                        {
502                            break;
503                        }
504                        pump_events(&mut core, &*emit);
505                        continue;
506                    }
507                    syncular_client::AppliedSyncRound::Complete {
508                        outcome,
509                        more,
510                        bootstrap_advanced,
511                        ..
512                    } => (outcome, more, bootstrap_advanced),
513                };
514                let Some(mut run) = active.take() else {
515                    continue;
516                };
517                let mut keep_running = false;
518                if run.until_idle {
519                    if let syncular_client::SyncOutcome::Ok(report) = &outcome {
520                        run.aggregate.merge(report);
521                        if bootstrap_advanced && run.max_rounds.is_none() {
522                            run.spent = 0;
523                        }
524                        keep_running = more && core.transport_enabled();
525                        if keep_running && run.spent >= run.max_rounds.unwrap_or(20).max(1) {
526                            keep_running = false;
527                            outcome = syncular_client::SyncOutcome::Failed {
528                                error_code: "sync.invalid_request".into(),
529                                message: "sync did not reach idle within the round budget".into(),
530                                details: None,
531                            };
532                        } else if !keep_running {
533                            outcome = syncular_client::SyncOutcome::Ok(run.aggregate.clone());
534                        }
535                    }
536                }
537                if keep_running {
538                    queued.push_front(run);
539                } else if let Some(reply) = run.reply {
540                    let _ = reply.send(json!({ "result": outcome.to_json() }));
541                }
542                pump_events(&mut core, &*emit);
543            }
544            Request::Command { command, reply } => {
545                let method = command.get("method").and_then(Value::as_str);
546                if matches!(method, Some("sync" | "syncUntilIdle")) && core.transport_enabled() {
547                    queued.push_back(RoundRun {
548                        reply: Some(reply),
549                        until_idle: method == Some("syncUntilIdle"),
550                        max_rounds: command
551                            .pointer("/params/maxRounds")
552                            .and_then(Value::as_u64)
553                            .map(|n| n as u32),
554                        spent: 0,
555                        aggregate: Default::default(),
556                    });
557                    continue;
558                }
559                let result = core.command(&command);
560                let _ = reply.send(result);
561                pump_events(&mut core, &*emit);
562            }
563            Request::Query { sql, params, reply } => {
564                let _ = reply.send(core.query(&sql, params));
565                pump_events(&mut core, &*emit);
566            }
567            Request::SetHeaders { headers, reply } => {
568                core.set_headers(headers);
569                let _ = reply.send(json!({ "result": null }));
570            }
571            Request::TransportWake => {
572                core.poll_transport();
573                pump_events(&mut core, &*emit);
574            }
575            Request::QueryRead {
576                id,
577                tables,
578                failure,
579            } => {
580                core.record_query_read(&id, &tables, failure.as_ref());
581                pump_events(&mut core, &*emit);
582            }
583            #[cfg(test)]
584            Request::Block { duration, entered } => {
585                let _ = entered.send(());
586                std::thread::sleep(duration);
587            }
588            Request::Shutdown => break,
589        }
590    }
591    core.shutdown();
592    drop(network_tx);
593    // Transport shutdown releases a pending socket round. An HTTP request
594    // finishes on its own bounded transport timeout, outside the owner.
595    let _ = network.join();
596    for run in active.into_iter().chain(queued) {
597        if let Some(reply) = run.reply {
598            let _ = reply.send(json!({ "error": { "code": "client.closed", "message": "client owner has closed" } }));
599        }
600    }
601}
602
603/// The database file a `create` opens: `<database_dir>/<database>.db` for a
604/// named database, otherwise the configured `db_path` (`None` → in-memory; a
605/// configuration with only `database_dir` requires the name). A
606/// database name is one file name of ASCII letters, digits, `-`, `_` and `.`,
607/// starting with a letter or digit, so it cannot leave the directory. The
608/// webview never supplies a path itself.
609fn resolve_database(config: &SyncularConfig, params: &Value) -> Result<Option<String>, Value> {
610    if params.get("dbPath").is_some() {
611        return Err(invalid_request(
612            "sync.invalid_request: create.dbPath is host configuration; name a database with create.database",
613        ));
614    }
615    let Some(database) = params.get("database") else {
616        if config.db_path.is_none() && config.database_dir.is_some() {
617            return Err(invalid_request(
618                "sync.invalid_request: create.database is required when SyncularConfig sets database_dir without db_path",
619            ));
620        }
621        return Ok(config.db_path.clone());
622    };
623    let Some(name) = database.as_str().filter(|name| {
624        (1..=128).contains(&name.len())
625            && name.starts_with(|c: char| c.is_ascii_alphanumeric())
626            && !name.contains("..")
627            && name
628                .chars()
629                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
630    }) else {
631        return Err(invalid_request(
632            "sync.invalid_request: create.database must be 1 to 128 ASCII letters, digits, '-', '_' or '.', start with a letter or digit, and contain no '..'",
633        ));
634    };
635    let Some(dir) = &config.database_dir else {
636        return Err(invalid_request(
637            "sync.invalid_request: create.database needs SyncularConfig.database_dir",
638        ));
639    };
640    std::fs::create_dir_all(dir)
641        .map_err(|error| client_error(format!("cannot create the database directory: {error}")))?;
642    Ok(Some(
643        std::path::Path::new(dir)
644            .join(format!("{name}.db"))
645            .to_string_lossy()
646            .into_owned(),
647    ))
648}
649
650fn invalid_request(message: &str) -> Value {
651    json!({ "error": { "code": "sync.invalid_request", "message": message } })
652}
653
654fn client_error(message: impl Into<String>) -> Value {
655    json!({ "error": { "code": "client.failed", "message": message.into() } })
656}
657
658fn security_preflight_error() -> Value {
659    json!({
660        "error": {
661            "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
662            "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
663        }
664    })
665}
666
667fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
668    let object = value
669        .and_then(Value::as_object)
670        .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
671    let table = object
672        .get("table")
673        .and_then(Value::as_str)
674        .ok_or_else(|| "window base missing table".to_owned())?
675        .to_owned();
676    let variable = object
677        .get("variable")
678        .and_then(Value::as_str)
679        .ok_or_else(|| "window base missing variable".to_owned())?
680        .to_owned();
681    let fixed_scopes = match object.get("fixedScopes") {
682        Some(value) => syncular_client::values::json_to_scope_map(value)?,
683        None => Vec::new(),
684    };
685    let params = object
686        .get("params")
687        .and_then(Value::as_str)
688        .map(str::to_owned);
689    Ok(WindowBase {
690        table,
691        variable,
692        fixed_scopes,
693        params,
694    })
695}
696
697fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
698    let Some(value) = value else {
699        return Ok(Vec::new());
700    };
701    if value.is_null() {
702        return Ok(Vec::new());
703    }
704    let entries = value
705        .as_array()
706        .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
707    entries
708        .iter()
709        .map(|entry| {
710            let units = entry
711                .get("units")
712                .and_then(Value::as_array)
713                .map(|values| {
714                    values
715                        .iter()
716                        .filter_map(|value| value.as_str().map(str::to_owned))
717                        .collect()
718                })
719                .unwrap_or_default();
720            Ok(WindowCoverage {
721                base: parse_window_base(entry.get("base"))?,
722                units,
723            })
724        })
725        .collect()
726}
727
728fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
729    for event in core.drain_events() {
730        emit(&event.json);
731    }
732}
733
734// -- Tauri commands (the thin shell) -----------------------------------------
735
736#[tauri::command]
737async fn syncular_command<R: Runtime>(
738    app: tauri::AppHandle<R>,
739    command: Value,
740) -> Result<Value, String> {
741    let state = app.state::<SyncularState>();
742    let method = command
743        .get("method")
744        .and_then(Value::as_str)
745        .unwrap_or("")
746        .to_owned();
747    if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
748        // Gate fast reads and wait for already-started sidecar snapshots before
749        // the owner-thread barrier is enqueued.
750        state.security_gate.begin_preflight()?;
751    }
752    // Taken after the gate drained, so no admitted read still waits for it.
753    let mut reader = if method == "create" || method == "shutdown" {
754        Some(
755            state
756                .reader
757                .lock()
758                .map_err(|_| "syncular read mailbox poisoned".to_owned())?,
759        )
760    } else {
761        None
762    };
763    let mut command = command;
764    let mut database = None;
765    if method == "create" {
766        if let Some(reads) = command.pointer("/params/authorityReads") {
767            let declarations: Vec<syncular_client::AuthorityReadDeclaration> =
768                match serde_json::from_value(reads.clone()) {
769                    Ok(value) => value,
770                    Err(_) => {
771                        return Ok(
772                            json!({"error": {"code": "client.authority_read_forbidden", "message": "invalid authority declaration"}}),
773                        )
774                    }
775                };
776            if !declarations.iter().all(|read| {
777                state
778                    .config
779                    .authority_columns
780                    .get(&read.table)
781                    .is_some_and(|columns| read.columns.iter().all(|c| columns.contains(c)))
782            }) {
783                return Ok(
784                    json!({"error": {"code": "client.authority_read_forbidden", "message": "authority declaration exceeds the native app policy"}}),
785                );
786            }
787        }
788        database =
789            match resolve_database(&state.config, command.get("params").unwrap_or(&Value::Null)) {
790                Ok(path) => path,
791                Err(reply) => return Ok(reply),
792            };
793        if let Some(path) = &database {
794            match command.get_mut("params").and_then(Value::as_object_mut) {
795                Some(params) => {
796                    params.insert("dbPath".to_owned(), Value::from(path.clone()));
797                }
798                None => {
799                    if let Some(object) = command.as_object_mut() {
800                        object.insert("params".to_owned(), json!({ "dbPath": path }));
801                    }
802                }
803            }
804        }
805    }
806    let create_preflight = method == "create"
807        && command
808            .pointer("/params/securityPreflight")
809            .and_then(Value::as_bool)
810            .unwrap_or(false);
811    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
812    state.send(Request::Command {
813        command,
814        reply: reply_tx,
815    })?;
816    let reply = reply_rx
817        .recv()
818        .map_err(|_| "the syncular core dropped the reply".to_owned())?;
819    let succeeded = reply.get("error").is_none();
820    if let (true, Some(slot)) = (succeeded, reader.as_mut()) {
821        let next = match database.filter(|path| path.as_str() != ":memory:") {
822            Some(path) => Some(spawn_reader(
823                path,
824                state
825                    .sender
826                    .lock()
827                    .map_err(|_| "syncular mailbox poisoned".to_owned())?
828                    .clone(),
829            )?),
830            None => None,
831        };
832        if let Some(previous) = std::mem::replace(&mut **slot, next) {
833            let _ = previous.send(ReadRequest::Shutdown);
834        }
835    }
836    drop(reader);
837    if succeeded && method == "create" {
838        if !create_preflight {
839            state.security_gate.activate()?;
840        }
841    } else if succeeded && method == "activateSecurity" {
842        state.security_gate.activate()?;
843    }
844    Ok(reply)
845}
846
847/// Replace the native transport's request headers at runtime — the auth
848/// Header rotation path: a fresh JWT reaches the transport without
849/// re-registering the plugin. HTTP requests use the new set from the next
850/// call; the realtime socket applies it on its next (re)connect.
851#[tauri::command]
852async fn syncular_set_headers<R: Runtime>(
853    app: tauri::AppHandle<R>,
854    headers: std::collections::BTreeMap<String, String>,
855) -> Result<Value, String> {
856    let state = app.state::<SyncularState>();
857    // Runtime bearer replacement is an active-session operation. Hold the
858    // same gate as fast reads so beginSecurityPreflight both rejects new
859    // replacements and waits for an already-started mailbox update.
860    let _active_guard = match state.security_gate.enter_read() {
861        Ok(guard) => guard,
862        Err(reply) => return Ok(reply),
863    };
864    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
865    state.send(Request::SetHeaders {
866        headers: headers.into_iter().collect(),
867        reply: reply_tx,
868    })?;
869    reply_rx
870        .recv()
871        .map_err(|_| "the syncular core dropped the reply".to_owned())
872}
873
874#[tauri::command]
875async fn syncular_query<R: Runtime>(
876    app: tauri::AppHandle<R>,
877    sql: String,
878    params: Option<Value>,
879) -> Result<Value, String> {
880    let state = app.state::<SyncularState>();
881    let _read_guard = match state.security_gate.enter_read() {
882        Ok(guard) => guard,
883        Err(reply) => return Ok(reply),
884    };
885    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
886    state.send(Request::Query {
887        sql,
888        params: params.unwrap_or(Value::Null),
889        reply: reply_tx,
890    })?;
891    reply_rx
892        .recv()
893        .map_err(|_| "the syncular core dropped the reply".to_owned())
894}
895
896/// Atomic rows + revision + window coverage on the independent read-only
897/// connection. In-memory configurations fall back to the core owner because
898/// SQLite cannot share an anonymous database across connections.
899#[tauri::command]
900async fn syncular_query_snapshot<R: Runtime>(
901    app: tauri::AppHandle<R>,
902    sql: String,
903    params: Option<Value>,
904    coverage: Option<Value>,
905    owner: Option<Value>,
906) -> Result<Value, String> {
907    let state = app.state::<SyncularState>();
908    let _read_guard = match state.security_gate.enter_read() {
909        Ok(guard) => guard,
910        Err(reply) => return Ok(reply),
911    };
912    let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
913    let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
914
915    if let Some(reader) = state.reader()? {
916        let bind = match params_value.as_array() {
917            Some(values) => values.clone(),
918            None => return Ok(client_error("querySnapshot params must be a list")),
919        };
920        let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
921            Ok(value) => value,
922            Err(message) => return Ok(client_error(message)),
923        };
924        // §7.5 owner: the same `{id, tables}` contract the command router parses.
925        let parsed_owner = match &owner {
926            None | Some(Value::Null) => None,
927            Some(value) => {
928                let id = value
929                    .get("id")
930                    .and_then(Value::as_str)
931                    .filter(|id| !id.is_empty());
932                let tables = value
933                    .get("tables")
934                    .and_then(Value::as_array)
935                    .and_then(|list| {
936                        list.iter()
937                            .map(|table| table.as_str().map(str::to_owned))
938                            .collect::<Option<Vec<String>>>()
939                    });
940                match (id, tables) {
941                    (Some(id), Some(tables)) => Some((id.to_owned(), tables)),
942                    _ => {
943                        return Ok(json!({ "error": {
944                            "code": "sync.invalid_request",
945                            "message": "sync.invalid_request: querySnapshot owner must be {id: non-empty string, tables: string[]}",
946                        } }))
947                    }
948                }
949            }
950        };
951        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
952        reader
953            .send(ReadRequest::QuerySnapshot {
954                sql,
955                params: bind,
956                coverage: parsed_coverage,
957                owner: parsed_owner,
958                reply: reply_tx,
959            })
960            .map_err(|_| "the syncular read thread has stopped".to_owned())?;
961        return reply_rx
962            .recv()
963            .map_err(|_| "the syncular read thread dropped the reply".to_owned());
964    }
965
966    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
967    state.send(Request::Command {
968        command: json!({
969            "method": "querySnapshot",
970            "params": {
971                "sql": sql,
972                "params": params_value,
973                "coverage": coverage_value,
974                "owner": owner.unwrap_or(Value::Null),
975            }
976        }),
977        reply: reply_tx,
978    })?;
979    reply_rx
980        .recv()
981        .map_err(|_| "the syncular core dropped the reply".to_owned())
982}
983
984/// Initialize the plugin with a config. Register with
985/// `tauri::Builder::default().plugin(tauri_plugin_syncular::init(config))`.
986///
987/// The owning thread is spawned in `setup`; it builds the core (native
988/// transport if `base_url` + the `native-transport` feature), pumps events onto
989/// [`EVENT_NAME`], and runs the §8.4 host loop. The mailbox `Sender` is managed
990/// as plugin state and torn down on `RunEvent::Exit`.
991pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
992    Builder::<R>::new("syncular")
993        .invoke_handler(tauri::generate_handler![
994            syncular_command,
995            syncular_query,
996            syncular_query_snapshot,
997            syncular_set_headers
998        ])
999        .setup(move |app, _api| {
1000            let (tx, rx) = std::sync::mpsc::channel::<Request>();
1001            app.manage(SyncularState {
1002                sender: Mutex::new(tx.clone()),
1003                reader: Mutex::new(None),
1004                config: config.clone(),
1005                // Fail closed until the first successful `create` declares
1006                // whether this process starts active or in preflight.
1007                security_gate: SecurityGate::new_preflight(),
1008            });
1009            let app_handle = app.clone();
1010            let emit = move |value: &Value| {
1011                // Best-effort: a webview that has gone away must not crash the
1012                // owning thread. Emit to all windows on the syncular channel.
1013                let _ = app_handle.emit(EVENT_NAME, value.clone());
1014            };
1015            std::thread::Builder::new()
1016                .name("syncular-core".to_owned())
1017                .spawn(move || run_owner_thread(config, tx, rx, emit))
1018                .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
1019            Ok(())
1020        })
1021        .on_event(|app, event| {
1022            if let RunEvent::Exit = event {
1023                if let Some(state) = app.try_state::<SyncularState>() {
1024                    let _ = state.send(Request::Shutdown);
1025                    if let Ok(Some(reader)) = state.reader() {
1026                        let _ = reader.send(ReadRequest::Shutdown);
1027                    }
1028                }
1029            }
1030        })
1031        .build()
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037
1038    #[test]
1039    fn config_to_transport_json_shapes_fields() {
1040        let config = SyncularConfig {
1041            base_url: Some("https://api.example.com".to_owned()),
1042            headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
1043            ..Default::default()
1044        };
1045        let json = config.to_transport_json();
1046        assert_eq!(json["baseUrl"], "https://api.example.com");
1047        assert_eq!(json["headers"]["authorization"], "Bearer x");
1048    }
1049
1050    #[test]
1051    fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
1052        let gate = std::sync::Arc::new(SecurityGate::new_preflight());
1053        gate.activate().expect("activate test gate");
1054        let read = gate.enter_read().expect("active read");
1055        let gate_for_barrier = std::sync::Arc::clone(&gate);
1056        let (done_tx, done_rx) = std::sync::mpsc::channel();
1057        let barrier = std::thread::spawn(move || {
1058            gate_for_barrier.begin_preflight().expect("enter preflight");
1059            done_tx.send(()).expect("barrier reply");
1060        });
1061
1062        assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
1063        drop(read);
1064        done_rx
1065            .recv_timeout(Duration::from_secs(1))
1066            .expect("barrier drains after the read");
1067        barrier.join().expect("barrier thread");
1068        assert!(gate.enter_read().is_err());
1069    }
1070
1071    #[test]
1072    fn direct_set_headers_command_respects_the_native_preflight_gate() {
1073        use std::collections::BTreeMap;
1074        use tauri::test::{mock_builder, mock_context, noop_assets};
1075
1076        let app = mock_builder()
1077            .plugin(init(SyncularConfig {
1078                auto_sync: false,
1079                ..Default::default()
1080            }))
1081            .build(mock_context(noop_assets()))
1082            .expect("build mock app");
1083
1084        let blocked = tauri::async_runtime::block_on(syncular_set_headers(
1085            app.handle().clone(),
1086            BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
1087        ))
1088        .expect("preflight reply");
1089        assert_eq!(
1090            blocked["error"]["code"],
1091            Value::from("client.security_preflight_required")
1092        );
1093
1094        let created = tauri::async_runtime::block_on(syncular_command(
1095            app.handle().clone(),
1096            json!({
1097                "method": "create",
1098                "params": {
1099                    "clientId": "native-header-gate",
1100                    "schema": { "version": 1, "tables": [] }
1101                }
1102            }),
1103        ))
1104        .expect("create reply");
1105        assert!(created.get("error").is_none(), "{created}");
1106
1107        let active = tauri::async_runtime::block_on(syncular_set_headers(
1108            app.handle().clone(),
1109            BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
1110        ))
1111        .expect("active reply");
1112        assert_eq!(active["result"], Value::Null);
1113    }
1114
1115    #[test]
1116    fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
1117        use tauri::test::{mock_builder, mock_context, noop_assets};
1118
1119        let app = mock_builder()
1120            .plugin(init(SyncularConfig {
1121                auto_sync: false,
1122                ..Default::default()
1123            }))
1124            .build(mock_context(noop_assets()))
1125            .expect("build mock app");
1126        let command = |value: Value| {
1127            tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1128                .expect("command reply")
1129        };
1130
1131        let created = command(json!({
1132            "method": "create",
1133            "params": {
1134                "clientId": "native-preflight-escape",
1135                "schema": { "version": 1, "tables": [] },
1136                "securityPreflight": true
1137            }
1138        }));
1139        assert!(created.get("error").is_none(), "{created}");
1140
1141        // The escape attempt: a plain re-create must be refused by the shared
1142        // router, and the plugin's fast-read gate must stay engaged.
1143        let escape = command(json!({
1144            "method": "create",
1145            "params": {
1146                "clientId": "native-preflight-escape",
1147                "schema": { "version": 1, "tables": [] }
1148            }
1149        }));
1150        assert_eq!(
1151            escape["error"]["code"],
1152            Value::from("client.security_preflight_required"),
1153            "{escape}"
1154        );
1155        let read = tauri::async_runtime::block_on(syncular_query(
1156            app.handle().clone(),
1157            "SELECT 1".to_owned(),
1158            None,
1159        ))
1160        .expect("query reply");
1161        assert_eq!(
1162            read["error"]["code"],
1163            Value::from("client.security_preflight_required")
1164        );
1165
1166        // Activation (with a fresh header set) releases both gates.
1167        let activated = command(json!({
1168            "method": "activateSecurity",
1169            "params": { "headers": { "authorization": "Bearer fresh" } }
1170        }));
1171        assert!(activated.get("error").is_none(), "{activated}");
1172        let read = tauri::async_runtime::block_on(syncular_query(
1173            app.handle().clone(),
1174            "SELECT 1 AS value".to_owned(),
1175            None,
1176        ))
1177        .expect("query reply");
1178        assert_eq!(read["result"]["rows"][0]["value"], 1);
1179        let recreated = command(json!({
1180            "method": "create",
1181            "params": {
1182                "clientId": "native-preflight-escape",
1183                "schema": { "version": 1, "tables": [] }
1184            }
1185        }));
1186        assert!(recreated.get("error").is_none(), "{recreated}");
1187    }
1188
1189    #[test]
1190    fn authority_snapshot_raw_ipc_obeys_native_columns_and_never_opens_protected_reads() {
1191        use tauri::test::{mock_builder, mock_context, noop_assets};
1192        let path =
1193            std::env::temp_dir().join(format!("syncular-authority-ipc-{}.db", std::process::id()));
1194        let app = mock_builder()
1195            .plugin(init(SyncularConfig {
1196                auto_sync: false,
1197                db_path: Some(path.to_string_lossy().into_owned()),
1198                authority_columns: [(
1199                    "authority".into(),
1200                    vec!["id".into(), "actor_id".into(), "role".into()],
1201                )]
1202                .into(),
1203                ..Default::default()
1204            }))
1205            .build(mock_context(noop_assets()))
1206            .expect("mock app");
1207        let handle = app.handle().clone();
1208        let command = |value: Value| {
1209            tauri::async_runtime::block_on(syncular_command(handle.clone(), value))
1210                .expect("IPC reply")
1211        };
1212        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
1213            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "role", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
1214        ]}]});
1215        let policy = json!([{"table": "authority", "columns": ["id", "actor_id", "role"], "scopes": {"actor_id": ["a", "b"]}}]);
1216        let create = |reads: Value| json!({"method": "create", "params": {"schema": schema, "securityPreflight": true, "transportEnabled": false, "authorityReads": reads}});
1217        let created = command(create(policy.clone()));
1218        assert!(created.get("error").is_none(), "{created}");
1219        let conn = rusqlite::Connection::open(&path).expect("fixture db");
1220        conn.execute_batch("INSERT INTO _syncular_base_authority(id,actor_id,role,secret,_syncular_version)VALUES('one','a','accepted','clinical plaintext',7); INSERT INTO authority SELECT * FROM _syncular_base_authority;").expect("fixture base");
1221        for actor in ["a", "b"] {
1222            let state = json!({"requested": {"actor_id": [actor]}, "effectiveScopes": {"actor_id": [actor]}, "status": "active", "cursor": 7});
1223            conn.execute(
1224                "INSERT INTO _syncular_subscriptions(id,tbl,state_json)VALUES(?1,'authority',?2)",
1225                rusqlite::params![actor, state.to_string()],
1226            )
1227            .expect("fixture subscription");
1228        }
1229        let snapshot = command(json!({"method": "authoritySnapshot", "params": {}}));
1230        assert_eq!(snapshot["result"]["complete"], true, "{snapshot}");
1231        assert_eq!(
1232            snapshot["result"]["tables"][0]["rows"][0]["values"],
1233            json!({"id": "one", "actor_id": "a", "role": "accepted"})
1234        );
1235        assert!(!snapshot.to_string().contains("clinical plaintext"));
1236        assert_eq!(
1237            command(json!({"method": "securityLifecycle", "params": {}}))["result"]["state"],
1238            "preflight"
1239        );
1240        for params in [
1241            json!({"sql": "SELECT secret FROM authority"}),
1242            json!({"columns": ["secret"]}),
1243            json!({"table": "_syncular_meta"}),
1244            json!({"scopes": {"actor_id": ["outside"]}}),
1245            json!({"authorityReads": policy}),
1246        ] {
1247            assert_eq!(
1248                command(json!({"method": "authoritySnapshot", "params": params}))["error"]["code"],
1249                "client.authority_read_forbidden"
1250            );
1251        }
1252        for columns in [
1253            json!(["id", "actor_id", "secret"]),
1254            json!([
1255                "id",
1256                "actor_id",
1257                "role FROM authority; DELETE FROM authority"
1258            ]),
1259        ] {
1260            assert_eq!(
1261                command(create(
1262                    json!([{"table": "authority", "columns": columns, "scopes": {"actor_id": ["a"]}}])
1263                ))["error"]["code"],
1264                "client.authority_read_forbidden"
1265            );
1266        }
1267        for method in [
1268            "query",
1269            "querySnapshot",
1270            "mutate",
1271            "setHeaders",
1272            "subscribe",
1273            "sync",
1274        ] {
1275            assert_eq!(
1276                command(json!({"method": method, "params": {"sql": "SELECT id FROM authority"}}))
1277                    ["error"]["code"],
1278                "client.security_preflight_required"
1279            );
1280        }
1281        assert_eq!(
1282            tauri::async_runtime::block_on(syncular_query(
1283                app.handle().clone(),
1284                "SELECT id FROM authority".into(),
1285                None
1286            ))
1287            .unwrap()["error"]["code"],
1288            "client.security_preflight_required"
1289        );
1290        assert_eq!(
1291            tauri::async_runtime::block_on(syncular_query_snapshot(
1292                app.handle().clone(),
1293                "SELECT id FROM authority".into(),
1294                None,
1295                None,
1296                None
1297            ))
1298            .unwrap()["error"]["code"],
1299            "client.security_preflight_required"
1300        );
1301        let before = snapshot["result"]["revision"].clone();
1302        assert_eq!(
1303            command(json!({"method": "authoritySnapshot", "params": {}}))["result"]["revision"],
1304            before
1305        );
1306        conn.execute("UPDATE _syncular_subscriptions SET state_json=?1 WHERE id='b'", [json!({"requested": {"actor_id": ["b"]}, "effectiveScopes": {"actor_id": ["b"]}, "status": "active", "cursor": 7, "bootstrapState": "credential-do-not-expose"}).to_string()]).unwrap();
1307        let partial = command(json!({"method": "authoritySnapshot", "params": {}}));
1308        assert_eq!(partial["result"]["complete"], false);
1309        assert!(!partial.to_string().contains("credential-do-not-expose"));
1310        conn.execute(
1311            "UPDATE _syncular_subscriptions SET state_json='credential-do-not-expose' WHERE id='b'",
1312            [],
1313        )
1314        .unwrap();
1315        let corrupt = command(json!({"method": "authoritySnapshot", "params": {}}));
1316        assert_eq!(corrupt["error"]["code"], "sync.local_corrupt");
1317        assert!(!corrupt.to_string().contains("credential-do-not-expose"));
1318        conn.execute("DELETE FROM _syncular_subscriptions WHERE id='b'", [])
1319            .unwrap();
1320        command(json!({"method": "beginSecurityPreflight", "params": {}}));
1321        assert!(
1322            command(json!({"method": "authoritySnapshot", "params": {}}))
1323                .get("error")
1324                .is_none()
1325        );
1326        // The owner's mailbox serializes a lock/read race without admitting SQL.
1327        let barrier = std::sync::Barrier::new(2);
1328        std::thread::scope(|scope| {
1329            let locking = scope.spawn(|| {
1330                barrier.wait();
1331                command(json!({"method": "beginSecurityPreflight", "params": {}}))
1332            });
1333            barrier.wait();
1334            let reading = command(json!({"method": "authoritySnapshot", "params": {}}));
1335            assert_eq!(reading["result"]["revision"], before);
1336            assert!(locking.join().unwrap().get("error").is_none());
1337        });
1338        // A racing close either follows the entire snapshot or rejects it.
1339        let barrier = std::sync::Barrier::new(2);
1340        std::thread::scope(|scope| {
1341            let closing = scope.spawn(|| {
1342                barrier.wait();
1343                command(json!({"method": "shutdown", "params": {}}))
1344            });
1345            barrier.wait();
1346            let reading = command(json!({"method": "authoritySnapshot", "params": {}}));
1347            assert!(
1348                reading["result"]["revision"] == before
1349                    || reading["error"]["code"] == "client.closed",
1350                "{reading}"
1351            );
1352            assert!(closing.join().unwrap().get("error").is_none());
1353        });
1354        assert_eq!(
1355            command(json!({"method": "authoritySnapshot", "params": {}}))["error"]["code"],
1356            "client.closed"
1357        );
1358        drop(conn);
1359        let _ = std::fs::remove_file(path);
1360    }
1361
1362    #[test]
1363    fn resolve_database_names_a_file_inside_the_configured_directory() {
1364        let dir =
1365            std::env::temp_dir().join(format!("syncular-tauri-resolve-{}", std::process::id()));
1366        let config = SyncularConfig {
1367            db_path: Some("/tmp/default.db".to_owned()),
1368            database_dir: Some(dir.to_string_lossy().into_owned()),
1369            ..Default::default()
1370        };
1371        // No name → the configured default path.
1372        assert_eq!(
1373            resolve_database(&config, &json!({ "clientId": "c1" })),
1374            Ok(Some("/tmp/default.db".to_owned()))
1375        );
1376        // A name → `<database_dir>/<name>.db`, directory created.
1377        assert_eq!(
1378            resolve_database(&config, &json!({ "database": "app-actor-0f.v2" })),
1379            Ok(Some(
1380                dir.join("app-actor-0f.v2.db")
1381                    .to_string_lossy()
1382                    .into_owned()
1383            ))
1384        );
1385        assert!(dir.is_dir());
1386        // A webview-supplied path and every name that could leave the
1387        // directory are refused.
1388        for params in [
1389            json!({ "dbPath": "/etc/other.db" }),
1390            json!({ "database": "../escape" }),
1391            json!({ "database": "a/b" }),
1392            json!({ "database": "a\\b" }),
1393            json!({ "database": "/abs" }),
1394            json!({ "database": ".hidden" }),
1395            json!({ "database": "a..b" }),
1396            json!({ "database": "" }),
1397            json!({ "database": "x".repeat(129) }),
1398            json!({ "database": 7 }),
1399        ] {
1400            let refused = resolve_database(&config, &params).expect_err("refused");
1401            assert_eq!(refused["error"]["code"], "sync.invalid_request", "{params}");
1402        }
1403        // A configuration with only a directory never opens an unnamed
1404        // create in memory.
1405        let refused = resolve_database(
1406            &SyncularConfig {
1407                database_dir: Some(dir.to_string_lossy().into_owned()),
1408                ..Default::default()
1409            },
1410            &json!({ "clientId": "c1" }),
1411        )
1412        .expect_err("name required");
1413        assert_eq!(refused["error"]["code"], "sync.invalid_request");
1414        // A name without a configured directory is refused, never placed
1415        // somewhere else.
1416        let refused = resolve_database(&SyncularConfig::default(), &json!({ "database": "actor" }))
1417            .expect_err("no directory");
1418        assert_eq!(refused["error"]["code"], "sync.invalid_request");
1419        std::fs::remove_dir_all(dir).expect("remove temp directory");
1420    }
1421
1422    /// One replica per actor: each `create` opens its named database, keeps
1423    /// its own client id, and the snapshot reader follows the open database.
1424    #[test]
1425    fn named_databases_keep_one_replica_and_client_id_per_actor() {
1426        use tauri::test::{mock_builder, mock_context, noop_assets};
1427
1428        let dir =
1429            std::env::temp_dir().join(format!("syncular-tauri-actors-{}", std::process::id()));
1430        let _ = std::fs::remove_dir_all(&dir);
1431        let app = mock_builder()
1432            .plugin(init(SyncularConfig {
1433                database_dir: Some(dir.to_string_lossy().into_owned()),
1434                auto_sync: false,
1435                ..Default::default()
1436            }))
1437            .build(mock_context(noop_assets()))
1438            .expect("build mock app");
1439        let command = |value: Value| {
1440            tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1441                .expect("command reply")
1442        };
1443        let snapshot = |sql: &str| {
1444            tauri::async_runtime::block_on(syncular_query_snapshot(
1445                app.handle().clone(),
1446                sql.to_owned(),
1447                None,
1448                None,
1449                None,
1450            ))
1451            .expect("snapshot reply")
1452        };
1453        let schema = json!({
1454            "version": 1,
1455            "tables": [{
1456                "name": "todo", "primaryKey": "id",
1457                "columns": [
1458                    { "name": "id", "type": "string", "nullable": false },
1459                    { "name": "title", "type": "string", "nullable": false }
1460                ],
1461                "scopes": []
1462            }]
1463        });
1464        let create = |database: &str, client_id: &str| {
1465            command(json!({ "method": "create", "params": {
1466                "database": database, "clientId": client_id, "schema": schema
1467            } }))
1468        };
1469
1470        assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1471        command(json!({ "method": "mutate", "params": { "mutations": [{
1472            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "a" }
1473        }] } }));
1474        assert_eq!(
1475            snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1476            "a"
1477        );
1478        assert_eq!(
1479            command(json!({ "method": "shutdown" }))["result"],
1480            json!({})
1481        );
1482
1483        assert_eq!(create("actor-b", "client-b")["result"], json!({}));
1484        assert_eq!(
1485            snapshot("SELECT title FROM todo")["result"]["rows"],
1486            json!([]),
1487            "the reader follows the second actor's database"
1488        );
1489        assert!(dir.join("actor-a.db").is_file());
1490        assert!(dir.join("actor-b.db").is_file());
1491        assert_eq!(
1492            command(json!({ "method": "shutdown" }))["result"],
1493            json!({})
1494        );
1495
1496        // Each replica keeps the client id it was created with.
1497        assert_eq!(
1498            create("actor-a", "client-b")["error"]["code"],
1499            "client.identity_mismatch"
1500        );
1501        assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1502        assert_eq!(
1503            snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1504            "a"
1505        );
1506        assert_eq!(
1507            create("../actor-a", "client-a")["error"]["code"],
1508            "sync.invalid_request"
1509        );
1510        command(json!({ "method": "shutdown" }));
1511        std::fs::remove_dir_all(dir).expect("remove temp directory");
1512    }
1513
1514    #[test]
1515    fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
1516        let parsed = parse_coverage(Some(&json!([{
1517            "base": {
1518                "table": "tasks",
1519                "variable": "project_id",
1520                "fixedScopes": { "tenant_id": ["one", "two"] },
1521                "params": "opaque"
1522            },
1523            "units": ["a", "b"]
1524        }])))
1525        .expect("parse coverage");
1526        assert_eq!(parsed.len(), 1);
1527        let entry = &parsed[0];
1528        assert_eq!(entry.base.table, "tasks");
1529        assert_eq!(entry.base.variable, "project_id");
1530        assert_eq!(
1531            entry.base.fixed_scopes,
1532            vec![(
1533                "tenant_id".to_owned(),
1534                vec!["one".to_owned(), "two".to_owned()]
1535            )]
1536        );
1537        assert_eq!(entry.base.params.as_deref(), Some("opaque"));
1538        assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
1539    }
1540
1541    /// The owner-thread mailbox loop end-to-end, without any Tauri window: post
1542    /// commands, collect emitted events. This is the real host path — the Tauri
1543    /// commands are a two-line channel forward over exactly this.
1544    #[test]
1545    fn owner_thread_round_trips_over_mailbox() {
1546        use std::sync::mpsc::channel;
1547        use std::sync::{Arc, Mutex as StdMutex};
1548
1549        let (tx, rx) = channel::<Request>();
1550        let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
1551        let events_for_thread = Arc::clone(&events);
1552        let config = SyncularConfig {
1553            auto_sync: false,
1554            ..Default::default()
1555        };
1556        let owner_tx = tx.clone();
1557        let handle = std::thread::spawn(move || {
1558            run_owner_thread(config, owner_tx, rx, move |v| {
1559                events_for_thread.lock().unwrap().push(v.clone());
1560            });
1561        });
1562
1563        let call = |command: Value| -> Value {
1564            let (rtx, rrx) = channel();
1565            tx.send(Request::Command {
1566                command,
1567                reply: rtx,
1568            })
1569            .unwrap();
1570            rrx.recv().unwrap()
1571        };
1572
1573        let schema = json!({
1574            "version": 1,
1575            "tables": [{
1576                "name": "todo", "primaryKey": "id",
1577                "columns": [
1578                    { "name": "id", "type": "string", "nullable": false },
1579                    { "name": "title", "type": "string", "nullable": false }
1580                ],
1581                "scopes": []
1582            }]
1583        });
1584        assert_eq!(
1585            call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1586                ["result"],
1587            json!({})
1588        );
1589        call(json!({ "method": "mutate", "params": { "mutations": [{
1590            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1591        }] } }));
1592
1593        // A query over the mailbox.
1594        let (qtx, qrx) = channel();
1595        tx.send(Request::Query {
1596            sql: "SELECT title FROM todo".to_owned(),
1597            params: Value::Null,
1598            reply: qtx,
1599        })
1600        .unwrap();
1601        let rows = qrx.recv().unwrap();
1602        assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1603
1604        // Header rotation rides the same mailbox; a
1605        // client-local (Null-transport) core accepts and ignores the set.
1606        let (htx, hrx) = channel();
1607        tx.send(Request::SetHeaders {
1608            headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1609            reply: htx,
1610        })
1611        .unwrap();
1612        assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1613
1614        tx.send(Request::Shutdown).unwrap();
1615        handle.join().unwrap();
1616
1617        let seen = events.lock().unwrap();
1618        let kinds: Vec<String> = seen
1619            .iter()
1620            .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1621            .collect();
1622        // The local mutate emits the exact revisioned batch onto the channel.
1623        assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1624    }
1625
1626    #[cfg(feature = "native-transport")]
1627    #[test]
1628    fn pending_network_round_keeps_local_mutations_and_queries_responsive() {
1629        use ssp2::model::{Change, Frame, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
1630        use ssp2::segment::{encode_row, Column, ColumnType, ColumnValue};
1631        use std::io::{Read, Write};
1632        use std::net::TcpListener;
1633        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1634        use std::sync::mpsc::channel;
1635        use std::sync::Arc;
1636
1637        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1638        let address = listener.local_addr().unwrap();
1639        let (entered_tx, entered_rx) = channel();
1640        let (release_tx, release_rx) = channel();
1641        let (delivered_tx, delivered_rx) = channel();
1642        let (ack_tx, ack_rx) = channel();
1643        let image_sent = Arc::new(AtomicBool::new(false));
1644        let server_image_sent = image_sent.clone();
1645        let requests = Arc::new(AtomicUsize::new(0));
1646        let server_requests = requests.clone();
1647        let server = std::thread::spawn(move || {
1648            let mut pushed = Vec::new();
1649            loop {
1650                let (mut socket, _) = listener.accept().unwrap();
1651                server_requests.fetch_add(1, Ordering::SeqCst);
1652                let mut header = Vec::new();
1653                while !header.ends_with(b"\r\n\r\n") {
1654                    let mut byte = [0];
1655                    socket.read_exact(&mut byte).unwrap();
1656                    header.push(byte[0]);
1657                }
1658                let header = String::from_utf8(header).unwrap();
1659                assert!(header
1660                    .to_ascii_lowercase()
1661                    .contains("authorization: bearer fresh"));
1662                let length: usize = header
1663                    .lines()
1664                    .find_map(|line| {
1665                        let (name, value) = line.split_once(':')?;
1666                        name.eq_ignore_ascii_case("content-length")
1667                            .then(|| value.trim().parse().unwrap())
1668                    })
1669                    .unwrap();
1670                let mut bytes = vec![0; length];
1671                socket.read_exact(&mut bytes).unwrap();
1672                let request = ssp2::decode_message(&bytes).unwrap();
1673                let reset = matches!(
1674                    &request.frames[0],
1675                    Frame::ReqHeader {
1676                        log_epoch: None,
1677                        ..
1678                    }
1679                );
1680                let deliver = !reset && pushed.len() == 3;
1681                let mut frames = vec![Frame::RespHeader {
1682                    required_schema_version: None,
1683                    latest_schema_version: None,
1684                    log_epoch: Some("epoch".into()),
1685                    reset_required: Some(reset),
1686                }];
1687                if !reset {
1688                    let commits: Vec<_> = request
1689                        .frames
1690                        .iter()
1691                        .filter_map(|frame| {
1692                            if let Frame::PushCommit {
1693                                client_commit_id,
1694                                operations,
1695                            } = frame
1696                            {
1697                                Some((client_commit_id.clone(), operations.len()))
1698                            } else {
1699                                None
1700                            }
1701                        })
1702                        .collect();
1703                    if !commits.is_empty() && pushed.is_empty() {
1704                        entered_tx.send(()).unwrap();
1705                        release_rx.recv().unwrap();
1706                    }
1707                    for (id, count) in commits {
1708                        pushed.push(id.clone());
1709                        frames.push(Frame::PushResult {
1710                            client_commit_id: id,
1711                            status: PushStatus::Applied,
1712                            commit_seq: Some(pushed.len() as i64),
1713                            results: (0..count)
1714                                .map(|i| OpResult::Applied { op_index: i as i32 })
1715                                .collect(),
1716                        });
1717                    }
1718                }
1719                if !reset
1720                    && request
1721                        .frames
1722                        .iter()
1723                        .any(|frame| matches!(frame, Frame::Subscription { id, .. } if id == "own"))
1724                {
1725                    frames.push(Frame::SubStart {
1726                        id: "own".into(),
1727                        status: SubStatus::Active,
1728                        reason_code: String::new(),
1729                        effective_scopes: vec![("id".into(), vec!["one".into()])],
1730                        bootstrap: false,
1731                    });
1732                    if deliver {
1733                        assert!(request
1734                            .frames
1735                            .iter()
1736                            .any(|frame| matches!(frame, Frame::PullHeader { .. })));
1737                        assert!(!request
1738                            .frames
1739                            .iter()
1740                            .any(|frame| matches!(frame, Frame::PushCommit { .. })));
1741                        let columns = ["id", "title"].map(|name| Column {
1742                            name: name.into(),
1743                            ty: ColumnType::String,
1744                            nullable: false,
1745                        });
1746                        let mut row = ssp2::primitives::Writer::new();
1747                        encode_row(
1748                            &mut row,
1749                            &columns,
1750                            &vec![
1751                                Some(ColumnValue::String("one".into())),
1752                                Some(ColumnValue::String("300/v3".into())),
1753                            ],
1754                        );
1755                        frames.push(Frame::Commit {
1756                            commit_seq: 3,
1757                            created_at_ms: 0,
1758                            actor_id: "actor".into(),
1759                            tables: vec!["todo".into()],
1760                            changes: vec![Change {
1761                                table_index: 0,
1762                                row_id: "one".into(),
1763                                op: Op::Upsert,
1764                                row_version: Some(3),
1765                                scopes: vec![("id".into(), "one".into())],
1766                                row: Some(row.into_bytes()),
1767                            }],
1768                        });
1769                        server_image_sent.store(true, Ordering::SeqCst);
1770                    }
1771                    frames.push(Frame::SubEnd {
1772                        next_cursor: if deliver { 3 } else { 0 },
1773                        bootstrap_state: None,
1774                    });
1775                }
1776                let response = ssp2::encode_message(&Message {
1777                    wire_version: ssp2::decode::WIRE_VERSION,
1778                    msg_kind: MsgKind::Response,
1779                    frames,
1780                });
1781                write!(
1782                    socket,
1783                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1784                    response.len()
1785                )
1786                .unwrap();
1787                socket.write_all(&response).unwrap();
1788                if deliver {
1789                    break;
1790                }
1791            }
1792            pushed
1793        });
1794        let (tx, rx) = channel();
1795        let owner_tx = tx.clone();
1796        let owner = std::thread::spawn(move || {
1797            run_owner_thread(
1798                SyncularConfig {
1799                    base_url: Some(format!("http://{address}")),
1800                    auto_sync: true,
1801                    ..Default::default()
1802                },
1803                owner_tx,
1804                rx,
1805                move |event| {
1806                    if event["type"] == "change" && event["batch"]["outcomesChanged"] == true {
1807                        let _ = ack_tx.send(());
1808                    }
1809                    if image_sent.load(Ordering::SeqCst) && event["type"] == "change" {
1810                        let _ = delivered_tx.send(());
1811                    }
1812                },
1813            )
1814        });
1815        let call = |command: Value| {
1816            let (reply, result) = channel();
1817            tx.send(Request::Command { command, reply }).unwrap();
1818            result
1819        };
1820        let created = call(json!({ "method": "create", "params": {
1821            "clientId": "pending-round", "transportEnabled": false, "securityPreflight": true, "schema": { "version": 1, "tables": [{
1822                "name": "todo", "primaryKey": "id", "columns": [
1823                    { "name": "id", "type": "string", "nullable": false },
1824                    { "name": "title", "type": "string", "nullable": false }
1825                ], "scopes": [{ "pattern": "todo:{id}" }]
1826            }] }
1827        } }));
1828        assert!(created.recv().unwrap().get("error").is_none());
1829        assert!(call(json!({ "method": "activateSecurity", "params": {} }))
1830            .recv()
1831            .unwrap()
1832            .get("error")
1833            .is_none());
1834        assert!(call(json!({ "method": "subscribe", "params": { "id": "own", "table": "todo", "scopes": { "id": ["one"] } } })).recv().unwrap().get("error").is_none());
1835        let first = call(json!({ "method": "mutate", "params": { "mutations": [{
1836            "op": "upsert", "table": "todo", "values": { "id": "one", "title": "100/v1" }
1837        }] } }))
1838        .recv()
1839        .unwrap();
1840        let queued_second = call(json!({ "method": "mutate", "params": { "mutations": [{
1841            "op": "patch", "table": "todo", "values": { "id": "one", "title": "200/v2" }
1842        }] } }))
1843        .recv()
1844        .unwrap();
1845        assert_eq!(
1846            call(json!({ "method": "sync" })).recv().unwrap()["result"]["errorCode"],
1847            "sync.offline"
1848        );
1849        assert_eq!(
1850            call(json!({ "method": "connectRealtime" })).recv().unwrap()["error"]["code"],
1851            "sync.offline"
1852        );
1853        assert_eq!(requests.load(Ordering::SeqCst), 0);
1854        let (reply, result) = channel();
1855        tx.send(Request::SetHeaders {
1856            headers: vec![("authorization".into(), "Bearer fresh".into())],
1857            reply,
1858        })
1859        .unwrap();
1860        assert_eq!(result.recv().unwrap()["result"], Value::Null);
1861        assert!(
1862            call(json!({ "method": "setTransportEnabled", "params": { "enabled": true } }))
1863                .recv()
1864                .unwrap()
1865                .get("error")
1866                .is_none()
1867        );
1868        entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
1869        assert!(
1870            call(json!({ "method": "setTransportEnabled", "params": { "enabled": false } }))
1871                .recv()
1872                .unwrap()
1873                .get("error")
1874                .is_none()
1875        );
1876        let started = Instant::now();
1877        let second = call(json!({ "method": "mutate", "params": { "mutations": [{
1878            "op": "patch", "table": "todo", "values": { "id": "one", "title": "300/v3" }
1879        }] } }));
1880        let local_reply = second.recv_timeout(Duration::from_millis(100));
1881        let elapsed = started.elapsed();
1882        let (query_tx, query_rx) = channel();
1883        tx.send(Request::Query {
1884            sql: "SELECT title FROM todo ORDER BY id".into(),
1885            params: Value::Null,
1886            reply: query_tx,
1887        })
1888        .unwrap();
1889        let local_rows = query_rx.recv_timeout(Duration::from_millis(100));
1890        // Release even on the old blocking implementation, so a failing test
1891        // joins its transport and owner rather than leaving network threads.
1892        release_tx.send(()).unwrap();
1893        let second_reply = local_reply
1894            .clone()
1895            .unwrap_or_else(|_| second.recv().unwrap());
1896        let rows = local_rows
1897            .clone()
1898            .unwrap_or_else(|_| query_rx.recv().unwrap());
1899        // Local mutation emits an outcome batch too; wait for both captured commits' applied outcomes.
1900        loop {
1901            ack_rx.recv_timeout(Duration::from_secs(10)).unwrap();
1902            let outcomes = call(json!({ "method": "commitOutcomes" })).recv().unwrap();
1903            if outcomes["result"]["outcomes"]
1904                .as_array()
1905                .unwrap()
1906                .iter()
1907                .filter(|o| o["status"] == "applied")
1908                .count()
1909                == 2
1910            {
1911                break;
1912            }
1913        }
1914        let paused_count = requests.load(Ordering::SeqCst);
1915        assert_eq!(
1916            call(json!({ "method": "sync" })).recv().unwrap()["result"]["errorCode"],
1917            "sync.offline"
1918        );
1919        assert_eq!(requests.load(Ordering::SeqCst), paused_count);
1920        assert!(
1921            call(json!({ "method": "setTransportEnabled", "params": { "enabled": true } }))
1922                .recv()
1923                .unwrap()
1924                .get("error")
1925                .is_none()
1926        );
1927        delivered_rx
1928            .recv_timeout(Duration::from_secs(10))
1929            .expect("ACK schedules the own-image pull without realtime or a manual round");
1930        let (query_tx, query_rx) = channel();
1931        tx.send(Request::Query {
1932            sql: "SELECT title,_sync_version AS version FROM todo WHERE id='one'".into(),
1933            params: Value::Null,
1934            reply: query_tx,
1935        })
1936        .unwrap();
1937        let delivered = query_rx.recv().unwrap();
1938        assert_eq!(delivered["result"]["rows"][0]["title"], "300/v3");
1939        assert_eq!(delivered["result"]["rows"][0]["version"], 3);
1940        let pushed = server.join().unwrap();
1941        tx.send(Request::Shutdown).unwrap();
1942        owner.join().unwrap();
1943        assert!(
1944            local_reply.is_ok(),
1945            "local mutation waited for network reply"
1946        );
1947        assert!(local_rows.is_ok(), "local query waited for network reply");
1948        eprintln!("second local mutation while network pending: {elapsed:?}");
1949        assert_eq!(rows["result"]["rows"].as_array().unwrap().len(), 1);
1950        assert_eq!(
1951            pushed,
1952            vec![
1953                first["result"]["clientCommitId"].as_str().unwrap(),
1954                queued_second["result"]["clientCommitId"].as_str().unwrap(),
1955                second_reply["result"]["clientCommitId"].as_str().unwrap()
1956            ]
1957        );
1958    }
1959
1960    #[test]
1961    fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1962        use std::sync::mpsc::channel;
1963
1964        let path =
1965            std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1966        let config = SyncularConfig {
1967            db_path: Some(path.to_string_lossy().into_owned()),
1968            auto_sync: false,
1969            ..Default::default()
1970        };
1971        let (tx, rx) = channel::<Request>();
1972        let owner_tx = tx.clone();
1973        let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1974
1975        let (create_tx, create_rx) = channel();
1976        tx.send(Request::Command {
1977            command: json!({
1978                "method": "create",
1979                "params": {
1980                    "clientId": "sidecar-client",
1981                    "schema": { "version": 1, "tables": [] },
1982                    "dbPath": path.to_string_lossy()
1983                }
1984            }),
1985            reply: create_tx,
1986        })
1987        .expect("post create");
1988        assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1989
1990        let (read_tx, read_rx) = channel::<ReadRequest>();
1991        let read_path = path.to_string_lossy().into_owned();
1992        let owner_tx = tx.clone();
1993        let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
1994
1995        // Model a slow HTTP/WS round on the mutable owner. The dedicated read
1996        // mailbox must still return the durable local snapshot immediately.
1997        let (entered_tx, entered_rx) = channel();
1998        tx.send(Request::Block {
1999            duration: Duration::from_millis(200),
2000            entered: entered_tx,
2001        })
2002        .expect("block owner");
2003        entered_rx.recv().expect("owner entered blocking round");
2004        let (snapshot_tx, snapshot_rx) = channel();
2005        read_tx
2006            .send(ReadRequest::QuerySnapshot {
2007                sql: "SELECT 1 AS value".to_owned(),
2008                params: Vec::new(),
2009                coverage: Vec::new(),
2010                owner: None,
2011                reply: snapshot_tx,
2012            })
2013            .expect("post snapshot");
2014        let snapshot = snapshot_rx
2015            .recv_timeout(Duration::from_millis(50))
2016            .expect("local snapshot must not wait for the owner");
2017        assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
2018
2019        let (failed_tx, failed_rx) = channel();
2020        read_tx
2021            .send(ReadRequest::QuerySnapshot {
2022                sql: "SELECT private_value FROM missing_private_table".to_owned(),
2023                params: Vec::new(),
2024                coverage: Vec::new(),
2025                owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
2026                reply: failed_tx,
2027            })
2028            .expect("post failed snapshot");
2029        assert_eq!(
2030            failed_rx.recv().expect("failed snapshot reply")["error"]["code"],
2031            "client.failed"
2032        );
2033        let (diagnostics_tx, diagnostics_rx) = channel();
2034        tx.send(Request::Command {
2035            command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
2036            reply: diagnostics_tx,
2037        })
2038        .expect("post diagnostics");
2039        assert_eq!(
2040            diagnostics_rx.recv().expect("diagnostics reply")["result"]["queryFailures"][0]["id"],
2041            "queries:missing"
2042        );
2043
2044        let (recovered_tx, recovered_rx) = channel();
2045        read_tx
2046            .send(ReadRequest::QuerySnapshot {
2047                sql: "SELECT 1 AS value".to_owned(),
2048                params: Vec::new(),
2049                coverage: Vec::new(),
2050                owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
2051                reply: recovered_tx,
2052            })
2053            .expect("post recovered snapshot");
2054        assert!(recovered_rx.recv().expect("recovered snapshot reply")["result"].is_object());
2055        let (cleared_tx, cleared_rx) = channel();
2056        tx.send(Request::Command {
2057            command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
2058            reply: cleared_tx,
2059        })
2060        .expect("post cleared diagnostics");
2061        assert_eq!(
2062            cleared_rx.recv().expect("cleared diagnostics reply")["result"]["queryFailures"],
2063            json!([])
2064        );
2065
2066        read_tx.send(ReadRequest::Shutdown).expect("stop reader");
2067        reader.join().expect("join reader");
2068        tx.send(Request::Shutdown).expect("stop owner");
2069        owner.join().expect("join owner");
2070        std::fs::remove_file(path).expect("remove temp database");
2071    }
2072}