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}
78
79impl Default for SyncularConfig {
80    fn default() -> Self {
81        Self {
82            base_url: None,
83            ws_url: None,
84            headers: Vec::new(),
85            db_path: None,
86            database_dir: None,
87            auto_sync: true,
88        }
89    }
90}
91
92impl SyncularConfig {
93    /// Build the JSON config the core's transport reads.
94    fn to_transport_json(&self) -> Value {
95        let mut map = serde_json::Map::new();
96        if let Some(base) = &self.base_url {
97            map.insert("baseUrl".to_owned(), Value::from(base.clone()));
98        }
99        if let Some(ws) = &self.ws_url {
100            map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
101        }
102        if !self.headers.is_empty() {
103            let headers: serde_json::Map<String, Value> = self
104                .headers
105                .iter()
106                .map(|(k, v)| (k.clone(), Value::from(v.clone())))
107                .collect();
108            map.insert("headers".to_owned(), Value::Object(headers));
109        }
110        Value::Object(map)
111    }
112}
113
114/// A request posted to the owning thread's mailbox. Each carries a one-shot
115/// reply channel; the Tauri command blocks on it (`spawn_blocking`-friendly).
116enum Request {
117    Command {
118        command: Value,
119        reply: Sender<Value>,
120    },
121    Query {
122        sql: String,
123        params: Value,
124        reply: Sender<Value>,
125    },
126    /// Replace the transport's request headers. Header state
127    /// lives on the core-owned transport, so mutation rides the same mailbox
128    /// as every other access — the one-owning-thread invariant holds.
129    SetHeaders {
130        headers: Vec<(String, String)>,
131        reply: Sender<Value>,
132    },
133    /// Native realtime reader wake; contains no data (the transport buffer does).
134    TransportWake,
135    /// §7.6: an owned read-sidecar failure, or the first success after one.
136    QueryRead {
137        id: String,
138        tables: Vec<String>,
139        failure: Option<QueryReadFailure>,
140    },
141    #[cfg(test)]
142    Block {
143        duration: Duration,
144        entered: Sender<()>,
145    },
146    Shutdown,
147}
148
149/// Latency-critical reads use a second, read-only SQLite connection. This
150/// mailbox is deliberately independent from [`Request`]: a network round on
151/// the mutable owner must never head-of-line-block a local UI snapshot.
152enum ReadRequest {
153    QuerySnapshot {
154        sql: String,
155        params: Vec<Value>,
156        coverage: Vec<WindowCoverage>,
157        owner: Option<(String, Vec<String>)>,
158        reply: Sender<Value>,
159    },
160    Shutdown,
161}
162
163/// The plugin's managed state: the mailbox sender the commands post to. Wrapped
164/// in a `Mutex` only to be `Sync` for Tauri state (the `Sender` is `Send`).
165struct SyncularState {
166    sender: Mutex<Sender<Request>>,
167    /// The snapshot reader of the file database the last successful `create`
168    /// opened; `None` before it, after `shutdown`, and for an in-memory client.
169    /// `create` and `shutdown` hold this lock until their reply, so the reader
170    /// always reads the database the owner has open.
171    reader: Mutex<Option<Sender<ReadRequest>>>,
172    config: SyncularConfig,
173    security_gate: SecurityGate,
174}
175
176struct SecurityGateState {
177    preflight: bool,
178    active_reads: usize,
179}
180
181struct SecurityGate {
182    state: Mutex<SecurityGateState>,
183    idle: Condvar,
184}
185
186struct SecurityReadGuard<'a> {
187    gate: &'a SecurityGate,
188}
189
190impl SecurityGate {
191    fn new_preflight() -> Self {
192        Self {
193            state: Mutex::new(SecurityGateState {
194                preflight: true,
195                active_reads: 0,
196            }),
197            idle: Condvar::new(),
198        }
199    }
200
201    fn begin_preflight(&self) -> Result<(), String> {
202        let mut state = self
203            .state
204            .lock()
205            .map_err(|_| "syncular security gate poisoned".to_owned())?;
206        state.preflight = true;
207        while state.active_reads > 0 {
208            state = self
209                .idle
210                .wait(state)
211                .map_err(|_| "syncular security gate poisoned".to_owned())?;
212        }
213        Ok(())
214    }
215
216    fn activate(&self) -> Result<(), String> {
217        let mut state = self
218            .state
219            .lock()
220            .map_err(|_| "syncular security gate poisoned".to_owned())?;
221        state.preflight = false;
222        Ok(())
223    }
224
225    fn enter_read(&self) -> Result<SecurityReadGuard<'_>, Value> {
226        let mut state = self
227            .state
228            .lock()
229            .map_err(|_| client_error("syncular security gate poisoned"))?;
230        if state.preflight {
231            return Err(security_preflight_error());
232        }
233        state.active_reads += 1;
234        Ok(SecurityReadGuard { gate: self })
235    }
236}
237
238impl Drop for SecurityReadGuard<'_> {
239    fn drop(&mut self) {
240        let Ok(mut state) = self.gate.state.lock() else {
241            return;
242        };
243        state.active_reads = state.active_reads.saturating_sub(1);
244        if state.active_reads == 0 {
245            self.gate.idle.notify_all();
246        }
247    }
248}
249
250impl SyncularState {
251    fn send(&self, request: Request) -> Result<(), String> {
252        self.sender
253            .lock()
254            .map_err(|_| "syncular mailbox poisoned".to_owned())?
255            .send(request)
256            .map_err(|_| "the syncular core thread has stopped".to_owned())
257    }
258
259    fn reader(&self) -> Result<Option<Sender<ReadRequest>>, String> {
260        Ok(self
261            .reader
262            .lock()
263            .map_err(|_| "syncular read mailbox poisoned".to_owned())?
264            .clone())
265    }
266}
267
268fn spawn_reader(path: String, owner_tx: Sender<Request>) -> Result<Sender<ReadRequest>, String> {
269    let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
270    std::thread::Builder::new()
271        .name("syncular-read".to_owned())
272        .spawn(move || run_reader_thread(path, reader_rx, owner_tx))
273        .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
274    Ok(reader_tx)
275}
276
277fn run_reader_thread(path: String, rx: Receiver<ReadRequest>, owner_tx: Sender<Request>) {
278    let mut reader = FileQuerySnapshotReader::new(path);
279    // §7.6: owner ids whose last sidecar read failed. Only a failure or the
280    // first success after one reaches the owning core's mailbox.
281    let mut failing = std::collections::HashSet::<String>::new();
282    while let Ok(request) = rx.recv() {
283        match request {
284            ReadRequest::QuerySnapshot {
285                sql,
286                params,
287                coverage,
288                owner,
289                reply,
290            } => {
291                let result = reader.query_snapshot(&sql, &params, &coverage);
292                let value = match &result {
293                    Ok(snapshot) => json!({ "result": snapshot }),
294                    Err(failure) => json!({
295                        "error": {
296                            "code": failure.code.unwrap_or("client.failed"),
297                            "message": failure.message,
298                            "details": failure.details(),
299                        }
300                    }),
301                };
302                if let Some((id, tables)) = owner {
303                    let failure = result.err();
304                    let report = if failure.is_some() {
305                        failing.insert(id.clone());
306                        true
307                    } else {
308                        failing.remove(&id)
309                    };
310                    if report {
311                        let _ = owner_tx.send(Request::QueryRead {
312                            id,
313                            tables,
314                            failure,
315                        });
316                    }
317                }
318                let _ = reply.send(value);
319            }
320            ReadRequest::Shutdown => return,
321        }
322    }
323}
324
325/// The owning thread: builds the core, then loops over the mailbox and the
326/// background host policy. `emit` pushes drained events onto the Tauri channel.
327fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
328where
329    F: Fn(&Value) + Send + Sync + 'static,
330{
331    let emit = std::sync::Arc::new(emit);
332    let transport_json = config.to_transport_json();
333    let wake_tx = tx.clone();
334    let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
335        let _ = wake_tx.send(Request::TransportWake);
336    });
337    let mut core = match SyncularCore::new_with_notify(&transport_json, Some(notify)) {
338        Ok(core) => core,
339        Err(message) => {
340            // A construction failure is terminal for this instance; surface it
341            // once on the channel so the webview can show it, then stop.
342            emit(&json!({ "type": "error", "message": message }));
343            return;
344        }
345    };
346    let progress_emit = emit.clone();
347    core.progress_listener = Some(std::sync::Arc::new(move |progress| {
348        progress_emit(&json!({ "type": "progress", "progress": progress }));
349    }));
350
351    // No idle poll: commands/realtime wake the mailbox, while a retryable
352    // transport failure contributes one real monotonic deadline.
353    let mut background_deadline: Option<Instant> = None;
354    loop {
355        if config.auto_sync {
356            match core.take_sync_intent() {
357                syncular_client::SyncIntent::Interactive => {
358                    background_deadline = None;
359                    core.sync_until_idle();
360                    pump_events(&mut core, &*emit);
361                    continue;
362                }
363                syncular_client::SyncIntent::Background { delay_ms } => {
364                    let candidate = Instant::now()
365                        .checked_add(Duration::from_millis(delay_ms))
366                        .unwrap_or_else(Instant::now);
367                    background_deadline = Some(
368                        background_deadline.map_or(candidate, |current| current.min(candidate)),
369                    );
370                }
371                syncular_client::SyncIntent::None => {}
372            }
373        }
374
375        let request = if let Some(deadline) = background_deadline {
376            let now = Instant::now();
377            if deadline <= now {
378                background_deadline = None;
379                core.sync_until_idle();
380                pump_events(&mut core, &*emit);
381                continue;
382            }
383            match rx.recv_timeout(deadline.saturating_duration_since(now)) {
384                Ok(request) => request,
385                Err(RecvTimeoutError::Timeout) => {
386                    background_deadline = None;
387                    core.sync_until_idle();
388                    pump_events(&mut core, &*emit);
389                    continue;
390                }
391                Err(RecvTimeoutError::Disconnected) => {
392                    core.shutdown();
393                    return;
394                }
395            }
396        } else {
397            match rx.recv() {
398                Ok(request) => request,
399                Err(std::sync::mpsc::RecvError) => {
400                    core.shutdown();
401                    return;
402                }
403            }
404        };
405
406        match request {
407            Request::Command { command, reply } => {
408                let result = core.command(&command);
409                if result.get("error").is_none() {
410                    if let Some(headers) = activation_headers(&command) {
411                        // A successful `activateSecurity` may carry a fresh
412                        // header set; apply it here, before this loop consumes
413                        // the startup sync intent the activation enqueued, so
414                        // a preflight that outlived the boot token starts its
415                        // first round with valid credentials.
416                        core.set_headers(headers);
417                    }
418                }
419                let _ = reply.send(result);
420                pump_events(&mut core, &*emit);
421            }
422            Request::Query { sql, params, reply } => {
423                let result = core.query(&sql, params);
424                let _ = reply.send(result);
425                pump_events(&mut core, &*emit);
426            }
427            Request::SetHeaders { headers, reply } => {
428                core.set_headers(headers);
429                let _ = reply.send(json!({ "result": null }));
430            }
431            Request::TransportWake => {
432                core.poll_transport();
433                pump_events(&mut core, &*emit);
434            }
435            Request::QueryRead {
436                id,
437                tables,
438                failure,
439            } => {
440                core.record_query_read(&id, &tables, failure.as_ref());
441                pump_events(&mut core, &*emit);
442            }
443            #[cfg(test)]
444            Request::Block { duration, entered } => {
445                let _ = entered.send(());
446                std::thread::sleep(duration);
447            }
448            Request::Shutdown => {
449                core.shutdown();
450                return;
451            }
452        }
453    }
454}
455
456/// The database file a `create` opens: `<database_dir>/<database>.db` for a
457/// named database, otherwise the configured `db_path` (`None` → in-memory; a
458/// configuration with only `database_dir` requires the name). A
459/// database name is one file name of ASCII letters, digits, `-`, `_` and `.`,
460/// starting with a letter or digit, so it cannot leave the directory. The
461/// webview never supplies a path itself.
462fn resolve_database(config: &SyncularConfig, params: &Value) -> Result<Option<String>, Value> {
463    if params.get("dbPath").is_some() {
464        return Err(invalid_request(
465            "sync.invalid_request: create.dbPath is host configuration; name a database with create.database",
466        ));
467    }
468    let Some(database) = params.get("database") else {
469        if config.db_path.is_none() && config.database_dir.is_some() {
470            return Err(invalid_request(
471                "sync.invalid_request: create.database is required when SyncularConfig sets database_dir without db_path",
472            ));
473        }
474        return Ok(config.db_path.clone());
475    };
476    let Some(name) = database.as_str().filter(|name| {
477        (1..=128).contains(&name.len())
478            && name.starts_with(|c: char| c.is_ascii_alphanumeric())
479            && !name.contains("..")
480            && name
481                .chars()
482                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
483    }) else {
484        return Err(invalid_request(
485            "sync.invalid_request: create.database must be 1 to 128 ASCII letters, digits, '-', '_' or '.', start with a letter or digit, and contain no '..'",
486        ));
487    };
488    let Some(dir) = &config.database_dir else {
489        return Err(invalid_request(
490            "sync.invalid_request: create.database needs SyncularConfig.database_dir",
491        ));
492    };
493    std::fs::create_dir_all(dir)
494        .map_err(|error| client_error(format!("cannot create the database directory: {error}")))?;
495    Ok(Some(
496        std::path::Path::new(dir)
497            .join(format!("{name}.db"))
498            .to_string_lossy()
499            .into_owned(),
500    ))
501}
502
503fn invalid_request(message: &str) -> Value {
504    json!({ "error": { "code": "sync.invalid_request", "message": message } })
505}
506
507/// The header set an `activateSecurity` command carries (already validated by
508/// the shared command router; a shape failure surfaces as its error reply, so
509/// this extraction only sees well-formed sets on the success path).
510fn activation_headers(command: &Value) -> Option<Vec<(String, String)>> {
511    if command.get("method").and_then(Value::as_str) != Some("activateSecurity") {
512        return None;
513    }
514    let headers = command.pointer("/params/headers")?;
515    syncular_command::parse_headers(headers).ok()
516}
517
518fn client_error(message: impl Into<String>) -> Value {
519    json!({ "error": { "code": "client.failed", "message": message.into() } })
520}
521
522fn security_preflight_error() -> Value {
523    json!({
524        "error": {
525            "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
526            "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
527        }
528    })
529}
530
531fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
532    let object = value
533        .and_then(Value::as_object)
534        .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
535    let table = object
536        .get("table")
537        .and_then(Value::as_str)
538        .ok_or_else(|| "window base missing table".to_owned())?
539        .to_owned();
540    let variable = object
541        .get("variable")
542        .and_then(Value::as_str)
543        .ok_or_else(|| "window base missing variable".to_owned())?
544        .to_owned();
545    let fixed_scopes = match object.get("fixedScopes") {
546        Some(value) => syncular_client::values::json_to_scope_map(value)?,
547        None => Vec::new(),
548    };
549    let params = object
550        .get("params")
551        .and_then(Value::as_str)
552        .map(str::to_owned);
553    Ok(WindowBase {
554        table,
555        variable,
556        fixed_scopes,
557        params,
558    })
559}
560
561fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
562    let Some(value) = value else {
563        return Ok(Vec::new());
564    };
565    if value.is_null() {
566        return Ok(Vec::new());
567    }
568    let entries = value
569        .as_array()
570        .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
571    entries
572        .iter()
573        .map(|entry| {
574            let units = entry
575                .get("units")
576                .and_then(Value::as_array)
577                .map(|values| {
578                    values
579                        .iter()
580                        .filter_map(|value| value.as_str().map(str::to_owned))
581                        .collect()
582                })
583                .unwrap_or_default();
584            Ok(WindowCoverage {
585                base: parse_window_base(entry.get("base"))?,
586                units,
587            })
588        })
589        .collect()
590}
591
592fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
593    for event in core.drain_events() {
594        emit(&event.json);
595    }
596}
597
598// -- Tauri commands (the thin shell) -----------------------------------------
599
600#[tauri::command]
601async fn syncular_command<R: Runtime>(
602    app: tauri::AppHandle<R>,
603    command: Value,
604) -> Result<Value, String> {
605    let state = app.state::<SyncularState>();
606    let method = command
607        .get("method")
608        .and_then(Value::as_str)
609        .unwrap_or("")
610        .to_owned();
611    if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
612        // Gate fast reads and wait for already-started sidecar snapshots before
613        // the owner-thread barrier is enqueued.
614        state.security_gate.begin_preflight()?;
615    }
616    // Taken after the gate drained, so no admitted read still waits for it.
617    let mut reader = if method == "create" || method == "shutdown" {
618        Some(
619            state
620                .reader
621                .lock()
622                .map_err(|_| "syncular read mailbox poisoned".to_owned())?,
623        )
624    } else {
625        None
626    };
627    let mut command = command;
628    let mut database = None;
629    if method == "create" {
630        database =
631            match resolve_database(&state.config, command.get("params").unwrap_or(&Value::Null)) {
632                Ok(path) => path,
633                Err(reply) => return Ok(reply),
634            };
635        if let Some(path) = &database {
636            match command.get_mut("params").and_then(Value::as_object_mut) {
637                Some(params) => {
638                    params.insert("dbPath".to_owned(), Value::from(path.clone()));
639                }
640                None => {
641                    if let Some(object) = command.as_object_mut() {
642                        object.insert("params".to_owned(), json!({ "dbPath": path }));
643                    }
644                }
645            }
646        }
647    }
648    let create_preflight = method == "create"
649        && command
650            .pointer("/params/securityPreflight")
651            .and_then(Value::as_bool)
652            .unwrap_or(false);
653    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
654    state.send(Request::Command {
655        command,
656        reply: reply_tx,
657    })?;
658    let reply = reply_rx
659        .recv()
660        .map_err(|_| "the syncular core dropped the reply".to_owned())?;
661    let succeeded = reply.get("error").is_none();
662    if let (true, Some(slot)) = (succeeded, reader.as_mut()) {
663        let next = match database.filter(|path| path.as_str() != ":memory:") {
664            Some(path) => Some(spawn_reader(
665                path,
666                state
667                    .sender
668                    .lock()
669                    .map_err(|_| "syncular mailbox poisoned".to_owned())?
670                    .clone(),
671            )?),
672            None => None,
673        };
674        if let Some(previous) = std::mem::replace(&mut **slot, next) {
675            let _ = previous.send(ReadRequest::Shutdown);
676        }
677    }
678    drop(reader);
679    if succeeded && method == "create" {
680        if !create_preflight {
681            state.security_gate.activate()?;
682        }
683    } else if succeeded && method == "activateSecurity" {
684        state.security_gate.activate()?;
685    }
686    Ok(reply)
687}
688
689/// Replace the native transport's request headers at runtime — the auth
690/// Header rotation path: a fresh JWT reaches the transport without
691/// re-registering the plugin. HTTP requests use the new set from the next
692/// call; the realtime socket applies it on its next (re)connect.
693#[tauri::command]
694async fn syncular_set_headers<R: Runtime>(
695    app: tauri::AppHandle<R>,
696    headers: std::collections::BTreeMap<String, String>,
697) -> Result<Value, String> {
698    let state = app.state::<SyncularState>();
699    // Runtime bearer replacement is an active-session operation. Hold the
700    // same gate as fast reads so beginSecurityPreflight both rejects new
701    // replacements and waits for an already-started mailbox update.
702    let _active_guard = match state.security_gate.enter_read() {
703        Ok(guard) => guard,
704        Err(reply) => return Ok(reply),
705    };
706    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
707    state.send(Request::SetHeaders {
708        headers: headers.into_iter().collect(),
709        reply: reply_tx,
710    })?;
711    reply_rx
712        .recv()
713        .map_err(|_| "the syncular core dropped the reply".to_owned())
714}
715
716#[tauri::command]
717async fn syncular_query<R: Runtime>(
718    app: tauri::AppHandle<R>,
719    sql: String,
720    params: Option<Value>,
721) -> Result<Value, String> {
722    let state = app.state::<SyncularState>();
723    let _read_guard = match state.security_gate.enter_read() {
724        Ok(guard) => guard,
725        Err(reply) => return Ok(reply),
726    };
727    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
728    state.send(Request::Query {
729        sql,
730        params: params.unwrap_or(Value::Null),
731        reply: reply_tx,
732    })?;
733    reply_rx
734        .recv()
735        .map_err(|_| "the syncular core dropped the reply".to_owned())
736}
737
738/// Atomic rows + revision + window coverage on the independent read-only
739/// connection. In-memory configurations fall back to the core owner because
740/// SQLite cannot share an anonymous database across connections.
741#[tauri::command]
742async fn syncular_query_snapshot<R: Runtime>(
743    app: tauri::AppHandle<R>,
744    sql: String,
745    params: Option<Value>,
746    coverage: Option<Value>,
747    owner: Option<Value>,
748) -> Result<Value, String> {
749    let state = app.state::<SyncularState>();
750    let _read_guard = match state.security_gate.enter_read() {
751        Ok(guard) => guard,
752        Err(reply) => return Ok(reply),
753    };
754    let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
755    let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
756
757    if let Some(reader) = state.reader()? {
758        let bind = match params_value.as_array() {
759            Some(values) => values.clone(),
760            None => return Ok(client_error("querySnapshot params must be a list")),
761        };
762        let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
763            Ok(value) => value,
764            Err(message) => return Ok(client_error(message)),
765        };
766        // §7.5 owner: the same `{id, tables}` contract the command router parses.
767        let parsed_owner = match &owner {
768            None | Some(Value::Null) => None,
769            Some(value) => {
770                let id = value
771                    .get("id")
772                    .and_then(Value::as_str)
773                    .filter(|id| !id.is_empty());
774                let tables = value
775                    .get("tables")
776                    .and_then(Value::as_array)
777                    .and_then(|list| {
778                        list.iter()
779                            .map(|table| table.as_str().map(str::to_owned))
780                            .collect::<Option<Vec<String>>>()
781                    });
782                match (id, tables) {
783                    (Some(id), Some(tables)) => Some((id.to_owned(), tables)),
784                    _ => {
785                        return Ok(json!({ "error": {
786                            "code": "sync.invalid_request",
787                            "message": "sync.invalid_request: querySnapshot owner must be {id: non-empty string, tables: string[]}",
788                        } }))
789                    }
790                }
791            }
792        };
793        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
794        reader
795            .send(ReadRequest::QuerySnapshot {
796                sql,
797                params: bind,
798                coverage: parsed_coverage,
799                owner: parsed_owner,
800                reply: reply_tx,
801            })
802            .map_err(|_| "the syncular read thread has stopped".to_owned())?;
803        return reply_rx
804            .recv()
805            .map_err(|_| "the syncular read thread dropped the reply".to_owned());
806    }
807
808    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
809    state.send(Request::Command {
810        command: json!({
811            "method": "querySnapshot",
812            "params": {
813                "sql": sql,
814                "params": params_value,
815                "coverage": coverage_value,
816                "owner": owner.unwrap_or(Value::Null),
817            }
818        }),
819        reply: reply_tx,
820    })?;
821    reply_rx
822        .recv()
823        .map_err(|_| "the syncular core dropped the reply".to_owned())
824}
825
826/// Initialize the plugin with a config. Register with
827/// `tauri::Builder::default().plugin(tauri_plugin_syncular::init(config))`.
828///
829/// The owning thread is spawned in `setup`; it builds the core (native
830/// transport if `base_url` + the `native-transport` feature), pumps events onto
831/// [`EVENT_NAME`], and runs the §8.4 host loop. The mailbox `Sender` is managed
832/// as plugin state and torn down on `RunEvent::Exit`.
833pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
834    Builder::<R>::new("syncular")
835        .invoke_handler(tauri::generate_handler![
836            syncular_command,
837            syncular_query,
838            syncular_query_snapshot,
839            syncular_set_headers
840        ])
841        .setup(move |app, _api| {
842            let (tx, rx) = std::sync::mpsc::channel::<Request>();
843            app.manage(SyncularState {
844                sender: Mutex::new(tx.clone()),
845                reader: Mutex::new(None),
846                config: config.clone(),
847                // Fail closed until the first successful `create` declares
848                // whether this process starts active or in preflight.
849                security_gate: SecurityGate::new_preflight(),
850            });
851            let app_handle = app.clone();
852            let emit = move |value: &Value| {
853                // Best-effort: a webview that has gone away must not crash the
854                // owning thread. Emit to all windows on the syncular channel.
855                let _ = app_handle.emit(EVENT_NAME, value.clone());
856            };
857            std::thread::Builder::new()
858                .name("syncular-core".to_owned())
859                .spawn(move || run_owner_thread(config, tx, rx, emit))
860                .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
861            Ok(())
862        })
863        .on_event(|app, event| {
864            if let RunEvent::Exit = event {
865                if let Some(state) = app.try_state::<SyncularState>() {
866                    let _ = state.send(Request::Shutdown);
867                    if let Ok(Some(reader)) = state.reader() {
868                        let _ = reader.send(ReadRequest::Shutdown);
869                    }
870                }
871            }
872        })
873        .build()
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879
880    #[test]
881    fn config_to_transport_json_shapes_fields() {
882        let config = SyncularConfig {
883            base_url: Some("https://api.example.com".to_owned()),
884            headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
885            ..Default::default()
886        };
887        let json = config.to_transport_json();
888        assert_eq!(json["baseUrl"], "https://api.example.com");
889        assert_eq!(json["headers"]["authorization"], "Bearer x");
890    }
891
892    #[test]
893    fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
894        let gate = std::sync::Arc::new(SecurityGate::new_preflight());
895        gate.activate().expect("activate test gate");
896        let read = gate.enter_read().expect("active read");
897        let gate_for_barrier = std::sync::Arc::clone(&gate);
898        let (done_tx, done_rx) = std::sync::mpsc::channel();
899        let barrier = std::thread::spawn(move || {
900            gate_for_barrier.begin_preflight().expect("enter preflight");
901            done_tx.send(()).expect("barrier reply");
902        });
903
904        assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
905        drop(read);
906        done_rx
907            .recv_timeout(Duration::from_secs(1))
908            .expect("barrier drains after the read");
909        barrier.join().expect("barrier thread");
910        assert!(gate.enter_read().is_err());
911    }
912
913    #[test]
914    fn direct_set_headers_command_respects_the_native_preflight_gate() {
915        use std::collections::BTreeMap;
916        use tauri::test::{mock_builder, mock_context, noop_assets};
917
918        let app = mock_builder()
919            .plugin(init(SyncularConfig {
920                auto_sync: false,
921                ..Default::default()
922            }))
923            .build(mock_context(noop_assets()))
924            .expect("build mock app");
925
926        let blocked = tauri::async_runtime::block_on(syncular_set_headers(
927            app.handle().clone(),
928            BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
929        ))
930        .expect("preflight reply");
931        assert_eq!(
932            blocked["error"]["code"],
933            Value::from("client.security_preflight_required")
934        );
935
936        let created = tauri::async_runtime::block_on(syncular_command(
937            app.handle().clone(),
938            json!({
939                "method": "create",
940                "params": {
941                    "clientId": "native-header-gate",
942                    "schema": { "version": 1, "tables": [] }
943                }
944            }),
945        ))
946        .expect("create reply");
947        assert!(created.get("error").is_none(), "{created}");
948
949        let active = tauri::async_runtime::block_on(syncular_set_headers(
950            app.handle().clone(),
951            BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
952        ))
953        .expect("active reply");
954        assert_eq!(active["result"], Value::Null);
955    }
956
957    #[test]
958    fn activation_headers_extracts_only_the_activation_set() {
959        assert_eq!(
960            activation_headers(&json!({
961                "method": "activateSecurity",
962                "params": { "headers": { "authorization": "Bearer fresh" } }
963            })),
964            Some(vec![(
965                "authorization".to_owned(),
966                "Bearer fresh".to_owned()
967            )])
968        );
969        // Absent headers, other methods, and invalid shapes all yield nothing.
970        assert_eq!(
971            activation_headers(&json!({ "method": "activateSecurity", "params": {} })),
972            None
973        );
974        assert_eq!(
975            activation_headers(&json!({
976                "method": "setWindow",
977                "params": { "headers": { "authorization": "Bearer fresh" } }
978            })),
979            None
980        );
981        assert_eq!(
982            activation_headers(&json!({
983                "method": "activateSecurity",
984                "params": { "headers": { "authorization": 7 } }
985            })),
986            None
987        );
988    }
989
990    #[test]
991    fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
992        use tauri::test::{mock_builder, mock_context, noop_assets};
993
994        let app = mock_builder()
995            .plugin(init(SyncularConfig {
996                auto_sync: false,
997                ..Default::default()
998            }))
999            .build(mock_context(noop_assets()))
1000            .expect("build mock app");
1001        let command = |value: Value| {
1002            tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1003                .expect("command reply")
1004        };
1005
1006        let created = command(json!({
1007            "method": "create",
1008            "params": {
1009                "clientId": "native-preflight-escape",
1010                "schema": { "version": 1, "tables": [] },
1011                "securityPreflight": true
1012            }
1013        }));
1014        assert!(created.get("error").is_none(), "{created}");
1015
1016        // The escape attempt: a plain re-create must be refused by the shared
1017        // router, and the plugin's fast-read gate must stay engaged.
1018        let escape = command(json!({
1019            "method": "create",
1020            "params": {
1021                "clientId": "native-preflight-escape",
1022                "schema": { "version": 1, "tables": [] }
1023            }
1024        }));
1025        assert_eq!(
1026            escape["error"]["code"],
1027            Value::from("client.security_preflight_required"),
1028            "{escape}"
1029        );
1030        let read = tauri::async_runtime::block_on(syncular_query(
1031            app.handle().clone(),
1032            "SELECT 1".to_owned(),
1033            None,
1034        ))
1035        .expect("query reply");
1036        assert_eq!(
1037            read["error"]["code"],
1038            Value::from("client.security_preflight_required")
1039        );
1040
1041        // Activation (with a fresh header set) releases both gates.
1042        let activated = command(json!({
1043            "method": "activateSecurity",
1044            "params": { "headers": { "authorization": "Bearer fresh" } }
1045        }));
1046        assert!(activated.get("error").is_none(), "{activated}");
1047        let read = tauri::async_runtime::block_on(syncular_query(
1048            app.handle().clone(),
1049            "SELECT 1 AS value".to_owned(),
1050            None,
1051        ))
1052        .expect("query reply");
1053        assert_eq!(read["result"]["rows"][0]["value"], 1);
1054        let recreated = command(json!({
1055            "method": "create",
1056            "params": {
1057                "clientId": "native-preflight-escape",
1058                "schema": { "version": 1, "tables": [] }
1059            }
1060        }));
1061        assert!(recreated.get("error").is_none(), "{recreated}");
1062    }
1063
1064    #[test]
1065    fn resolve_database_names_a_file_inside_the_configured_directory() {
1066        let dir =
1067            std::env::temp_dir().join(format!("syncular-tauri-resolve-{}", std::process::id()));
1068        let config = SyncularConfig {
1069            db_path: Some("/tmp/default.db".to_owned()),
1070            database_dir: Some(dir.to_string_lossy().into_owned()),
1071            ..Default::default()
1072        };
1073        // No name → the configured default path.
1074        assert_eq!(
1075            resolve_database(&config, &json!({ "clientId": "c1" })),
1076            Ok(Some("/tmp/default.db".to_owned()))
1077        );
1078        // A name → `<database_dir>/<name>.db`, directory created.
1079        assert_eq!(
1080            resolve_database(&config, &json!({ "database": "app-actor-0f.v2" })),
1081            Ok(Some(
1082                dir.join("app-actor-0f.v2.db")
1083                    .to_string_lossy()
1084                    .into_owned()
1085            ))
1086        );
1087        assert!(dir.is_dir());
1088        // A webview-supplied path and every name that could leave the
1089        // directory are refused.
1090        for params in [
1091            json!({ "dbPath": "/etc/other.db" }),
1092            json!({ "database": "../escape" }),
1093            json!({ "database": "a/b" }),
1094            json!({ "database": "a\\b" }),
1095            json!({ "database": "/abs" }),
1096            json!({ "database": ".hidden" }),
1097            json!({ "database": "a..b" }),
1098            json!({ "database": "" }),
1099            json!({ "database": "x".repeat(129) }),
1100            json!({ "database": 7 }),
1101        ] {
1102            let refused = resolve_database(&config, &params).expect_err("refused");
1103            assert_eq!(refused["error"]["code"], "sync.invalid_request", "{params}");
1104        }
1105        // A configuration with only a directory never opens an unnamed
1106        // create in memory.
1107        let refused = resolve_database(
1108            &SyncularConfig {
1109                database_dir: Some(dir.to_string_lossy().into_owned()),
1110                ..Default::default()
1111            },
1112            &json!({ "clientId": "c1" }),
1113        )
1114        .expect_err("name required");
1115        assert_eq!(refused["error"]["code"], "sync.invalid_request");
1116        // A name without a configured directory is refused, never placed
1117        // somewhere else.
1118        let refused = resolve_database(&SyncularConfig::default(), &json!({ "database": "actor" }))
1119            .expect_err("no directory");
1120        assert_eq!(refused["error"]["code"], "sync.invalid_request");
1121        std::fs::remove_dir_all(dir).expect("remove temp directory");
1122    }
1123
1124    /// One replica per actor: each `create` opens its named database, keeps
1125    /// its own client id, and the snapshot reader follows the open database.
1126    #[test]
1127    fn named_databases_keep_one_replica_and_client_id_per_actor() {
1128        use tauri::test::{mock_builder, mock_context, noop_assets};
1129
1130        let dir =
1131            std::env::temp_dir().join(format!("syncular-tauri-actors-{}", std::process::id()));
1132        let _ = std::fs::remove_dir_all(&dir);
1133        let app = mock_builder()
1134            .plugin(init(SyncularConfig {
1135                database_dir: Some(dir.to_string_lossy().into_owned()),
1136                auto_sync: false,
1137                ..Default::default()
1138            }))
1139            .build(mock_context(noop_assets()))
1140            .expect("build mock app");
1141        let command = |value: Value| {
1142            tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1143                .expect("command reply")
1144        };
1145        let snapshot = |sql: &str| {
1146            tauri::async_runtime::block_on(syncular_query_snapshot(
1147                app.handle().clone(),
1148                sql.to_owned(),
1149                None,
1150                None,
1151                None,
1152            ))
1153            .expect("snapshot reply")
1154        };
1155        let schema = json!({
1156            "version": 1,
1157            "tables": [{
1158                "name": "todo", "primaryKey": "id",
1159                "columns": [
1160                    { "name": "id", "type": "string", "nullable": false },
1161                    { "name": "title", "type": "string", "nullable": false }
1162                ],
1163                "scopes": []
1164            }]
1165        });
1166        let create = |database: &str, client_id: &str| {
1167            command(json!({ "method": "create", "params": {
1168                "database": database, "clientId": client_id, "schema": schema
1169            } }))
1170        };
1171
1172        assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1173        command(json!({ "method": "mutate", "params": { "mutations": [{
1174            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "a" }
1175        }] } }));
1176        assert_eq!(
1177            snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1178            "a"
1179        );
1180        assert_eq!(
1181            command(json!({ "method": "shutdown" }))["result"],
1182            json!({})
1183        );
1184
1185        assert_eq!(create("actor-b", "client-b")["result"], json!({}));
1186        assert_eq!(
1187            snapshot("SELECT title FROM todo")["result"]["rows"],
1188            json!([]),
1189            "the reader follows the second actor's database"
1190        );
1191        assert!(dir.join("actor-a.db").is_file());
1192        assert!(dir.join("actor-b.db").is_file());
1193        assert_eq!(
1194            command(json!({ "method": "shutdown" }))["result"],
1195            json!({})
1196        );
1197
1198        // Each replica keeps the client id it was created with.
1199        assert_eq!(
1200            create("actor-a", "client-b")["error"]["code"],
1201            "client.identity_mismatch"
1202        );
1203        assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1204        assert_eq!(
1205            snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1206            "a"
1207        );
1208        assert_eq!(
1209            create("../actor-a", "client-a")["error"]["code"],
1210            "sync.invalid_request"
1211        );
1212        command(json!({ "method": "shutdown" }));
1213        std::fs::remove_dir_all(dir).expect("remove temp directory");
1214    }
1215
1216    #[test]
1217    fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
1218        let parsed = parse_coverage(Some(&json!([{
1219            "base": {
1220                "table": "tasks",
1221                "variable": "project_id",
1222                "fixedScopes": { "tenant_id": ["one", "two"] },
1223                "params": "opaque"
1224            },
1225            "units": ["a", "b"]
1226        }])))
1227        .expect("parse coverage");
1228        assert_eq!(parsed.len(), 1);
1229        let entry = &parsed[0];
1230        assert_eq!(entry.base.table, "tasks");
1231        assert_eq!(entry.base.variable, "project_id");
1232        assert_eq!(
1233            entry.base.fixed_scopes,
1234            vec![(
1235                "tenant_id".to_owned(),
1236                vec!["one".to_owned(), "two".to_owned()]
1237            )]
1238        );
1239        assert_eq!(entry.base.params.as_deref(), Some("opaque"));
1240        assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
1241    }
1242
1243    /// The owner-thread mailbox loop end-to-end, without any Tauri window: post
1244    /// commands, collect emitted events. This is the real host path — the Tauri
1245    /// commands are a two-line channel forward over exactly this.
1246    #[test]
1247    fn owner_thread_round_trips_over_mailbox() {
1248        use std::sync::mpsc::channel;
1249        use std::sync::{Arc, Mutex as StdMutex};
1250
1251        let (tx, rx) = channel::<Request>();
1252        let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
1253        let events_for_thread = Arc::clone(&events);
1254        let config = SyncularConfig {
1255            auto_sync: false,
1256            ..Default::default()
1257        };
1258        let owner_tx = tx.clone();
1259        let handle = std::thread::spawn(move || {
1260            run_owner_thread(config, owner_tx, rx, move |v| {
1261                events_for_thread.lock().unwrap().push(v.clone());
1262            });
1263        });
1264
1265        let call = |command: Value| -> Value {
1266            let (rtx, rrx) = channel();
1267            tx.send(Request::Command {
1268                command,
1269                reply: rtx,
1270            })
1271            .unwrap();
1272            rrx.recv().unwrap()
1273        };
1274
1275        let schema = json!({
1276            "version": 1,
1277            "tables": [{
1278                "name": "todo", "primaryKey": "id",
1279                "columns": [
1280                    { "name": "id", "type": "string", "nullable": false },
1281                    { "name": "title", "type": "string", "nullable": false }
1282                ],
1283                "scopes": []
1284            }]
1285        });
1286        assert_eq!(
1287            call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1288                ["result"],
1289            json!({})
1290        );
1291        call(json!({ "method": "mutate", "params": { "mutations": [{
1292            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1293        }] } }));
1294
1295        // A query over the mailbox.
1296        let (qtx, qrx) = channel();
1297        tx.send(Request::Query {
1298            sql: "SELECT title FROM todo".to_owned(),
1299            params: Value::Null,
1300            reply: qtx,
1301        })
1302        .unwrap();
1303        let rows = qrx.recv().unwrap();
1304        assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1305
1306        // Header rotation rides the same mailbox; a
1307        // client-local (Null-transport) core accepts and ignores the set.
1308        let (htx, hrx) = channel();
1309        tx.send(Request::SetHeaders {
1310            headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1311            reply: htx,
1312        })
1313        .unwrap();
1314        assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1315
1316        tx.send(Request::Shutdown).unwrap();
1317        handle.join().unwrap();
1318
1319        let seen = events.lock().unwrap();
1320        let kinds: Vec<String> = seen
1321            .iter()
1322            .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1323            .collect();
1324        // The local mutate emits the exact revisioned batch onto the channel.
1325        assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1326    }
1327
1328    #[test]
1329    fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1330        use std::sync::mpsc::channel;
1331
1332        let path =
1333            std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1334        let config = SyncularConfig {
1335            db_path: Some(path.to_string_lossy().into_owned()),
1336            auto_sync: false,
1337            ..Default::default()
1338        };
1339        let (tx, rx) = channel::<Request>();
1340        let owner_tx = tx.clone();
1341        let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1342
1343        let (create_tx, create_rx) = channel();
1344        tx.send(Request::Command {
1345            command: json!({
1346                "method": "create",
1347                "params": {
1348                    "clientId": "sidecar-client",
1349                    "schema": { "version": 1, "tables": [] },
1350                    "dbPath": path.to_string_lossy()
1351                }
1352            }),
1353            reply: create_tx,
1354        })
1355        .expect("post create");
1356        assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1357
1358        let (read_tx, read_rx) = channel::<ReadRequest>();
1359        let read_path = path.to_string_lossy().into_owned();
1360        let owner_tx = tx.clone();
1361        let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
1362
1363        // Model a slow HTTP/WS round on the mutable owner. The dedicated read
1364        // mailbox must still return the durable local snapshot immediately.
1365        let (entered_tx, entered_rx) = channel();
1366        tx.send(Request::Block {
1367            duration: Duration::from_millis(200),
1368            entered: entered_tx,
1369        })
1370        .expect("block owner");
1371        entered_rx.recv().expect("owner entered blocking round");
1372        let (snapshot_tx, snapshot_rx) = channel();
1373        read_tx
1374            .send(ReadRequest::QuerySnapshot {
1375                sql: "SELECT 1 AS value".to_owned(),
1376                params: Vec::new(),
1377                coverage: Vec::new(),
1378                owner: None,
1379                reply: snapshot_tx,
1380            })
1381            .expect("post snapshot");
1382        let snapshot = snapshot_rx
1383            .recv_timeout(Duration::from_millis(50))
1384            .expect("local snapshot must not wait for the owner");
1385        assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
1386
1387        let (failed_tx, failed_rx) = channel();
1388        read_tx
1389            .send(ReadRequest::QuerySnapshot {
1390                sql: "SELECT private_value FROM missing_private_table".to_owned(),
1391                params: Vec::new(),
1392                coverage: Vec::new(),
1393                owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1394                reply: failed_tx,
1395            })
1396            .expect("post failed snapshot");
1397        assert_eq!(
1398            failed_rx.recv().expect("failed snapshot reply")["error"]["code"],
1399            "client.failed"
1400        );
1401        let (diagnostics_tx, diagnostics_rx) = channel();
1402        tx.send(Request::Command {
1403            command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1404            reply: diagnostics_tx,
1405        })
1406        .expect("post diagnostics");
1407        assert_eq!(
1408            diagnostics_rx.recv().expect("diagnostics reply")["result"]["queryFailures"][0]["id"],
1409            "queries:missing"
1410        );
1411
1412        let (recovered_tx, recovered_rx) = channel();
1413        read_tx
1414            .send(ReadRequest::QuerySnapshot {
1415                sql: "SELECT 1 AS value".to_owned(),
1416                params: Vec::new(),
1417                coverage: Vec::new(),
1418                owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1419                reply: recovered_tx,
1420            })
1421            .expect("post recovered snapshot");
1422        assert!(recovered_rx.recv().expect("recovered snapshot reply")["result"].is_object());
1423        let (cleared_tx, cleared_rx) = channel();
1424        tx.send(Request::Command {
1425            command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1426            reply: cleared_tx,
1427        })
1428        .expect("post cleared diagnostics");
1429        assert_eq!(
1430            cleared_rx.recv().expect("cleared diagnostics reply")["result"]["queryFailures"],
1431            json!([])
1432        );
1433
1434        read_tx.send(ReadRequest::Shutdown).expect("stop reader");
1435        reader.join().expect("join reader");
1436        tx.send(Request::Shutdown).expect("stop owner");
1437        owner.join().expect("join owner");
1438        std::fs::remove_file(path).expect("remove temp database");
1439    }
1440}