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