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