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