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::collections::BTreeSet;
37use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
38use std::sync::{Condvar, Mutex};
39use std::time::{Duration, Instant};
40
41use serde_json::{json, Value};
42use tauri::plugin::{Builder, TauriPlugin};
43use tauri::{Emitter, Manager, RunEvent, Runtime};
44
45pub mod core;
46pub mod transport;
47
48use core::SyncularCore;
49use syncular_client::{
50    FileQuerySnapshotReader, QueryReadFailure, WindowBase, WindowCoverage,
51    MAX_DIAGNOSTIC_QUERY_FAILURES,
52};
53
54/// The Tauri event name carrying derived client-observable events.
55pub const EVENT_NAME: &str = "syncular://event";
56
57/// Plugin configuration. Passed to [`init`]; every field is optional except a
58/// caller almost always wants a `base_url` (for real network sync) and a
59/// `database_dir` or `db_path` (for persistence — defaults to an in-memory core
60/// if both are absent).
61#[derive(Debug, Clone)]
62pub struct SyncularConfig {
63    /// Server base URL for the native HTTP+WS transport (needs the
64    /// `native-transport` feature). Absent → client-local only.
65    pub base_url: Option<String>,
66    /// Optional realtime WS URL; derived from `base_url` when absent.
67    pub ws_url: Option<String>,
68    /// Extra request headers (auth, actor/project ids) as (name, value).
69    pub headers: Vec<(String, String)>,
70    /// Per-request network deadline in milliseconds. `None` (the default)
71    /// leaves requests unbounded, matching the reference HTTP binding.
72    pub request_timeout_ms: Option<u64>,
73    /// One monotonic deadline for a whole sync round (uploads, continuations,
74    /// main request, and every segment fetch) in milliseconds. `None` (the
75    /// default) leaves the round unbounded.
76    pub round_deadline_ms: Option<u64>,
77    /// Largest request body the transport will send, in bytes. `None` (the
78    /// default) is unbounded.
79    pub max_request_bytes: Option<u64>,
80    /// Largest decoded (post-decompression) response body, in bytes. `None`
81    /// (the default) is unbounded.
82    pub max_response_bytes: Option<u64>,
83    /// Redirect handling: `"deny"` (the default) refuses every redirect so
84    /// configured credentials, URL userinfo, and signed capabilities never
85    /// replay to another origin; `"follow"` follows a redirect only for a
86    /// request with none of those, and still refuses a credential-bearing
87    /// request.
88    pub redirects: Option<String>,
89    /// On-disk SQLite path a `create` opens when it names no database. Absent →
90    /// in-memory (nothing survives a restart). Apps usually set this to a file
91    /// under the app-data dir; see [`init`].
92    pub db_path: Option<String>,
93    /// Directory of the named databases. A `create` that carries `database`
94    /// (for example one replica per signed-in actor) opens
95    /// `<database_dir>/<database>.db`; the plugin creates the directory. The
96    /// webview names a database and never supplies a path.
97    pub database_dir: Option<String>,
98    /// Run the background host loop (§8.4). Default true.
99    pub auto_sync: bool,
100    /// Native app-approved table/column ceiling. Webview create selects scopes
101    /// and a subset; raw IPC cannot expand this policy.
102    pub authority_columns: std::collections::BTreeMap<String, Vec<String>>,
103}
104
105impl Default for SyncularConfig {
106    fn default() -> Self {
107        Self {
108            base_url: None,
109            ws_url: None,
110            headers: Vec::new(),
111            request_timeout_ms: None,
112            round_deadline_ms: None,
113            max_request_bytes: None,
114            max_response_bytes: None,
115            redirects: None,
116            db_path: None,
117            database_dir: None,
118            auto_sync: true,
119            authority_columns: std::collections::BTreeMap::new(),
120        }
121    }
122}
123
124impl SyncularConfig {
125    /// Build the JSON config the core's transport reads.
126    fn to_transport_json(&self) -> Value {
127        let mut map = serde_json::Map::new();
128        if let Some(base) = &self.base_url {
129            map.insert("baseUrl".to_owned(), Value::from(base.clone()));
130        }
131        if let Some(ws) = &self.ws_url {
132            map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
133        }
134        if !self.headers.is_empty() {
135            let headers: serde_json::Map<String, Value> = self
136                .headers
137                .iter()
138                .map(|(k, v)| (k.clone(), Value::from(v.clone())))
139                .collect();
140            map.insert("headers".to_owned(), Value::Object(headers));
141        }
142        if let Some(ms) = self.request_timeout_ms {
143            map.insert("requestTimeoutMs".to_owned(), Value::from(ms));
144        }
145        if let Some(ms) = self.round_deadline_ms {
146            map.insert("roundDeadlineMs".to_owned(), Value::from(ms));
147        }
148        if let Some(bytes) = self.max_request_bytes {
149            map.insert("maxRequestBytes".to_owned(), Value::from(bytes));
150        }
151        if let Some(bytes) = self.max_response_bytes {
152            map.insert("maxResponseBytes".to_owned(), Value::from(bytes));
153        }
154        if let Some(redirects) = &self.redirects {
155            map.insert("redirects".to_owned(), Value::from(redirects.clone()));
156        }
157        Value::Object(map)
158    }
159}
160
161/// A request posted to the owning thread's mailbox. Each carries a one-shot
162/// reply channel; the Tauri command blocks on it (`spawn_blocking`-friendly).
163enum Request {
164    Command {
165        command: Value,
166        reply: Sender<Value>,
167    },
168    Query {
169        sql: String,
170        params: Value,
171        reply: Sender<Value>,
172    },
173    /// Replace the transport's request headers. Header state
174    /// lives on the core-owned transport, so mutation rides the same mailbox
175    /// as every other access — the one-owning-thread invariant holds.
176    SetHeaders {
177        headers: Vec<(String, String)>,
178        reply: Sender<Value>,
179    },
180    /// Native realtime reader wake; contains no data (the transport buffer does).
181    TransportWake,
182    RoundFinished(
183        Box<(
184            syncular_client::CompletedSyncRound,
185            crate::transport::HostTransport,
186        )>,
187    ),
188    /// §7.6: an owned read-sidecar failure, or the first success after one.
189    QueryRead {
190        id: String,
191        tables: Vec<String>,
192        failure: Option<QueryReadFailure>,
193    },
194    #[cfg(test)]
195    Block {
196        duration: Duration,
197        entered: Sender<()>,
198    },
199    Shutdown,
200}
201
202/// Latency-critical reads use a second, read-only SQLite connection. This
203/// mailbox is deliberately independent from [`Request`]: a network round on
204/// the mutable owner must never head-of-line-block a local UI snapshot.
205enum ReadRequest {
206    QuerySnapshot {
207        sql: String,
208        params: Vec<Value>,
209        coverage: Vec<WindowCoverage>,
210        owner: Option<(String, Vec<String>)>,
211        reply: Sender<Value>,
212    },
213    /// §7.5: one atomic read over the shared primitive, carrying every
214    /// requested statement, catch-up state, and delivery.
215    SnapshotRead {
216        statements: Vec<(String, Vec<Value>)>,
217        coverage: Vec<WindowCoverage>,
218        subscriptions: Vec<String>,
219        commit_ids: Vec<String>,
220        owner: Option<(String, Vec<String>)>,
221        reply: Sender<Value>,
222    },
223    /// Stop the reader thread.
224    Shutdown,
225}
226
227/// The `{error: {code, message, details?, retryable?}}` envelope the read
228/// sidecar returns. `retryable` mirrors the §7.5 write-failure classification
229/// so a `SQLITE_BUSY` read keeps `client.storage_busy` retryability.
230fn read_failure_json(failure: syncular_client::QueryReadFailure) -> Value {
231    json!({
232        "error": {
233            "code": failure.code.unwrap_or("client.failed"),
234            "message": failure.message,
235            "details": failure.details(),
236            "retryable": failure.retryable(),
237        }
238    })
239}
240
241/// The plugin's managed state: the mailbox sender the commands post to. Wrapped
242/// in a `Mutex` only to be `Sync` for Tauri state (the `Sender` is `Send`).
243struct SyncularState {
244    sender: Mutex<Sender<Request>>,
245    /// The snapshot reader of the file database the last successful `create`
246    /// opened; `None` before it, after `shutdown`, and for an in-memory client.
247    /// `create` and `shutdown` hold this lock until their reply, so the reader
248    /// always reads the database the owner has open.
249    reader: Mutex<Option<Sender<ReadRequest>>>,
250    config: SyncularConfig,
251    security_gate: SecurityGate,
252}
253
254struct SecurityGateState {
255    preflight: bool,
256    active_reads: usize,
257}
258
259struct SecurityGate {
260    state: Mutex<SecurityGateState>,
261    idle: Condvar,
262}
263
264struct SecurityReadGuard<'a> {
265    gate: &'a SecurityGate,
266}
267
268impl SecurityGate {
269    fn new_preflight() -> Self {
270        Self {
271            state: Mutex::new(SecurityGateState {
272                preflight: true,
273                active_reads: 0,
274            }),
275            idle: Condvar::new(),
276        }
277    }
278
279    fn begin_preflight(&self) -> Result<(), String> {
280        let mut state = self
281            .state
282            .lock()
283            .map_err(|_| "syncular security gate poisoned".to_owned())?;
284        state.preflight = true;
285        while state.active_reads > 0 {
286            state = self
287                .idle
288                .wait(state)
289                .map_err(|_| "syncular security gate poisoned".to_owned())?;
290        }
291        Ok(())
292    }
293
294    fn activate(&self) -> Result<(), String> {
295        let mut state = self
296            .state
297            .lock()
298            .map_err(|_| "syncular security gate poisoned".to_owned())?;
299        state.preflight = false;
300        Ok(())
301    }
302
303    fn enter_read(&self) -> Result<SecurityReadGuard<'_>, Value> {
304        let mut state = self
305            .state
306            .lock()
307            .map_err(|_| client_error("syncular security gate poisoned"))?;
308        if state.preflight {
309            return Err(security_preflight_error());
310        }
311        state.active_reads += 1;
312        Ok(SecurityReadGuard { gate: self })
313    }
314}
315
316impl Drop for SecurityReadGuard<'_> {
317    fn drop(&mut self) {
318        let Ok(mut state) = self.gate.state.lock() else {
319            return;
320        };
321        state.active_reads = state.active_reads.saturating_sub(1);
322        if state.active_reads == 0 {
323            self.gate.idle.notify_all();
324        }
325    }
326}
327
328impl SyncularState {
329    fn send(&self, request: Request) -> Result<(), String> {
330        self.sender
331            .lock()
332            .map_err(|_| "syncular mailbox poisoned".to_owned())?
333            .send(request)
334            .map_err(|_| "the syncular core thread has stopped".to_owned())
335    }
336
337    fn reader(&self) -> Result<Option<Sender<ReadRequest>>, String> {
338        Ok(self
339            .reader
340            .lock()
341            .map_err(|_| "syncular read mailbox poisoned".to_owned())?
342            .clone())
343    }
344}
345
346fn spawn_reader(path: String, owner_tx: Sender<Request>) -> Result<Sender<ReadRequest>, String> {
347    let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
348    std::thread::Builder::new()
349        .name("syncular-read".to_owned())
350        .spawn(move || run_reader_thread(path, reader_rx, owner_tx))
351        .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
352    Ok(reader_tx)
353}
354
355/// One owner the read sidecar has reported a failure for, carrying exactly the
356/// fields the owning core journals it under (`record_query_read` compares
357/// tables, code, and sqlite code before re-ordering an entry).
358struct ReportedQueryFailure {
359    id: String,
360    tables: Vec<String>,
361    code: &'static str,
362    sqlite_code: Option<i32>,
363}
364
365/// §7.6: report an owned read to the owner mailbox only on a failure
366/// transition: every failure, plus the first success that clears one, so a
367/// clean success loop enqueues nothing and the reader never waits for the owner
368/// to catch up on an earlier message. The mirror follows the core journal's
369/// retention: the tables are normalized the same way, an unchanged failure
370/// signature keeps its position, and passing `MAX_DIAGNOSTIC_QUERY_FAILURES`
371/// evicts the oldest id, so no owner id is kept without a bound.
372fn report_owned_read(
373    reported: &mut Vec<ReportedQueryFailure>,
374    owner_tx: &Sender<Request>,
375    id: String,
376    tables: Vec<String>,
377    failure: Option<QueryReadFailure>,
378) {
379    let position = reported.iter().position(|entry| entry.id == id);
380    let Some(failure) = failure else {
381        // Only the success that clears an outstanding failure is a transition.
382        let Some(index) = position else {
383            return;
384        };
385        reported.remove(index);
386        let _ = owner_tx.send(Request::QueryRead {
387            id,
388            tables,
389            failure: None,
390        });
391        return;
392    };
393    let code = failure.code.unwrap_or("client.query_failed");
394    let sqlite_code = failure.sqlite_code;
395    let signature: Vec<String> = BTreeSet::from_iter(tables.iter().cloned())
396        .into_iter()
397        .collect();
398    let unchanged = position.is_some_and(|index| {
399        let entry = &reported[index];
400        entry.tables == signature && entry.code == code && entry.sqlite_code == sqlite_code
401    });
402    if !unchanged {
403        if let Some(index) = position {
404            reported.remove(index);
405        }
406        reported.push(ReportedQueryFailure {
407            id: id.clone(),
408            tables: signature,
409            code,
410            sqlite_code,
411        });
412        if reported.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
413            reported.remove(0);
414        }
415    }
416    let _ = owner_tx.send(Request::QueryRead {
417        id,
418        tables,
419        failure: Some(failure),
420    });
421}
422
423fn run_reader_thread(path: String, rx: Receiver<ReadRequest>, owner_tx: Sender<Request>) {
424    let mut reader = FileQuerySnapshotReader::new(path);
425    // The read sidecar's own reports, bounded like the core journal.
426    let mut reported: Vec<ReportedQueryFailure> = Vec::new();
427    while let Ok(request) = rx.recv() {
428        match request {
429            ReadRequest::QuerySnapshot {
430                sql,
431                params,
432                coverage,
433                owner,
434                reply,
435            } => {
436                let result = reader.query_snapshot(&sql, &params, &coverage);
437                let value = match &result {
438                    Ok(snapshot) => json!({ "result": snapshot }),
439                    Err(failure) => read_failure_json(failure.clone()),
440                };
441                if let Some((id, tables)) = owner {
442                    report_owned_read(&mut reported, &owner_tx, id, tables, result.err());
443                }
444                let _ = reply.send(value);
445            }
446            ReadRequest::SnapshotRead {
447                statements,
448                coverage,
449                subscriptions,
450                commit_ids,
451                owner,
452                reply,
453            } => {
454                let request = syncular_client::SnapshotReadRequest {
455                    statements: statements
456                        .iter()
457                        .map(|(sql, params)| syncular_client::SnapshotStatement { sql, params })
458                        .collect(),
459                    coverage: &coverage,
460                    subscriptions,
461                    commit_ids,
462                };
463                let result = reader.snapshot_read(&request);
464                let value = match &result {
465                    Ok(read) => json!({ "result": read }),
466                    Err(failure) => read_failure_json(failure.clone()),
467                };
468                // §7.6: the same owner-failure bookkeeping the QuerySnapshot
469                // read uses, so a named owner gets one diagnostic path.
470                if let Some((id, tables)) = owner {
471                    report_owned_read(&mut reported, &owner_tx, id, tables, result.err());
472                }
473                let _ = reply.send(value);
474            }
475            ReadRequest::Shutdown => return,
476        }
477    }
478}
479
480/// The owning thread: builds the core, then loops over the mailbox and the
481/// background host policy. `emit` pushes drained events onto the Tauri channel.
482/// One serialized I/O executor per owner. It never owns a SQLite connection.
483enum NetworkWork {
484    Round(
485        Box<syncular_client::PreparedSyncRound>,
486        crate::transport::HostTransport,
487    ),
488    Controls(Vec<String>, crate::transport::HostTransport),
489}
490
491struct RoundRun {
492    reply: Option<Sender<Value>>,
493    until_idle: bool,
494    max_rounds: Option<u32>,
495    spent: u32,
496    aggregate: syncular_client::SyncReport,
497}
498
499fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
500where
501    F: Fn(&Value) + Send + Sync + 'static,
502{
503    let emit = std::sync::Arc::new(emit);
504    let wake_tx = tx.clone();
505    let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
506        let _ = wake_tx.send(Request::TransportWake);
507    });
508    let mut core = match SyncularCore::new_with_notify(&config.to_transport_json(), Some(notify)) {
509        Ok(core) => core,
510        Err(message) => {
511            emit(&json!({ "type": "error", "message": message }));
512            return;
513        }
514    };
515    let progress_emit = emit.clone();
516    core.progress_listener = Some(std::sync::Arc::new(move |progress| {
517        progress_emit(&json!({ "type": "progress", "progress": progress }));
518    }));
519    let (network_tx, network_rx) = std::sync::mpsc::channel();
520    let completed_tx = tx.clone();
521    let network = std::thread::spawn(move || {
522        use syncular_client::Transport;
523        while let Ok(work) = network_rx.recv() {
524            match work {
525                NetworkWork::Round(prepared, mut transport) => {
526                    let completed = (*prepared).exchange(&mut transport);
527                    if completed_tx
528                        .send(Request::RoundFinished(Box::new((completed, transport))))
529                        .is_err()
530                    {
531                        return;
532                    }
533                }
534                NetworkWork::Controls(controls, mut transport) => {
535                    for text in controls {
536                        let _ = transport.realtime_send(&text);
537                    }
538                }
539            }
540        }
541    });
542    let mut active: Option<RoundRun> = None;
543    let mut queued = std::collections::VecDeque::new();
544    let mut background_deadline: Option<Instant> = None;
545    loop {
546        if !core.transport_enabled() {
547            background_deadline = None;
548        }
549        if let Some((controls, transport)) = core.take_controls() {
550            if network_tx
551                .send(NetworkWork::Controls(controls, transport))
552                .is_err()
553            {
554                break;
555            }
556        }
557        // Consume sync intent only while idle. Mutations arriving mid-round
558        // retain their coalesced intent and enter the next captured request.
559        if active.is_none() {
560            if let Some(run) = queued.pop_front() {
561                active = Some(run);
562            } else if config.auto_sync {
563                match core.take_sync_intent() {
564                    syncular_client::SyncIntent::Interactive => {
565                        background_deadline = None;
566                        active = Some(RoundRun {
567                            reply: None,
568                            until_idle: true,
569                            max_rounds: None,
570                            spent: 0,
571                            aggregate: Default::default(),
572                        });
573                    }
574                    syncular_client::SyncIntent::Background { delay_ms } => {
575                        let candidate = Instant::now()
576                            .checked_add(Duration::from_millis(delay_ms))
577                            .unwrap_or_else(Instant::now);
578                        background_deadline = Some(
579                            background_deadline.map_or(candidate, |current| current.min(candidate)),
580                        );
581                    }
582                    syncular_client::SyncIntent::None => {}
583                }
584            }
585            if active.is_some() {
586                match core.prepare_round() {
587                    Ok((prepared, transport)) => {
588                        active.as_mut().expect("active run").spent += 1;
589                        if network_tx
590                            .send(NetworkWork::Round(Box::new(prepared), transport))
591                            .is_err()
592                        {
593                            break;
594                        }
595                    }
596                    Err(outcome) => {
597                        if let Some(reply) = active.take().and_then(|run| run.reply) {
598                            let _ = reply.send(json!({ "result": outcome.to_json() }));
599                        }
600                    }
601                }
602                pump_events(&mut core, &*emit);
603                if active.is_none() {
604                    continue;
605                }
606            }
607        }
608        let request = if active.is_none() {
609            if let Some(deadline) = background_deadline {
610                match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
611                    Ok(request) => request,
612                    Err(RecvTimeoutError::Timeout) => {
613                        background_deadline = None;
614                        queued.push_back(RoundRun {
615                            reply: None,
616                            until_idle: true,
617                            max_rounds: None,
618                            spent: 0,
619                            aggregate: Default::default(),
620                        });
621                        continue;
622                    }
623                    Err(RecvTimeoutError::Disconnected) => break,
624                }
625            } else {
626                match rx.recv() {
627                    Ok(request) => request,
628                    Err(_) => break,
629                }
630            }
631        } else {
632            match rx.recv() {
633                Ok(request) => request,
634                Err(_) => break,
635            }
636        };
637        match request {
638            Request::RoundFinished(completed) => {
639                let (completed, transport) = *completed;
640                let applied = core.apply_round(completed);
641                let (mut outcome, more, bootstrap_advanced) = match applied {
642                    syncular_client::AppliedSyncRound::Continue(prepared) => {
643                        if network_tx
644                            .send(NetworkWork::Round(Box::new(prepared), transport))
645                            .is_err()
646                        {
647                            break;
648                        }
649                        pump_events(&mut core, &*emit);
650                        continue;
651                    }
652                    syncular_client::AppliedSyncRound::Complete {
653                        outcome,
654                        more,
655                        bootstrap_advanced,
656                        ..
657                    } => (outcome, more, bootstrap_advanced),
658                };
659                let Some(mut run) = active.take() else {
660                    continue;
661                };
662                let mut keep_running = false;
663                if run.until_idle {
664                    if let syncular_client::SyncOutcome::Ok(report) = &outcome {
665                        run.aggregate.merge(report);
666                        if bootstrap_advanced && run.max_rounds.is_none() {
667                            run.spent = 0;
668                        }
669                        keep_running = more && core.transport_enabled();
670                        if keep_running && run.spent >= run.max_rounds.unwrap_or(20) {
671                            keep_running = false;
672                            outcome = syncular_client::SyncOutcome::BudgetExhausted(
673                                run.aggregate.clone(),
674                            );
675                        } else if !keep_running {
676                            outcome = syncular_client::SyncOutcome::Ok(run.aggregate.clone());
677                        }
678                    }
679                }
680                if keep_running {
681                    queued.push_front(run);
682                } else if let Some(reply) = run.reply {
683                    let _ = reply.send(json!({ "result": outcome.to_json() }));
684                }
685                pump_events(&mut core, &*emit);
686            }
687            Request::Command { command, reply } => {
688                let method = command.get("method").and_then(Value::as_str);
689                if matches!(method, Some("sync" | "syncUntilIdle")) && core.transport_enabled() {
690                    let until_idle = method == Some("syncUntilIdle");
691                    // The shared router ignores `maxRounds` for `sync`; only
692                    // `syncUntilIdle` carries a round budget.
693                    let max_rounds = if until_idle {
694                        match command.pointer("/params/maxRounds") {
695                            None | Some(Value::Null) => None,
696                            Some(value) => match value
697                                .as_u64()
698                                .and_then(|n| u32::try_from(n).ok())
699                                .filter(|n| *n >= 1)
700                            {
701                                Some(n) => Some(n),
702                                None => {
703                                    let _ = reply.send(json!({
704                                        "error": {
705                                            "code": "sync.invalid_request",
706                                            "message": "maxRounds must be a positive integer",
707                                            "retryable": false
708                                        }
709                                    }));
710                                    continue;
711                                }
712                            },
713                        }
714                    } else {
715                        None
716                    };
717                    queued.push_back(RoundRun {
718                        reply: Some(reply),
719                        until_idle,
720                        max_rounds,
721                        spent: 0,
722                        aggregate: Default::default(),
723                    });
724                    continue;
725                }
726                let result = core.command(&command);
727                let _ = reply.send(result);
728                pump_events(&mut core, &*emit);
729            }
730            Request::Query { sql, params, reply } => {
731                let _ = reply.send(core.query(&sql, params));
732                pump_events(&mut core, &*emit);
733            }
734            Request::SetHeaders { headers, reply } => {
735                core.set_headers(headers);
736                let _ = reply.send(json!({ "result": null }));
737            }
738            Request::TransportWake => {
739                core.poll_transport();
740                pump_events(&mut core, &*emit);
741            }
742            Request::QueryRead {
743                id,
744                tables,
745                failure,
746            } => {
747                core.record_query_read(&id, &tables, failure.as_ref());
748                pump_events(&mut core, &*emit);
749            }
750            #[cfg(test)]
751            Request::Block { duration, entered } => {
752                let _ = entered.send(());
753                std::thread::sleep(duration);
754            }
755            Request::Shutdown => break,
756        }
757    }
758    core.shutdown();
759    drop(network_tx);
760    // Transport shutdown releases a pending socket round. An HTTP request
761    // finishes on its own bounded transport timeout, outside the owner.
762    let _ = network.join();
763    for run in active.into_iter().chain(queued) {
764        if let Some(reply) = run.reply {
765            let _ = reply.send(json!({ "error": { "code": "client.closed", "message": "client owner has closed" } }));
766        }
767    }
768}
769
770/// The database file a `create` opens: `<database_dir>/<database>.db` for a
771/// named database, otherwise the configured `db_path` (`None` → in-memory; a
772/// configuration with only `database_dir` requires the name). A
773/// database name is one file name of ASCII letters, digits, `-`, `_` and `.`,
774/// starting with a letter or digit, so it cannot leave the directory. The
775/// webview never supplies a path itself.
776fn resolve_database(config: &SyncularConfig, params: &Value) -> Result<Option<String>, Value> {
777    if params.get("dbPath").is_some() {
778        return Err(invalid_request(
779            "sync.invalid_request: create.dbPath is host configuration; name a database with create.database",
780        ));
781    }
782    let Some(database) = params.get("database") else {
783        if config.db_path.is_none() && config.database_dir.is_some() {
784            return Err(invalid_request(
785                "sync.invalid_request: create.database is required when SyncularConfig sets database_dir without db_path",
786            ));
787        }
788        return Ok(config.db_path.clone());
789    };
790    let Some(name) = database.as_str().filter(|name| {
791        (1..=128).contains(&name.len())
792            && name.starts_with(|c: char| c.is_ascii_alphanumeric())
793            && !name.contains("..")
794            && name
795                .chars()
796                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
797    }) else {
798        return Err(invalid_request(
799            "sync.invalid_request: create.database must be 1 to 128 ASCII letters, digits, '-', '_' or '.', start with a letter or digit, and contain no '..'",
800        ));
801    };
802    let Some(dir) = &config.database_dir else {
803        return Err(invalid_request(
804            "sync.invalid_request: create.database needs SyncularConfig.database_dir",
805        ));
806    };
807    std::fs::create_dir_all(dir)
808        .map_err(|error| client_error(format!("cannot create the database directory: {error}")))?;
809    Ok(Some(
810        std::path::Path::new(dir)
811            .join(format!("{name}.db"))
812            .to_string_lossy()
813            .into_owned(),
814    ))
815}
816
817fn invalid_request(message: &str) -> Value {
818    json!({ "error": { "code": "sync.invalid_request", "message": message } })
819}
820
821fn client_error(message: impl Into<String>) -> Value {
822    json!({ "error": { "code": "client.failed", "message": message.into() } })
823}
824
825fn security_preflight_error() -> Value {
826    json!({
827        "error": {
828            "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
829            "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
830        }
831    })
832}
833
834fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
835    let object = value
836        .and_then(Value::as_object)
837        .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
838    let table = object
839        .get("table")
840        .and_then(Value::as_str)
841        .ok_or_else(|| "window base missing table".to_owned())?
842        .to_owned();
843    let variable = object
844        .get("variable")
845        .and_then(Value::as_str)
846        .ok_or_else(|| "window base missing variable".to_owned())?
847        .to_owned();
848    let fixed_scopes = match object.get("fixedScopes") {
849        Some(value) => syncular_client::values::json_to_scope_map(value)?,
850        None => Vec::new(),
851    };
852    let params = object
853        .get("params")
854        .and_then(Value::as_str)
855        .map(str::to_owned);
856    Ok(WindowBase {
857        table,
858        variable,
859        fixed_scopes,
860        params,
861    })
862}
863
864fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
865    let Some(value) = value else {
866        return Ok(Vec::new());
867    };
868    if value.is_null() {
869        return Ok(Vec::new());
870    }
871    let entries = value
872        .as_array()
873        .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
874    entries
875        .iter()
876        .map(|entry| {
877            let units = entry
878                .get("units")
879                .and_then(Value::as_array)
880                .map(|values| {
881                    values
882                        .iter()
883                        .filter_map(|value| value.as_str().map(str::to_owned))
884                        .collect()
885                })
886                .unwrap_or_default();
887            Ok(WindowCoverage {
888                base: parse_window_base(entry.get("base"))?,
889                units,
890            })
891        })
892        .collect()
893}
894
895fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
896    for event in core.drain_events() {
897        emit(&event.json);
898    }
899}
900
901// -- Tauri commands (the thin shell) -----------------------------------------
902
903#[tauri::command]
904async fn syncular_command<R: Runtime>(
905    app: tauri::AppHandle<R>,
906    command: Value,
907) -> Result<Value, String> {
908    let method = command
909        .get("method")
910        .and_then(Value::as_str)
911        .unwrap_or("")
912        .to_owned();
913    // §7.6: the generic command entry delegates both owned snapshot reads to
914    // the dedicated Tauri read commands, so one existing read path produces the
915    // owner-failure bookkeeping the sidecar's transition mirror tracks. It runs
916    // before any state borrow, because those commands take the app handle.
917    // In-memory replicas keep their own owner fallback inside those commands,
918    // and only the required fields below keep the router's error text; every
919    // other malformed shape gets the dedicated command's validation.
920    if matches!(method.as_str(), "querySnapshot" | "snapshotRead") {
921        let params = command.get("params").unwrap_or(&Value::Null);
922        if method == "querySnapshot" {
923            let Some(sql) = params.get("sql").and_then(Value::as_str) else {
924                return Ok(client_error("querySnapshot missing sql"));
925            };
926            return syncular_query_snapshot(
927                app,
928                sql.to_owned(),
929                params.get("params").cloned(),
930                params.get("coverage").cloned(),
931                params.get("owner").cloned(),
932            )
933            .await;
934        }
935        let statements = params.get("statements").cloned().unwrap_or(Value::Null);
936        if !statements.is_array() {
937            return Ok(invalid_request(
938                "sync.invalid_request: snapshotRead statements must be a list",
939            ));
940        }
941        return syncular_snapshot_read(
942            app,
943            statements,
944            params.get("coverage").cloned(),
945            params.get("subscriptions").cloned(),
946            params.get("commitIds").cloned(),
947            params.get("owner").cloned(),
948        )
949        .await;
950    }
951    let state = app.state::<SyncularState>();
952    if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
953        // Gate fast reads and wait for already-started sidecar snapshots before
954        // the owner-thread barrier is enqueued.
955        state.security_gate.begin_preflight()?;
956    }
957    // Taken after the gate drained, so no admitted read still waits for it.
958    let mut reader = if method == "create" || method == "shutdown" {
959        Some(
960            state
961                .reader
962                .lock()
963                .map_err(|_| "syncular read mailbox poisoned".to_owned())?,
964        )
965    } else {
966        None
967    };
968    let mut command = command;
969    let mut database = None;
970    if method == "create" {
971        if let Some(reads) = command.pointer("/params/authorityReads") {
972            let declarations: Vec<syncular_client::AuthorityReadDeclaration> =
973                match serde_json::from_value(reads.clone()) {
974                    Ok(value) => value,
975                    Err(_) => {
976                        return Ok(
977                            json!({"error": {"code": "client.authority_read_forbidden", "message": "invalid authority declaration"}}),
978                        )
979                    }
980                };
981            if !declarations.iter().all(|read| {
982                state
983                    .config
984                    .authority_columns
985                    .get(&read.table)
986                    .is_some_and(|columns| read.columns.iter().all(|c| columns.contains(c)))
987            }) {
988                return Ok(
989                    json!({"error": {"code": "client.authority_read_forbidden", "message": "authority declaration exceeds the native app policy"}}),
990                );
991            }
992        }
993        database =
994            match resolve_database(&state.config, command.get("params").unwrap_or(&Value::Null)) {
995                Ok(path) => path,
996                Err(reply) => return Ok(reply),
997            };
998        if let Some(path) = &database {
999            match command.get_mut("params").and_then(Value::as_object_mut) {
1000                Some(params) => {
1001                    params.insert("dbPath".to_owned(), Value::from(path.clone()));
1002                }
1003                None => {
1004                    if let Some(object) = command.as_object_mut() {
1005                        object.insert("params".to_owned(), json!({ "dbPath": path }));
1006                    }
1007                }
1008            }
1009        }
1010    }
1011    let create_preflight = method == "create"
1012        && command
1013            .pointer("/params/securityPreflight")
1014            .and_then(Value::as_bool)
1015            .unwrap_or(false);
1016    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1017    state.send(Request::Command {
1018        command,
1019        reply: reply_tx,
1020    })?;
1021    let reply = reply_rx
1022        .recv()
1023        .map_err(|_| "the syncular core dropped the reply".to_owned())?;
1024    let succeeded = reply.get("error").is_none();
1025    if let (true, Some(slot)) = (succeeded, reader.as_mut()) {
1026        let next = match database.filter(|path| path.as_str() != ":memory:") {
1027            Some(path) => Some(spawn_reader(
1028                path,
1029                state
1030                    .sender
1031                    .lock()
1032                    .map_err(|_| "syncular mailbox poisoned".to_owned())?
1033                    .clone(),
1034            )?),
1035            None => None,
1036        };
1037        if let Some(previous) = std::mem::replace(&mut **slot, next) {
1038            let _ = previous.send(ReadRequest::Shutdown);
1039        }
1040    }
1041    drop(reader);
1042    if succeeded && method == "create" {
1043        if !create_preflight {
1044            state.security_gate.activate()?;
1045        }
1046    } else if succeeded && method == "activateSecurity" {
1047        state.security_gate.activate()?;
1048    }
1049    Ok(reply)
1050}
1051
1052/// Replace the native transport's request headers at runtime — the auth
1053/// Header rotation path: a fresh JWT reaches the transport without
1054/// re-registering the plugin. HTTP requests use the new set from the next
1055/// call; the realtime socket applies it on its next (re)connect.
1056#[tauri::command]
1057async fn syncular_set_headers<R: Runtime>(
1058    app: tauri::AppHandle<R>,
1059    headers: std::collections::BTreeMap<String, String>,
1060) -> Result<Value, String> {
1061    let state = app.state::<SyncularState>();
1062    // Runtime bearer replacement is an active-session operation. Hold the
1063    // same gate as fast reads so beginSecurityPreflight both rejects new
1064    // replacements and waits for an already-started mailbox update.
1065    let _active_guard = match state.security_gate.enter_read() {
1066        Ok(guard) => guard,
1067        Err(reply) => return Ok(reply),
1068    };
1069    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1070    state.send(Request::SetHeaders {
1071        headers: headers.into_iter().collect(),
1072        reply: reply_tx,
1073    })?;
1074    reply_rx
1075        .recv()
1076        .map_err(|_| "the syncular core dropped the reply".to_owned())
1077}
1078
1079#[tauri::command]
1080async fn syncular_query<R: Runtime>(
1081    app: tauri::AppHandle<R>,
1082    sql: String,
1083    params: Option<Value>,
1084) -> Result<Value, String> {
1085    let state = app.state::<SyncularState>();
1086    let _read_guard = match state.security_gate.enter_read() {
1087        Ok(guard) => guard,
1088        Err(reply) => return Ok(reply),
1089    };
1090    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1091    state.send(Request::Query {
1092        sql,
1093        params: params.unwrap_or(Value::Null),
1094        reply: reply_tx,
1095    })?;
1096    reply_rx
1097        .recv()
1098        .map_err(|_| "the syncular core dropped the reply".to_owned())
1099}
1100
1101/// Atomic rows + revision + window coverage on the independent read-only
1102/// connection. In-memory configurations fall back to the core owner because
1103/// SQLite cannot share an anonymous database across connections.
1104#[tauri::command]
1105async fn syncular_query_snapshot<R: Runtime>(
1106    app: tauri::AppHandle<R>,
1107    sql: String,
1108    params: Option<Value>,
1109    coverage: Option<Value>,
1110    owner: Option<Value>,
1111) -> Result<Value, String> {
1112    let state = app.state::<SyncularState>();
1113    let _read_guard = match state.security_gate.enter_read() {
1114        Ok(guard) => guard,
1115        Err(reply) => return Ok(reply),
1116    };
1117    let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
1118    let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
1119
1120    if let Some(reader) = state.reader()? {
1121        let bind = match params_value.as_array() {
1122            Some(values) => values.clone(),
1123            None => return Ok(client_error("querySnapshot params must be a list")),
1124        };
1125        let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
1126            Ok(value) => value,
1127            Err(message) => return Ok(client_error(message)),
1128        };
1129        // §7.5 owner: the same `{id, tables}` contract the command router parses.
1130        let parsed_owner = match parse_owner(owner.as_ref()) {
1131            Ok(value) => value,
1132            Err(reply) => return Ok(reply),
1133        };
1134        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1135        reader
1136            .send(ReadRequest::QuerySnapshot {
1137                sql,
1138                params: bind,
1139                coverage: parsed_coverage,
1140                owner: parsed_owner,
1141                reply: reply_tx,
1142            })
1143            .map_err(|_| "the syncular read thread has stopped".to_owned())?;
1144        return reply_rx
1145            .recv()
1146            .map_err(|_| "the syncular read thread dropped the reply".to_owned());
1147    }
1148
1149    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1150    state.send(Request::Command {
1151        command: json!({
1152            "method": "querySnapshot",
1153            "params": {
1154                "sql": sql,
1155                "params": params_value,
1156                "coverage": coverage_value,
1157                "owner": owner.unwrap_or(Value::Null),
1158            }
1159        }),
1160        reply: reply_tx,
1161    })?;
1162    reply_rx
1163        .recv()
1164        .map_err(|_| "the syncular core dropped the reply".to_owned())
1165}
1166
1167/// §7.5 owner `{id, tables}`, both required when present. Shared by the
1168/// `querySnapshot` and `snapshotRead` commands.
1169fn parse_owner(value: Option<&Value>) -> Result<Option<(String, Vec<String>)>, Value> {
1170    match value {
1171        None | Some(Value::Null) => Ok(None),
1172        Some(value) => {
1173            let id = value
1174                .get("id")
1175                .and_then(Value::as_str)
1176                .filter(|id| !id.is_empty());
1177            let tables = value
1178                .get("tables")
1179                .and_then(Value::as_array)
1180                .and_then(|list| {
1181                    list.iter()
1182                        .map(|table| table.as_str().map(str::to_owned))
1183                        .collect::<Option<Vec<String>>>()
1184                });
1185            match (id, tables) {
1186                (Some(id), Some(tables)) => Ok(Some((id.to_owned(), tables))),
1187                _ => Err(json!({ "error": {
1188                    "code": "sync.invalid_request",
1189                    "message": "sync.invalid_request: owner must be {id: non-empty string, tables: string[]}",
1190                } })),
1191            }
1192        }
1193    }
1194}
1195
1196fn parse_string_list(value: Option<&Value>) -> Result<Vec<String>, String> {
1197    match value {
1198        None | Some(Value::Null) => Ok(Vec::new()),
1199        Some(Value::Array(items)) => items
1200            .iter()
1201            .map(|item| item.as_str().map(str::to_owned))
1202            .collect::<Option<Vec<String>>>()
1203            .ok_or_else(|| "expected a list of strings".to_owned()),
1204        Some(_) => Err("expected a list of strings".to_owned()),
1205    }
1206}
1207
1208/// §7.5: one atomic read over the file-backed sidecar, or the owner command
1209/// for an in-memory replica (SQLite cannot share an anonymous database across
1210/// connections). `statements` is a list of `{sql, params?}`; `subscriptions`
1211/// and `commitIds` name the catch-up and delivery reads. All results share one
1212/// revision and one read transaction.
1213#[tauri::command]
1214async fn syncular_snapshot_read<R: Runtime>(
1215    app: tauri::AppHandle<R>,
1216    statements: Value,
1217    coverage: Option<Value>,
1218    subscriptions: Option<Value>,
1219    commit_ids: Option<Value>,
1220    owner: Option<Value>,
1221) -> Result<Value, String> {
1222    let state = app.state::<SyncularState>();
1223    let _read_guard = match state.security_gate.enter_read() {
1224        Ok(guard) => guard,
1225        Err(reply) => return Ok(reply),
1226    };
1227    let Some(items) = statements.as_array() else {
1228        return Ok(invalid_request(
1229            "sync.invalid_request: snapshotRead statements must be a list",
1230        ));
1231    };
1232    let mut parsed_statements = Vec::with_capacity(items.len());
1233    for item in items {
1234        let Some(sql) = item.get("sql").and_then(Value::as_str) else {
1235            return Ok(invalid_request(
1236                "sync.invalid_request: each snapshotRead statement needs a sql string",
1237            ));
1238        };
1239        // A present `params` must be a list; never fabricate an empty bind set.
1240        let params = match item.get("params") {
1241            None | Some(Value::Null) => Vec::new(),
1242            Some(Value::Array(values)) => values.clone(),
1243            Some(_) => {
1244                return Ok(invalid_request(
1245                    "sync.invalid_request: snapshotRead statement params must be a list",
1246                ))
1247            }
1248        };
1249        parsed_statements.push((sql.to_owned(), params));
1250    }
1251    let coverage_value = coverage.clone().unwrap_or(Value::Null);
1252    let parsed_coverage = match parse_coverage(coverage.as_ref()) {
1253        Ok(value) => value,
1254        Err(message) => return Ok(invalid_request(&format!("sync.invalid_request: {message}"))),
1255    };
1256    let parsed_subscriptions = match parse_string_list(subscriptions.as_ref()) {
1257        Ok(value) => value,
1258        Err(message) => return Ok(invalid_request(&format!("sync.invalid_request: {message}"))),
1259    };
1260    let parsed_commit_ids = match parse_string_list(commit_ids.as_ref()) {
1261        Ok(value) => value,
1262        Err(message) => return Ok(invalid_request(&format!("sync.invalid_request: {message}"))),
1263    };
1264    let parsed_owner = match parse_owner(owner.as_ref()) {
1265        Ok(value) => value,
1266        Err(reply) => return Ok(reply),
1267    };
1268
1269    if let Some(reader) = state.reader()? {
1270        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1271        reader
1272            .send(ReadRequest::SnapshotRead {
1273                statements: parsed_statements,
1274                coverage: parsed_coverage,
1275                subscriptions: parsed_subscriptions,
1276                commit_ids: parsed_commit_ids,
1277                owner: parsed_owner,
1278                reply: reply_tx,
1279            })
1280            .map_err(|_| "the syncular read thread has stopped".to_owned())?;
1281        return reply_rx
1282            .recv()
1283            .map_err(|_| "the syncular read thread dropped the reply".to_owned());
1284    }
1285
1286    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1287    state.send(Request::Command {
1288        command: json!({
1289            "method": "snapshotRead",
1290            "params": {
1291                "statements": parsed_statements
1292                    .into_iter()
1293                    .map(|(sql, params)| json!({ "sql": sql, "params": params }))
1294                    .collect::<Vec<_>>(),
1295                "coverage": coverage_value,
1296                "subscriptions": parsed_subscriptions,
1297                "commitIds": parsed_commit_ids,
1298                "owner": parsed_owner.map_or(Value::Null, |(id, tables)| json!({
1299                    "id": id,
1300                    "tables": tables,
1301                })),
1302            }
1303        }),
1304        reply: reply_tx,
1305    })?;
1306    reply_rx
1307        .recv()
1308        .map_err(|_| "the syncular core dropped the reply".to_owned())
1309}
1310
1311/// Initialize the plugin with a config. Register with
1312/// `tauri::Builder::default().plugin(tauri_plugin_syncular::init(config))`.
1313///
1314/// The owning thread is spawned in `setup`; it builds the core (native
1315/// transport if `base_url` + the `native-transport` feature), pumps events onto
1316/// [`EVENT_NAME`], and runs the §8.4 host loop. The mailbox `Sender` is managed
1317/// as plugin state and torn down on `RunEvent::Exit`.
1318pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
1319    Builder::<R>::new("syncular")
1320        .invoke_handler(tauri::generate_handler![
1321            syncular_command,
1322            syncular_query,
1323            syncular_query_snapshot,
1324            syncular_snapshot_read,
1325            syncular_set_headers
1326        ])
1327        .setup(move |app, _api| {
1328            let (tx, rx) = std::sync::mpsc::channel::<Request>();
1329            app.manage(SyncularState {
1330                sender: Mutex::new(tx.clone()),
1331                reader: Mutex::new(None),
1332                config: config.clone(),
1333                // Fail closed until the first successful `create` declares
1334                // whether this process starts active or in preflight.
1335                security_gate: SecurityGate::new_preflight(),
1336            });
1337            let app_handle = app.clone();
1338            let emit = move |value: &Value| {
1339                // Best-effort: a webview that has gone away must not crash the
1340                // owning thread. Emit to all windows on the syncular channel.
1341                let _ = app_handle.emit(EVENT_NAME, value.clone());
1342            };
1343            std::thread::Builder::new()
1344                .name("syncular-core".to_owned())
1345                .spawn(move || run_owner_thread(config, tx, rx, emit))
1346                .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
1347            Ok(())
1348        })
1349        .on_event(|app, event| {
1350            if let RunEvent::Exit = event {
1351                if let Some(state) = app.try_state::<SyncularState>() {
1352                    let _ = state.send(Request::Shutdown);
1353                    if let Ok(Some(reader)) = state.reader() {
1354                        let _ = reader.send(ReadRequest::Shutdown);
1355                    }
1356                }
1357            }
1358        })
1359        .build()
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364    use super::*;
1365
1366    #[test]
1367    fn config_to_transport_json_shapes_fields() {
1368        let config = SyncularConfig {
1369            base_url: Some("https://api.example.com".to_owned()),
1370            headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
1371            request_timeout_ms: Some(1000),
1372            round_deadline_ms: Some(2000),
1373            max_request_bytes: Some(1024),
1374            max_response_bytes: Some(2048),
1375            redirects: Some("deny".to_owned()),
1376            ..Default::default()
1377        };
1378        let json = config.to_transport_json();
1379        assert_eq!(json["baseUrl"], "https://api.example.com");
1380        assert_eq!(json["headers"]["authorization"], "Bearer x");
1381        assert_eq!(json["requestTimeoutMs"], 1000);
1382        assert_eq!(json["roundDeadlineMs"], 2000);
1383        assert_eq!(json["maxRequestBytes"], 1024);
1384        assert_eq!(json["maxResponseBytes"], 2048);
1385        assert_eq!(json["redirects"], "deny");
1386    }
1387
1388    #[test]
1389    fn transport_policy_config_is_validated_at_the_core_boundary() {
1390        // A headerless core still validates the policy it is handed.
1391        let core = SyncularCore::new(&serde_json::json!({"requestTimeoutMs": 1000}));
1392        assert!(core.is_ok());
1393        let error = SyncularCore::new(&serde_json::json!({"requestTimeoutMs": 0}))
1394            .err()
1395            .unwrap();
1396        assert_eq!(
1397            error,
1398            "sync.invalid_request: transport config requestTimeoutMs must be a positive integer"
1399        );
1400        let error = SyncularCore::new(&serde_json::json!({"redirects": "sometimes"}))
1401            .err()
1402            .unwrap();
1403        assert_eq!(
1404            error,
1405            "sync.invalid_request: transport config redirects must be \"deny\" or \"follow\""
1406        );
1407        let mut core = SyncularCore::new(&serde_json::json!({})).unwrap();
1408        assert_eq!(
1409            core.set_transport_policy(crate::transport::HostTransportPolicy {
1410                max_response_bytes: Some(0),
1411                ..Default::default()
1412            })
1413            .unwrap_err(),
1414            "sync.invalid_request: transport config maxResponseBytes must be a positive integer"
1415        );
1416    }
1417
1418    #[test]
1419    fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
1420        let gate = std::sync::Arc::new(SecurityGate::new_preflight());
1421        gate.activate().expect("activate test gate");
1422        let read = gate.enter_read().expect("active read");
1423        let gate_for_barrier = std::sync::Arc::clone(&gate);
1424        let (done_tx, done_rx) = std::sync::mpsc::channel();
1425        let barrier = std::thread::spawn(move || {
1426            gate_for_barrier.begin_preflight().expect("enter preflight");
1427            done_tx.send(()).expect("barrier reply");
1428        });
1429
1430        assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
1431        drop(read);
1432        done_rx
1433            .recv_timeout(Duration::from_secs(1))
1434            .expect("barrier drains after the read");
1435        barrier.join().expect("barrier thread");
1436        assert!(gate.enter_read().is_err());
1437    }
1438
1439    #[test]
1440    fn direct_set_headers_command_respects_the_native_preflight_gate() {
1441        use std::collections::BTreeMap;
1442        use tauri::test::{mock_builder, mock_context, noop_assets};
1443
1444        let app = mock_builder()
1445            .plugin(init(SyncularConfig {
1446                auto_sync: false,
1447                ..Default::default()
1448            }))
1449            .build(mock_context(noop_assets()))
1450            .expect("build mock app");
1451
1452        let blocked = tauri::async_runtime::block_on(syncular_set_headers(
1453            app.handle().clone(),
1454            BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
1455        ))
1456        .expect("preflight reply");
1457        assert_eq!(
1458            blocked["error"]["code"],
1459            Value::from("client.security_preflight_required")
1460        );
1461
1462        let created = tauri::async_runtime::block_on(syncular_command(
1463            app.handle().clone(),
1464            json!({
1465                "method": "create",
1466                "params": {
1467                    "clientId": "native-header-gate",
1468                    "schema": { "version": 1, "tables": [] }
1469                }
1470            }),
1471        ))
1472        .expect("create reply");
1473        assert!(created.get("error").is_none(), "{created}");
1474
1475        let active = tauri::async_runtime::block_on(syncular_set_headers(
1476            app.handle().clone(),
1477            BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
1478        ))
1479        .expect("active reply");
1480        assert_eq!(active["result"], Value::Null);
1481    }
1482
1483    #[test]
1484    fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
1485        use tauri::test::{mock_builder, mock_context, noop_assets};
1486
1487        let app = mock_builder()
1488            .plugin(init(SyncularConfig {
1489                auto_sync: false,
1490                ..Default::default()
1491            }))
1492            .build(mock_context(noop_assets()))
1493            .expect("build mock app");
1494        let command = |value: Value| {
1495            tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1496                .expect("command reply")
1497        };
1498
1499        let created = command(json!({
1500            "method": "create",
1501            "params": {
1502                "clientId": "native-preflight-escape",
1503                "schema": { "version": 1, "tables": [] },
1504                "securityPreflight": true
1505            }
1506        }));
1507        assert!(created.get("error").is_none(), "{created}");
1508
1509        // The escape attempt: a plain re-create must be refused by the shared
1510        // router, and the plugin's fast-read gate must stay engaged.
1511        let escape = command(json!({
1512            "method": "create",
1513            "params": {
1514                "clientId": "native-preflight-escape",
1515                "schema": { "version": 1, "tables": [] }
1516            }
1517        }));
1518        assert_eq!(
1519            escape["error"]["code"],
1520            Value::from("client.security_preflight_required"),
1521            "{escape}"
1522        );
1523        let read = tauri::async_runtime::block_on(syncular_query(
1524            app.handle().clone(),
1525            "SELECT 1".to_owned(),
1526            None,
1527        ))
1528        .expect("query reply");
1529        assert_eq!(
1530            read["error"]["code"],
1531            Value::from("client.security_preflight_required")
1532        );
1533
1534        // Activation (with a fresh header set) releases both gates.
1535        let activated = command(json!({
1536            "method": "activateSecurity",
1537            "params": { "headers": { "authorization": "Bearer fresh" } }
1538        }));
1539        assert!(activated.get("error").is_none(), "{activated}");
1540        let read = tauri::async_runtime::block_on(syncular_query(
1541            app.handle().clone(),
1542            "SELECT 1 AS value".to_owned(),
1543            None,
1544        ))
1545        .expect("query reply");
1546        assert_eq!(read["result"]["rows"][0]["value"], 1);
1547        let recreated = command(json!({
1548            "method": "create",
1549            "params": {
1550                "clientId": "native-preflight-escape",
1551                "schema": { "version": 1, "tables": [] }
1552            }
1553        }));
1554        assert!(recreated.get("error").is_none(), "{recreated}");
1555    }
1556
1557    #[test]
1558    fn authority_snapshot_raw_ipc_obeys_native_columns_and_never_opens_protected_reads() {
1559        use tauri::test::{mock_builder, mock_context, noop_assets};
1560        let path =
1561            std::env::temp_dir().join(format!("syncular-authority-ipc-{}.db", std::process::id()));
1562        let app = mock_builder()
1563            .plugin(init(SyncularConfig {
1564                auto_sync: false,
1565                db_path: Some(path.to_string_lossy().into_owned()),
1566                authority_columns: [(
1567                    "authority".into(),
1568                    vec!["id".into(), "actor_id".into(), "role".into()],
1569                )]
1570                .into(),
1571                ..Default::default()
1572            }))
1573            .build(mock_context(noop_assets()))
1574            .expect("mock app");
1575        let handle = app.handle().clone();
1576        let command = |value: Value| {
1577            tauri::async_runtime::block_on(syncular_command(handle.clone(), value))
1578                .expect("IPC reply")
1579        };
1580        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
1581            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "role", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
1582        ]}]});
1583        let policy = json!([{"table": "authority", "columns": ["id", "actor_id", "role"], "scopes": {"actor_id": ["a", "b"]}}]);
1584        let create = |reads: Value| json!({"method": "create", "params": {"schema": schema, "securityPreflight": true, "transportEnabled": false, "authorityReads": reads}});
1585        let created = command(create(policy.clone()));
1586        assert!(created.get("error").is_none(), "{created}");
1587        let conn = rusqlite::Connection::open(&path).expect("fixture db");
1588        conn.execute_batch("INSERT INTO _syncular_base_authority(id,actor_id,role,secret,_syncular_version)VALUES('one','a','accepted','clinical plaintext',7); INSERT INTO authority SELECT * FROM _syncular_base_authority;").expect("fixture base");
1589        for actor in ["a", "b"] {
1590            let state = json!({"requested": {"actor_id": [actor]}, "effectiveScopes": {"actor_id": [actor]}, "status": "active", "cursor": 7});
1591            conn.execute(
1592                "INSERT INTO _syncular_subscriptions(id,tbl,state_json)VALUES(?1,'authority',?2)",
1593                rusqlite::params![actor, state.to_string()],
1594            )
1595            .expect("fixture subscription");
1596        }
1597        let snapshot = command(json!({"method": "authoritySnapshot", "params": {}}));
1598        assert_eq!(snapshot["result"]["complete"], true, "{snapshot}");
1599        assert_eq!(
1600            snapshot["result"]["tables"][0]["rows"][0]["values"],
1601            json!({"id": "one", "actor_id": "a", "role": "accepted"})
1602        );
1603        assert!(!snapshot.to_string().contains("clinical plaintext"));
1604        assert_eq!(
1605            command(json!({"method": "securityLifecycle", "params": {}}))["result"]["state"],
1606            "preflight"
1607        );
1608        for params in [
1609            json!({"sql": "SELECT secret FROM authority"}),
1610            json!({"columns": ["secret"]}),
1611            json!({"table": "_syncular_meta"}),
1612            json!({"scopes": {"actor_id": ["outside"]}}),
1613            json!({"authorityReads": policy}),
1614        ] {
1615            assert_eq!(
1616                command(json!({"method": "authoritySnapshot", "params": params}))["error"]["code"],
1617                "client.authority_read_forbidden"
1618            );
1619        }
1620        for columns in [
1621            json!(["id", "actor_id", "secret"]),
1622            json!([
1623                "id",
1624                "actor_id",
1625                "role FROM authority; DELETE FROM authority"
1626            ]),
1627        ] {
1628            assert_eq!(
1629                command(create(
1630                    json!([{"table": "authority", "columns": columns, "scopes": {"actor_id": ["a"]}}])
1631                ))["error"]["code"],
1632                "client.authority_read_forbidden"
1633            );
1634        }
1635        for method in [
1636            "query",
1637            "querySnapshot",
1638            "mutate",
1639            "setHeaders",
1640            "subscribe",
1641            "sync",
1642        ] {
1643            assert_eq!(
1644                command(json!({"method": method, "params": {"sql": "SELECT id FROM authority"}}))
1645                    ["error"]["code"],
1646                "client.security_preflight_required"
1647            );
1648        }
1649        assert_eq!(
1650            tauri::async_runtime::block_on(syncular_query(
1651                app.handle().clone(),
1652                "SELECT id FROM authority".into(),
1653                None
1654            ))
1655            .unwrap()["error"]["code"],
1656            "client.security_preflight_required"
1657        );
1658        assert_eq!(
1659            tauri::async_runtime::block_on(syncular_query_snapshot(
1660                app.handle().clone(),
1661                "SELECT id FROM authority".into(),
1662                None,
1663                None,
1664                None
1665            ))
1666            .unwrap()["error"]["code"],
1667            "client.security_preflight_required"
1668        );
1669        let before = snapshot["result"]["revision"].clone();
1670        assert_eq!(
1671            command(json!({"method": "authoritySnapshot", "params": {}}))["result"]["revision"],
1672            before
1673        );
1674        conn.execute("UPDATE _syncular_subscriptions SET state_json=?1 WHERE id='b'", [json!({"requested": {"actor_id": ["b"]}, "effectiveScopes": {"actor_id": ["b"]}, "status": "active", "cursor": 7, "bootstrapState": "credential-do-not-expose"}).to_string()]).unwrap();
1675        let partial = command(json!({"method": "authoritySnapshot", "params": {}}));
1676        assert_eq!(partial["result"]["complete"], false);
1677        assert!(!partial.to_string().contains("credential-do-not-expose"));
1678        conn.execute(
1679            "UPDATE _syncular_subscriptions SET state_json='credential-do-not-expose' WHERE id='b'",
1680            [],
1681        )
1682        .unwrap();
1683        let corrupt = command(json!({"method": "authoritySnapshot", "params": {}}));
1684        assert_eq!(corrupt["error"]["code"], "sync.local_corrupt");
1685        assert!(!corrupt.to_string().contains("credential-do-not-expose"));
1686        conn.execute("DELETE FROM _syncular_subscriptions WHERE id='b'", [])
1687            .unwrap();
1688        command(json!({"method": "beginSecurityPreflight", "params": {}}));
1689        assert!(
1690            command(json!({"method": "authoritySnapshot", "params": {}}))
1691                .get("error")
1692                .is_none()
1693        );
1694        // The owner's mailbox serializes a lock/read race without admitting SQL.
1695        let barrier = std::sync::Barrier::new(2);
1696        std::thread::scope(|scope| {
1697            let locking = scope.spawn(|| {
1698                barrier.wait();
1699                command(json!({"method": "beginSecurityPreflight", "params": {}}))
1700            });
1701            barrier.wait();
1702            let reading = command(json!({"method": "authoritySnapshot", "params": {}}));
1703            assert_eq!(reading["result"]["revision"], before);
1704            assert!(locking.join().unwrap().get("error").is_none());
1705        });
1706        // A racing close either follows the entire snapshot or rejects it.
1707        let barrier = std::sync::Barrier::new(2);
1708        std::thread::scope(|scope| {
1709            let closing = scope.spawn(|| {
1710                barrier.wait();
1711                command(json!({"method": "shutdown", "params": {}}))
1712            });
1713            barrier.wait();
1714            let reading = command(json!({"method": "authoritySnapshot", "params": {}}));
1715            assert!(
1716                reading["result"]["revision"] == before
1717                    || reading["error"]["code"] == "client.closed",
1718                "{reading}"
1719            );
1720            assert!(closing.join().unwrap().get("error").is_none());
1721        });
1722        assert_eq!(
1723            command(json!({"method": "authoritySnapshot", "params": {}}))["error"]["code"],
1724            "client.closed"
1725        );
1726        drop(conn);
1727        let _ = std::fs::remove_file(path);
1728    }
1729
1730    #[test]
1731    fn resolve_database_names_a_file_inside_the_configured_directory() {
1732        let dir =
1733            std::env::temp_dir().join(format!("syncular-tauri-resolve-{}", std::process::id()));
1734        let config = SyncularConfig {
1735            db_path: Some("/tmp/default.db".to_owned()),
1736            database_dir: Some(dir.to_string_lossy().into_owned()),
1737            ..Default::default()
1738        };
1739        // No name → the configured default path.
1740        assert_eq!(
1741            resolve_database(&config, &json!({ "clientId": "c1" })),
1742            Ok(Some("/tmp/default.db".to_owned()))
1743        );
1744        // A name → `<database_dir>/<name>.db`, directory created.
1745        assert_eq!(
1746            resolve_database(&config, &json!({ "database": "app-actor-0f.v2" })),
1747            Ok(Some(
1748                dir.join("app-actor-0f.v2.db")
1749                    .to_string_lossy()
1750                    .into_owned()
1751            ))
1752        );
1753        assert!(dir.is_dir());
1754        // A webview-supplied path and every name that could leave the
1755        // directory are refused.
1756        for params in [
1757            json!({ "dbPath": "/etc/other.db" }),
1758            json!({ "database": "../escape" }),
1759            json!({ "database": "a/b" }),
1760            json!({ "database": "a\\b" }),
1761            json!({ "database": "/abs" }),
1762            json!({ "database": ".hidden" }),
1763            json!({ "database": "a..b" }),
1764            json!({ "database": "" }),
1765            json!({ "database": "x".repeat(129) }),
1766            json!({ "database": 7 }),
1767        ] {
1768            let refused = resolve_database(&config, &params).expect_err("refused");
1769            assert_eq!(refused["error"]["code"], "sync.invalid_request", "{params}");
1770        }
1771        // A configuration with only a directory never opens an unnamed
1772        // create in memory.
1773        let refused = resolve_database(
1774            &SyncularConfig {
1775                database_dir: Some(dir.to_string_lossy().into_owned()),
1776                ..Default::default()
1777            },
1778            &json!({ "clientId": "c1" }),
1779        )
1780        .expect_err("name required");
1781        assert_eq!(refused["error"]["code"], "sync.invalid_request");
1782        // A name without a configured directory is refused, never placed
1783        // somewhere else.
1784        let refused = resolve_database(&SyncularConfig::default(), &json!({ "database": "actor" }))
1785            .expect_err("no directory");
1786        assert_eq!(refused["error"]["code"], "sync.invalid_request");
1787        std::fs::remove_dir_all(dir).expect("remove temp directory");
1788    }
1789
1790    /// One replica per actor: each `create` opens its named database, keeps
1791    /// its own client id, and the snapshot reader follows the open database.
1792    #[test]
1793    fn named_databases_keep_one_replica_and_client_id_per_actor() {
1794        use tauri::test::{mock_builder, mock_context, noop_assets};
1795
1796        let dir =
1797            std::env::temp_dir().join(format!("syncular-tauri-actors-{}", std::process::id()));
1798        let _ = std::fs::remove_dir_all(&dir);
1799        let app = mock_builder()
1800            .plugin(init(SyncularConfig {
1801                database_dir: Some(dir.to_string_lossy().into_owned()),
1802                auto_sync: false,
1803                ..Default::default()
1804            }))
1805            .build(mock_context(noop_assets()))
1806            .expect("build mock app");
1807        let command = |value: Value| {
1808            tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
1809                .expect("command reply")
1810        };
1811        let snapshot = |sql: &str| {
1812            tauri::async_runtime::block_on(syncular_query_snapshot(
1813                app.handle().clone(),
1814                sql.to_owned(),
1815                None,
1816                None,
1817                None,
1818            ))
1819            .expect("snapshot reply")
1820        };
1821        let schema = json!({
1822            "version": 1,
1823            "tables": [{
1824                "name": "todo", "primaryKey": "id",
1825                "columns": [
1826                    { "name": "id", "type": "string", "nullable": false },
1827                    { "name": "title", "type": "string", "nullable": false }
1828                ],
1829                "scopes": []
1830            }]
1831        });
1832        let create = |database: &str, client_id: &str| {
1833            command(json!({ "method": "create", "params": {
1834                "database": database, "clientId": client_id, "schema": schema
1835            } }))
1836        };
1837
1838        assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1839        command(json!({ "method": "mutate", "params": { "mutations": [{
1840            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "a" }
1841        }] } }));
1842        assert_eq!(
1843            snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1844            "a"
1845        );
1846        assert_eq!(
1847            command(json!({ "method": "shutdown" }))["result"],
1848            json!({})
1849        );
1850
1851        assert_eq!(create("actor-b", "client-b")["result"], json!({}));
1852        assert_eq!(
1853            snapshot("SELECT title FROM todo")["result"]["rows"],
1854            json!([]),
1855            "the reader follows the second actor's database"
1856        );
1857        assert!(dir.join("actor-a.db").is_file());
1858        assert!(dir.join("actor-b.db").is_file());
1859        assert_eq!(
1860            command(json!({ "method": "shutdown" }))["result"],
1861            json!({})
1862        );
1863
1864        // Each replica keeps the client id it was created with.
1865        assert_eq!(
1866            create("actor-a", "client-b")["error"]["code"],
1867            "client.identity_mismatch"
1868        );
1869        assert_eq!(create("actor-a", "client-a")["result"], json!({}));
1870        assert_eq!(
1871            snapshot("SELECT title FROM todo")["result"]["rows"][0]["title"],
1872            "a"
1873        );
1874        assert_eq!(
1875            create("../actor-a", "client-a")["error"]["code"],
1876            "sync.invalid_request"
1877        );
1878        command(json!({ "method": "shutdown" }));
1879        std::fs::remove_dir_all(dir).expect("remove temp directory");
1880    }
1881
1882    #[test]
1883    fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
1884        let parsed = parse_coverage(Some(&json!([{
1885            "base": {
1886                "table": "tasks",
1887                "variable": "project_id",
1888                "fixedScopes": { "tenant_id": ["one", "two"] },
1889                "params": "opaque"
1890            },
1891            "units": ["a", "b"]
1892        }])))
1893        .expect("parse coverage");
1894        assert_eq!(parsed.len(), 1);
1895        let entry = &parsed[0];
1896        assert_eq!(entry.base.table, "tasks");
1897        assert_eq!(entry.base.variable, "project_id");
1898        assert_eq!(
1899            entry.base.fixed_scopes,
1900            vec![(
1901                "tenant_id".to_owned(),
1902                vec!["one".to_owned(), "two".to_owned()]
1903            )]
1904        );
1905        assert_eq!(entry.base.params.as_deref(), Some("opaque"));
1906        assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
1907    }
1908
1909    /// The owner-thread mailbox loop end-to-end, without any Tauri window: post
1910    /// commands, collect emitted events. This is the real host path — the Tauri
1911    /// commands are a two-line channel forward over exactly this.
1912    #[test]
1913    fn owner_thread_round_trips_over_mailbox() {
1914        use std::sync::mpsc::channel;
1915        use std::sync::{Arc, Mutex as StdMutex};
1916
1917        let (tx, rx) = channel::<Request>();
1918        let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
1919        let events_for_thread = Arc::clone(&events);
1920        let config = SyncularConfig {
1921            auto_sync: false,
1922            ..Default::default()
1923        };
1924        let owner_tx = tx.clone();
1925        let handle = std::thread::spawn(move || {
1926            run_owner_thread(config, owner_tx, rx, move |v| {
1927                events_for_thread.lock().unwrap().push(v.clone());
1928            });
1929        });
1930
1931        let call = |command: Value| -> Value {
1932            let (rtx, rrx) = channel();
1933            tx.send(Request::Command {
1934                command,
1935                reply: rtx,
1936            })
1937            .unwrap();
1938            rrx.recv().unwrap()
1939        };
1940
1941        let schema = json!({
1942            "version": 1,
1943            "tables": [{
1944                "name": "todo", "primaryKey": "id",
1945                "columns": [
1946                    { "name": "id", "type": "string", "nullable": false },
1947                    { "name": "title", "type": "string", "nullable": false }
1948                ],
1949                "scopes": []
1950            }]
1951        });
1952        assert_eq!(
1953            call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1954                ["result"],
1955            json!({})
1956        );
1957        call(json!({ "method": "mutate", "params": { "mutations": [{
1958            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1959        }] } }));
1960
1961        // A query over the mailbox.
1962        let (qtx, qrx) = channel();
1963        tx.send(Request::Query {
1964            sql: "SELECT title FROM todo".to_owned(),
1965            params: Value::Null,
1966            reply: qtx,
1967        })
1968        .unwrap();
1969        let rows = qrx.recv().unwrap();
1970        assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1971
1972        // Header rotation rides the same mailbox; a
1973        // client-local (Null-transport) core accepts and ignores the set.
1974        let (htx, hrx) = channel();
1975        tx.send(Request::SetHeaders {
1976            headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1977            reply: htx,
1978        })
1979        .unwrap();
1980        assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1981
1982        tx.send(Request::Shutdown).unwrap();
1983        handle.join().unwrap();
1984
1985        let seen = events.lock().unwrap();
1986        let kinds: Vec<String> = seen
1987            .iter()
1988            .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1989            .collect();
1990        // The local mutate emits the exact revisioned batch onto the channel.
1991        assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1992    }
1993
1994    #[cfg(feature = "native-transport")]
1995    #[test]
1996    fn pending_network_round_keeps_local_mutations_and_queries_responsive() {
1997        use ssp2::model::{Change, Frame, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
1998        use ssp2::segment::{encode_row, Column, ColumnType, ColumnValue};
1999        use std::io::{Read, Write};
2000        use std::net::TcpListener;
2001        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2002        use std::sync::mpsc::channel;
2003        use std::sync::Arc;
2004
2005        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2006        let address = listener.local_addr().unwrap();
2007        let (entered_tx, entered_rx) = channel();
2008        let (release_tx, release_rx) = channel();
2009        let (delivered_tx, delivered_rx) = channel();
2010        let (ack_tx, ack_rx) = channel();
2011        let image_sent = Arc::new(AtomicBool::new(false));
2012        let server_image_sent = image_sent.clone();
2013        let requests = Arc::new(AtomicUsize::new(0));
2014        let server_requests = requests.clone();
2015        let server = std::thread::spawn(move || {
2016            let mut pushed = Vec::new();
2017            loop {
2018                let (mut socket, _) = listener.accept().unwrap();
2019                server_requests.fetch_add(1, Ordering::SeqCst);
2020                let mut header = Vec::new();
2021                while !header.ends_with(b"\r\n\r\n") {
2022                    let mut byte = [0];
2023                    socket.read_exact(&mut byte).unwrap();
2024                    header.push(byte[0]);
2025                }
2026                let header = String::from_utf8(header).unwrap();
2027                assert!(header
2028                    .to_ascii_lowercase()
2029                    .contains("authorization: bearer fresh"));
2030                let length: usize = header
2031                    .lines()
2032                    .find_map(|line| {
2033                        let (name, value) = line.split_once(':')?;
2034                        name.eq_ignore_ascii_case("content-length")
2035                            .then(|| value.trim().parse().unwrap())
2036                    })
2037                    .unwrap();
2038                let mut bytes = vec![0; length];
2039                socket.read_exact(&mut bytes).unwrap();
2040                let request = ssp2::decode_message(&bytes).unwrap();
2041                let reset = matches!(
2042                    &request.frames[0],
2043                    Frame::ReqHeader {
2044                        log_epoch: None,
2045                        ..
2046                    }
2047                );
2048                let deliver = !reset && pushed.len() == 3;
2049                let mut frames = vec![Frame::RespHeader {
2050                    required_schema_version: None,
2051                    latest_schema_version: None,
2052                    log_epoch: Some("epoch".into()),
2053                    reset_required: Some(reset),
2054                }];
2055                if !reset {
2056                    let commits: Vec<_> = request
2057                        .frames
2058                        .iter()
2059                        .filter_map(|frame| {
2060                            if let Frame::PushCommit {
2061                                client_commit_id,
2062                                operations,
2063                            } = frame
2064                            {
2065                                Some((client_commit_id.clone(), operations.len()))
2066                            } else {
2067                                None
2068                            }
2069                        })
2070                        .collect();
2071                    if !commits.is_empty() && pushed.is_empty() {
2072                        entered_tx.send(()).unwrap();
2073                        release_rx.recv().unwrap();
2074                    }
2075                    for (id, count) in commits {
2076                        pushed.push(id.clone());
2077                        frames.push(Frame::PushResult {
2078                            client_commit_id: id,
2079                            status: PushStatus::Applied,
2080                            commit_seq: Some(pushed.len() as i64),
2081                            results: (0..count)
2082                                .map(|i| OpResult::Applied { op_index: i as i32 })
2083                                .collect(),
2084                        });
2085                    }
2086                }
2087                if !reset
2088                    && request
2089                        .frames
2090                        .iter()
2091                        .any(|frame| matches!(frame, Frame::Subscription { id, .. } if id == "own"))
2092                {
2093                    frames.push(Frame::SubStart {
2094                        id: "own".into(),
2095                        status: SubStatus::Active,
2096                        reason_code: String::new(),
2097                        effective_scopes: vec![("id".into(), vec!["one".into()])],
2098                        bootstrap: false,
2099                    });
2100                    if deliver {
2101                        assert!(request
2102                            .frames
2103                            .iter()
2104                            .any(|frame| matches!(frame, Frame::PullHeader { .. })));
2105                        assert!(!request
2106                            .frames
2107                            .iter()
2108                            .any(|frame| matches!(frame, Frame::PushCommit { .. })));
2109                        let columns = ["id", "title"].map(|name| Column {
2110                            name: name.into(),
2111                            ty: ColumnType::String,
2112                            nullable: false,
2113                        });
2114                        let mut row = ssp2::primitives::Writer::new();
2115                        encode_row(
2116                            &mut row,
2117                            &columns,
2118                            &vec![
2119                                Some(ColumnValue::String("one".into())),
2120                                Some(ColumnValue::String("300/v3".into())),
2121                            ],
2122                        );
2123                        frames.push(Frame::Commit {
2124                            commit_seq: 3,
2125                            created_at_ms: 0,
2126                            actor_id: "actor".into(),
2127                            tables: vec!["todo".into()],
2128                            changes: vec![Change {
2129                                table_index: 0,
2130                                row_id: "one".into(),
2131                                op: Op::Upsert,
2132                                row_version: Some(3),
2133                                scopes: vec![("id".into(), "one".into())],
2134                                row: Some(row.into_bytes()),
2135                            }],
2136                        });
2137                        server_image_sent.store(true, Ordering::SeqCst);
2138                    }
2139                    frames.push(Frame::SubEnd {
2140                        next_cursor: if deliver { 3 } else { 0 },
2141                        bootstrap_state: None,
2142                    });
2143                }
2144                let response = ssp2::encode_message(&Message {
2145                    wire_version: ssp2::decode::WIRE_VERSION,
2146                    msg_kind: MsgKind::Response,
2147                    frames,
2148                });
2149                write!(
2150                    socket,
2151                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2152                    response.len()
2153                )
2154                .unwrap();
2155                socket.write_all(&response).unwrap();
2156                if deliver {
2157                    break;
2158                }
2159            }
2160            pushed
2161        });
2162        let (tx, rx) = channel();
2163        let owner_tx = tx.clone();
2164        let owner = std::thread::spawn(move || {
2165            run_owner_thread(
2166                SyncularConfig {
2167                    base_url: Some(format!("http://{address}")),
2168                    auto_sync: true,
2169                    ..Default::default()
2170                },
2171                owner_tx,
2172                rx,
2173                move |event| {
2174                    if event["type"] == "change" && event["batch"]["outcomesChanged"] == true {
2175                        let _ = ack_tx.send(());
2176                    }
2177                    if image_sent.load(Ordering::SeqCst) && event["type"] == "change" {
2178                        let _ = delivered_tx.send(());
2179                    }
2180                },
2181            )
2182        });
2183        let call = |command: Value| {
2184            let (reply, result) = channel();
2185            tx.send(Request::Command { command, reply }).unwrap();
2186            result
2187        };
2188        let created = call(json!({ "method": "create", "params": {
2189            "clientId": "pending-round", "transportEnabled": false, "securityPreflight": true, "schema": { "version": 1, "tables": [{
2190                "name": "todo", "primaryKey": "id", "columns": [
2191                    { "name": "id", "type": "string", "nullable": false },
2192                    { "name": "title", "type": "string", "nullable": false }
2193                ], "scopes": [{ "pattern": "todo:{id}" }]
2194            }] }
2195        } }));
2196        assert!(created.recv().unwrap().get("error").is_none());
2197        assert!(call(json!({ "method": "activateSecurity", "params": {} }))
2198            .recv()
2199            .unwrap()
2200            .get("error")
2201            .is_none());
2202        assert!(call(json!({ "method": "subscribe", "params": { "id": "own", "table": "todo", "scopes": { "id": ["one"] } } })).recv().unwrap().get("error").is_none());
2203        let first = call(json!({ "method": "mutate", "params": { "mutations": [{
2204            "op": "upsert", "table": "todo", "values": { "id": "one", "title": "100/v1" }
2205        }] } }))
2206        .recv()
2207        .unwrap();
2208        let queued_second = call(json!({ "method": "mutate", "params": { "mutations": [{
2209            "op": "patch", "table": "todo", "values": { "id": "one", "title": "200/v2" }
2210        }] } }))
2211        .recv()
2212        .unwrap();
2213        assert_eq!(
2214            call(json!({ "method": "sync" })).recv().unwrap()["result"]["errorCode"],
2215            "sync.offline"
2216        );
2217        assert_eq!(
2218            call(json!({ "method": "connectRealtime" })).recv().unwrap()["error"]["code"],
2219            "sync.offline"
2220        );
2221        assert_eq!(requests.load(Ordering::SeqCst), 0);
2222        let (reply, result) = channel();
2223        tx.send(Request::SetHeaders {
2224            headers: vec![("authorization".into(), "Bearer fresh".into())],
2225            reply,
2226        })
2227        .unwrap();
2228        assert_eq!(result.recv().unwrap()["result"], Value::Null);
2229        assert!(
2230            call(json!({ "method": "setTransportEnabled", "params": { "enabled": true } }))
2231                .recv()
2232                .unwrap()
2233                .get("error")
2234                .is_none()
2235        );
2236        entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
2237        assert!(
2238            call(json!({ "method": "setTransportEnabled", "params": { "enabled": false } }))
2239                .recv()
2240                .unwrap()
2241                .get("error")
2242                .is_none()
2243        );
2244        let started = Instant::now();
2245        let second = call(json!({ "method": "mutate", "params": { "mutations": [{
2246            "op": "patch", "table": "todo", "values": { "id": "one", "title": "300/v3" }
2247        }] } }));
2248        let local_reply = second.recv_timeout(Duration::from_millis(100));
2249        let elapsed = started.elapsed();
2250        let (query_tx, query_rx) = channel();
2251        tx.send(Request::Query {
2252            sql: "SELECT title FROM todo ORDER BY id".into(),
2253            params: Value::Null,
2254            reply: query_tx,
2255        })
2256        .unwrap();
2257        let local_rows = query_rx.recv_timeout(Duration::from_millis(100));
2258        // Release even on the old blocking implementation, so a failing test
2259        // joins its transport and owner rather than leaving network threads.
2260        release_tx.send(()).unwrap();
2261        let second_reply = local_reply
2262            .clone()
2263            .unwrap_or_else(|_| second.recv().unwrap());
2264        let rows = local_rows
2265            .clone()
2266            .unwrap_or_else(|_| query_rx.recv().unwrap());
2267        // Local mutation emits an outcome batch too; wait for both captured commits' applied outcomes.
2268        loop {
2269            ack_rx.recv_timeout(Duration::from_secs(10)).unwrap();
2270            let outcomes = call(json!({ "method": "commitOutcomes" })).recv().unwrap();
2271            if outcomes["result"]["outcomes"]
2272                .as_array()
2273                .unwrap()
2274                .iter()
2275                .filter(|o| o["status"] == "applied")
2276                .count()
2277                == 2
2278            {
2279                break;
2280            }
2281        }
2282        let paused_count = requests.load(Ordering::SeqCst);
2283        assert_eq!(
2284            call(json!({ "method": "sync" })).recv().unwrap()["result"]["errorCode"],
2285            "sync.offline"
2286        );
2287        assert_eq!(requests.load(Ordering::SeqCst), paused_count);
2288        assert!(
2289            call(json!({ "method": "setTransportEnabled", "params": { "enabled": true } }))
2290                .recv()
2291                .unwrap()
2292                .get("error")
2293                .is_none()
2294        );
2295        delivered_rx
2296            .recv_timeout(Duration::from_secs(10))
2297            .expect("ACK schedules the own-image pull without realtime or a manual round");
2298        let (query_tx, query_rx) = channel();
2299        tx.send(Request::Query {
2300            sql: "SELECT title,_sync_version AS version FROM todo WHERE id='one'".into(),
2301            params: Value::Null,
2302            reply: query_tx,
2303        })
2304        .unwrap();
2305        let delivered = query_rx.recv().unwrap();
2306        assert_eq!(delivered["result"]["rows"][0]["title"], "300/v3");
2307        assert_eq!(delivered["result"]["rows"][0]["version"], 3);
2308        let pushed = server.join().unwrap();
2309        tx.send(Request::Shutdown).unwrap();
2310        owner.join().unwrap();
2311        assert!(
2312            local_reply.is_ok(),
2313            "local mutation waited for network reply"
2314        );
2315        assert!(local_rows.is_ok(), "local query waited for network reply");
2316        eprintln!("second local mutation while network pending: {elapsed:?}");
2317        assert_eq!(rows["result"]["rows"].as_array().unwrap().len(), 1);
2318        assert_eq!(
2319            pushed,
2320            vec![
2321                first["result"]["clientCommitId"].as_str().unwrap(),
2322                queued_second["result"]["clientCommitId"].as_str().unwrap(),
2323                second_reply["result"]["clientCommitId"].as_str().unwrap()
2324            ]
2325        );
2326    }
2327
2328    #[cfg(feature = "native-transport")]
2329    #[test]
2330    fn owner_thread_sync_until_idle_reports_budget_exhaustion_with_the_report() {
2331        use ssp2::model::{Change, Frame, Message, MsgKind, Op, SubStatus};
2332        use ssp2::segment::{encode_row, Column, ColumnType, ColumnValue};
2333        use std::io::{ErrorKind, Read, Write};
2334        use std::net::TcpListener;
2335        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2336        use std::sync::mpsc::channel;
2337        use std::sync::Arc;
2338
2339        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2340        listener.set_nonblocking(true).unwrap();
2341        let address = listener.local_addr().unwrap();
2342        let requests = Arc::new(AtomicUsize::new(0));
2343        let stop = Arc::new(AtomicBool::new(false));
2344        let server_requests = Arc::clone(&requests);
2345        let server_stop = Arc::clone(&stop);
2346        let server = std::thread::spawn(move || {
2347            let columns = ["id", "title"].map(|name| Column {
2348                name: name.into(),
2349                ty: ColumnType::String,
2350                nullable: false,
2351            });
2352            while !server_stop.load(Ordering::SeqCst) {
2353                let (mut socket, _) = match listener.accept() {
2354                    Ok(accepted) => accepted,
2355                    Err(error) if error.kind() == ErrorKind::WouldBlock => {
2356                        std::thread::yield_now();
2357                        continue;
2358                    }
2359                    Err(error) => panic!("accept: {error}"),
2360                };
2361                socket.set_nonblocking(false).unwrap();
2362                let round = server_requests.fetch_add(1, Ordering::SeqCst) + 1;
2363                let mut header = Vec::new();
2364                while !header.ends_with(b"\r\n\r\n") {
2365                    let mut byte = [0];
2366                    socket.read_exact(&mut byte).unwrap();
2367                    header.push(byte[0]);
2368                }
2369                let header = String::from_utf8(header).unwrap();
2370                let length: usize = header
2371                    .lines()
2372                    .find_map(|line| {
2373                        let (name, value) = line.split_once(':')?;
2374                        name.eq_ignore_ascii_case("content-length")
2375                            .then(|| value.trim().parse().unwrap())
2376                    })
2377                    .unwrap();
2378                let mut bytes = vec![0; length];
2379                socket.read_exact(&mut bytes).unwrap();
2380                let request = ssp2::decode_message(&bytes).unwrap();
2381                let reset = matches!(
2382                    &request.frames[0],
2383                    Frame::ReqHeader {
2384                        log_epoch: None,
2385                        ..
2386                    }
2387                );
2388                let mut frames = vec![Frame::RespHeader {
2389                    required_schema_version: None,
2390                    latest_schema_version: None,
2391                    log_epoch: Some("epoch".into()),
2392                    reset_required: Some(reset),
2393                }];
2394                if !reset
2395                    && request
2396                        .frames
2397                        .iter()
2398                        .any(|frame| matches!(frame, Frame::Subscription { id, .. } if id == "own"))
2399                {
2400                    frames.push(Frame::SubStart {
2401                        id: "own".into(),
2402                        status: SubStatus::Active,
2403                        reason_code: String::new(),
2404                        effective_scopes: vec![("id".into(), vec!["one".into()])],
2405                        bootstrap: false,
2406                    });
2407                    let mut row = ssp2::primitives::Writer::new();
2408                    encode_row(
2409                        &mut row,
2410                        &columns,
2411                        &vec![
2412                            Some(ColumnValue::String("one".into())),
2413                            Some(ColumnValue::String(format!("v{round}"))),
2414                        ],
2415                    );
2416                    frames.push(Frame::Commit {
2417                        commit_seq: round as i64,
2418                        created_at_ms: 0,
2419                        actor_id: "actor".into(),
2420                        tables: vec!["todo".into()],
2421                        changes: vec![Change {
2422                            table_index: 0,
2423                            row_id: "one".into(),
2424                            op: Op::Upsert,
2425                            row_version: Some(round as i64),
2426                            scopes: vec![("id".into(), "one".into())],
2427                            row: Some(row.into_bytes()),
2428                        }],
2429                    });
2430                    frames.push(Frame::SubEnd {
2431                        next_cursor: round as i64,
2432                        bootstrap_state: None,
2433                    });
2434                }
2435                let response = ssp2::encode_message(&Message {
2436                    wire_version: ssp2::decode::WIRE_VERSION,
2437                    msg_kind: MsgKind::Response,
2438                    frames,
2439                });
2440                write!(
2441                    socket,
2442                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2443                    response.len()
2444                )
2445                .unwrap();
2446                socket.write_all(&response).unwrap();
2447            }
2448        });
2449
2450        let (tx, rx) = channel::<Request>();
2451        let owner_tx = tx.clone();
2452        let owner = std::thread::spawn(move || {
2453            run_owner_thread(
2454                SyncularConfig {
2455                    base_url: Some(format!("http://{address}")),
2456                    auto_sync: false,
2457                    ..Default::default()
2458                },
2459                owner_tx,
2460                rx,
2461                |_| {},
2462            )
2463        });
2464        let call = |command: Value| -> Value {
2465            let (reply, result) = channel();
2466            tx.send(Request::Command { command, reply }).unwrap();
2467            result.recv().unwrap()
2468        };
2469
2470        let created = call(json!({ "method": "create", "params": {
2471            "clientId": "budget-tauri", "transportEnabled": true, "schema": { "version": 1, "tables": [{
2472                "name": "todo", "primaryKey": "id",
2473                "columns": [
2474                    { "name": "id", "type": "string", "nullable": false },
2475                    { "name": "title", "type": "string", "nullable": false }
2476                ],
2477                "scopes": [{ "pattern": "todo:{id}" }]
2478            }] }
2479        } }));
2480        assert!(created.get("error").is_none(), "{created}");
2481        assert!(
2482            call(json!({ "method": "subscribe", "params": { "id": "own", "table": "todo", "scopes": { "id": ["one"] } } }))
2483                .get("error")
2484                .is_none()
2485        );
2486
2487        // An invalid budget is refused before any network round.
2488        let before = requests.load(Ordering::SeqCst);
2489        let invalid = call(json!({ "method": "syncUntilIdle", "params": { "maxRounds": 0 } }));
2490        assert_eq!(
2491            invalid["error"]["code"], "sync.invalid_request",
2492            "{invalid}"
2493        );
2494        assert_eq!(
2495            requests.load(Ordering::SeqCst),
2496            before,
2497            "an invalid budget performs no round"
2498        );
2499
2500        // `sync` ignores an irrelevant maxRounds (0 would be invalid for the
2501        // capped command) and performs one round.
2502        let single = call(json!({ "method": "sync", "params": { "maxRounds": 0 } }));
2503        assert!(single["result"].get("errorCode").is_none(), "{single}");
2504
2505        let before_capped = requests.load(Ordering::SeqCst);
2506        let exhausted = call(json!({ "method": "syncUntilIdle", "params": { "maxRounds": 2 } }));
2507        assert!(
2508            exhausted["result"].get("errorCode").is_none(),
2509            "{exhausted}"
2510        );
2511        assert_eq!(exhausted["result"]["ok"], json!(true), "{exhausted}");
2512        assert_eq!(
2513            exhausted["result"]["budgetExhausted"],
2514            json!(true),
2515            "{exhausted}"
2516        );
2517        assert_eq!(
2518            requests.load(Ordering::SeqCst) - before_capped,
2519            2,
2520            "the capped run made exactly two rounds: {exhausted}"
2521        );
2522        assert_eq!(
2523            exhausted["result"]["report"]["commitsApplied"],
2524            json!(2),
2525            "the aggregate retains every round's work: {exhausted}"
2526        );
2527
2528        tx.send(Request::Shutdown).unwrap();
2529        owner.join().unwrap();
2530        stop.store(true, Ordering::SeqCst);
2531        server.join().unwrap();
2532    }
2533
2534    #[test]
2535    fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
2536        use std::sync::mpsc::channel;
2537
2538        let path =
2539            std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
2540        let config = SyncularConfig {
2541            db_path: Some(path.to_string_lossy().into_owned()),
2542            auto_sync: false,
2543            ..Default::default()
2544        };
2545        let (tx, rx) = channel::<Request>();
2546        let owner_tx = tx.clone();
2547        let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
2548
2549        let (create_tx, create_rx) = channel();
2550        tx.send(Request::Command {
2551            command: json!({
2552                "method": "create",
2553                "params": {
2554                    "clientId": "sidecar-client",
2555                    "schema": { "version": 1, "tables": [] },
2556                    "dbPath": path.to_string_lossy()
2557                }
2558            }),
2559            reply: create_tx,
2560        })
2561        .expect("post create");
2562        assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
2563
2564        let (read_tx, read_rx) = channel::<ReadRequest>();
2565        let read_path = path.to_string_lossy().into_owned();
2566        let owner_tx = tx.clone();
2567        let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
2568
2569        // Model a slow HTTP/WS round on the mutable owner. The dedicated read
2570        // mailbox must still return the durable local snapshot immediately.
2571        let (entered_tx, entered_rx) = channel();
2572        tx.send(Request::Block {
2573            duration: Duration::from_millis(200),
2574            entered: entered_tx,
2575        })
2576        .expect("block owner");
2577        entered_rx.recv().expect("owner entered blocking round");
2578        let (snapshot_tx, snapshot_rx) = channel();
2579        read_tx
2580            .send(ReadRequest::QuerySnapshot {
2581                sql: "SELECT 1 AS value".to_owned(),
2582                params: Vec::new(),
2583                coverage: Vec::new(),
2584                owner: None,
2585                reply: snapshot_tx,
2586            })
2587            .expect("post snapshot");
2588        let snapshot = snapshot_rx
2589            .recv_timeout(Duration::from_millis(50))
2590            .expect("local snapshot must not wait for the owner");
2591        assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
2592
2593        let (failed_tx, failed_rx) = channel();
2594        read_tx
2595            .send(ReadRequest::QuerySnapshot {
2596                sql: "SELECT private_value FROM missing_private_table".to_owned(),
2597                params: Vec::new(),
2598                coverage: Vec::new(),
2599                owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
2600                reply: failed_tx,
2601            })
2602            .expect("post failed snapshot");
2603        assert_eq!(
2604            failed_rx.recv().expect("failed snapshot reply")["error"]["code"],
2605            "client.failed"
2606        );
2607        let (diagnostics_tx, diagnostics_rx) = channel();
2608        tx.send(Request::Command {
2609            command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
2610            reply: diagnostics_tx,
2611        })
2612        .expect("post diagnostics");
2613        assert_eq!(
2614            diagnostics_rx.recv().expect("diagnostics reply")["result"]["queryFailures"][0]["id"],
2615            "queries:missing"
2616        );
2617
2618        let (recovered_tx, recovered_rx) = channel();
2619        read_tx
2620            .send(ReadRequest::QuerySnapshot {
2621                sql: "SELECT 1 AS value".to_owned(),
2622                params: Vec::new(),
2623                coverage: Vec::new(),
2624                owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
2625                reply: recovered_tx,
2626            })
2627            .expect("post recovered snapshot");
2628        assert!(recovered_rx.recv().expect("recovered snapshot reply")["result"].is_object());
2629        let (cleared_tx, cleared_rx) = channel();
2630        tx.send(Request::Command {
2631            command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
2632            reply: cleared_tx,
2633        })
2634        .expect("post cleared diagnostics");
2635        assert_eq!(
2636            cleared_rx.recv().expect("cleared diagnostics reply")["result"]["queryFailures"],
2637            json!([])
2638        );
2639
2640        // More distinct failed owners than the diagnostic cap cannot leave
2641        // extra process-lifetime bookkeeping in the sidecar.
2642        for index in 0..300 {
2643            let (reply, result) = channel();
2644            read_tx
2645                .send(ReadRequest::QuerySnapshot {
2646                    sql: "SELECT value FROM absent_owner_table".to_owned(),
2647                    params: Vec::new(),
2648                    coverage: Vec::new(),
2649                    owner: Some((format!("owner-{index}"), vec!["tasks".to_owned()])),
2650                    reply,
2651                })
2652                .expect("post owner failure");
2653            assert!(result.recv().expect("failure reply")["error"].is_object());
2654        }
2655        let (reply, result) = channel();
2656        tx.send(Request::Command {
2657            command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
2658            reply,
2659        })
2660        .expect("post bounded diagnostics");
2661        let value = result.recv().expect("bounded diagnostics");
2662        let failures = value["result"]["queryFailures"].as_array().unwrap();
2663        assert_eq!(failures.len(), 256);
2664        assert_eq!(failures.last().unwrap()["id"], "owner-299");
2665
2666        read_tx.send(ReadRequest::Shutdown).expect("stop reader");
2667        reader.join().expect("join reader");
2668        tx.send(Request::Shutdown).expect("stop owner");
2669        owner.join().expect("join owner");
2670        std::fs::remove_file(path).expect("remove temp database");
2671    }
2672    #[test]
2673    fn read_failure_json_preserves_retryability_and_storage_details() {
2674        let busy = QueryReadFailure {
2675            code: Some("client.storage_busy"),
2676            sqlite_code: Some(5),
2677            sqlite_message: Some("database is locked".to_owned()),
2678            rollback_failure: Some(Box::new(QueryReadFailure {
2679                code: Some("client.storage_busy"),
2680                sqlite_code: Some(6),
2681                sqlite_message: Some("rollback could not lock".to_owned()),
2682                rollback_failure: None,
2683                message: "local SQLite storage is busy".to_owned(),
2684            })),
2685            message: "local SQLite storage is busy".to_owned(),
2686        };
2687        let value = read_failure_json(busy);
2688        assert_eq!(value["error"]["code"], "client.storage_busy");
2689        assert_eq!(value["error"]["retryable"], true);
2690        assert_eq!(value["error"]["details"]["sqliteCode"], 5);
2691        assert_eq!(
2692            value["error"]["details"]["sqliteMessage"],
2693            "database is locked"
2694        );
2695        assert_eq!(
2696            value["error"]["details"]["rollbackFailure"]["sqliteCode"],
2697            6
2698        );
2699        let corrupt = read_failure_json(QueryReadFailure {
2700            code: Some("sync.local_corrupt"),
2701            sqlite_code: None,
2702            sqlite_message: None,
2703            rollback_failure: None,
2704            message: "persisted local revision is invalid".to_owned(),
2705        });
2706        assert_eq!(corrupt["error"]["code"], "sync.local_corrupt");
2707        assert_eq!(corrupt["error"]["retryable"], false);
2708        assert!(corrupt["error"]["details"].is_null());
2709    }
2710
2711    #[test]
2712    fn snapshot_read_command_is_strict_and_dispatches_by_storage_mode() {
2713        use tauri::test::{mock_builder, mock_context, noop_assets};
2714
2715        let path =
2716            std::env::temp_dir().join(format!("syncular-tauri-snapshot-{}.db", std::process::id()));
2717        let _ = std::fs::remove_file(&path);
2718        let file_app = mock_builder()
2719            .plugin(init(SyncularConfig {
2720                db_path: Some(path.to_string_lossy().into_owned()),
2721                auto_sync: false,
2722                ..Default::default()
2723            }))
2724            .build(mock_context(noop_assets()))
2725            .expect("build file app");
2726        let command = |value: Value| {
2727            tauri::async_runtime::block_on(syncular_command(file_app.handle().clone(), value))
2728                .expect("command reply")
2729        };
2730        let read = |statements: Value, owner: Option<Value>| {
2731            tauri::async_runtime::block_on(syncular_snapshot_read(
2732                file_app.handle().clone(),
2733                statements,
2734                None,
2735                None,
2736                None,
2737                owner,
2738            ))
2739            .expect("read reply")
2740        };
2741        let schema = json!({
2742            "version": 1,
2743            "tables": [{
2744                "name": "todo", "primaryKey": "id",
2745                "columns": [
2746                    { "name": "id", "type": "string", "nullable": false },
2747                    { "name": "n", "type": "integer", "nullable": true }
2748                ],
2749                "scopes": []
2750            }]
2751        });
2752        assert_eq!(
2753            command(json!({ "method": "create", "params": {
2754                "clientId": "c1", "schema": schema.clone()
2755            } }))["result"],
2756            json!({})
2757        );
2758        // §2.4: authored row integers are bounded to the JavaScript safe range
2759        // (±(2^53−1)); the boundary value must queue a commit.
2760        let mutation = command(json!({ "method": "mutate", "params": { "mutations": [{
2761            "op": "upsert", "table": "todo",
2762            "values": { "id": "r1", "n": 9_007_199_254_740_991u64 }
2763        }] } }));
2764        assert!(
2765            mutation["result"]["clientCommitId"].as_str().is_some(),
2766            "safe authoring must queue a commit: {mutation}"
2767        );
2768
2769        // File-backed: one sidecar read returns the revision and every row.
2770        // The literal keeps the driver's full-width i64 `$bigint` envelope
2771        // covered through the same multi-statement read, because the
2772        // synchronized-row contract cannot carry that value through authoring.
2773        let ok = read(
2774            json!([
2775                { "sql": "SELECT id, n FROM todo" },
2776                { "sql": "SELECT CAST('9007199254740993' AS INTEGER) AS n FROM todo" }
2777            ]),
2778            None,
2779        );
2780        assert!(
2781            ok["result"]["revision"].as_str().is_some(),
2782            "expected a decimal revision: {ok}"
2783        );
2784        assert_eq!(ok["result"]["queries"][0][0]["id"], "r1");
2785        assert_eq!(
2786            ok["result"]["queries"][0][0]["n"],
2787            json!(9_007_199_254_740_991u64)
2788        );
2789        assert_eq!(
2790            ok["result"]["queries"][1][0]["n"]["$bigint"],
2791            "9007199254740993"
2792        );
2793
2794        // A malformed statement `params` and a non-array `statements` are
2795        // stable invalid requests, never silently defaulted.
2796        let malformed = read(json!([{ "sql": "SELECT ?", "params": "oops" }]), None);
2797        assert_eq!(
2798            malformed["error"]["code"], "sync.invalid_request",
2799            "{malformed}"
2800        );
2801        let nonarray = read(json!("oops"), None);
2802        assert_eq!(
2803            nonarray["error"]["code"], "sync.invalid_request",
2804            "{nonarray}"
2805        );
2806
2807        // §7.6: an owned sidecar failure reaches diagnostics.
2808        let failed = read(
2809            json!([{ "sql": "SELECT * FROM does_not_exist" }]),
2810            Some(json!({ "id": "queries:snapshot", "tables": ["todo"] })),
2811        );
2812        assert!(failed["error"].is_object(), "{failed}");
2813        let diagnostics = command(json!({ "method": "diagnosticsSnapshot", "params": {} }));
2814        assert_eq!(
2815            diagnostics["result"]["queryFailures"][0]["id"], "queries:snapshot",
2816            "{diagnostics}"
2817        );
2818        // An owned success on the file route clears the recorded failure.
2819        let recovered = read(
2820            json!([{ "sql": "SELECT 1 AS n" }]),
2821            Some(json!({ "id": "queries:snapshot", "tables": ["todo"] })),
2822        );
2823        assert!(recovered["result"].is_object(), "{recovered}");
2824        let cleared = command(json!({ "method": "diagnosticsSnapshot", "params": {} }));
2825        assert_eq!(
2826            cleared["result"]["queryFailures"],
2827            json!([]),
2828            "a successful owned file read clears the failure: {cleared}"
2829        );
2830
2831        command(json!({ "method": "shutdown" }));
2832
2833        // In-memory: the same command falls back to the owner connection.
2834        let memory_app = mock_builder()
2835            .plugin(init(SyncularConfig {
2836                auto_sync: false,
2837                ..Default::default()
2838            }))
2839            .build(mock_context(noop_assets()))
2840            .expect("build memory app");
2841        let memory_command = |value: Value| {
2842            tauri::async_runtime::block_on(syncular_command(memory_app.handle().clone(), value))
2843                .expect("command reply")
2844        };
2845        let memory_read = |statements: Value| {
2846            tauri::async_runtime::block_on(syncular_snapshot_read(
2847                memory_app.handle().clone(),
2848                statements,
2849                None,
2850                None,
2851                None,
2852                None,
2853            ))
2854            .expect("read reply")
2855        };
2856        assert_eq!(
2857            memory_command(json!({ "method": "create", "params": {
2858                "clientId": "c2", "schema": schema
2859            } }))["result"],
2860            json!({})
2861        );
2862        let memory_ok = memory_read(json!([{ "sql": "SELECT 7 AS n" }]));
2863        assert_eq!(memory_ok["result"]["queries"][0][0]["n"], 7, "{memory_ok}");
2864
2865        // The in-memory route uses the owner connection with the same owner
2866        // contract: failure is recorded and a later success clears it.
2867        let memory_failed = tauri::async_runtime::block_on(syncular_snapshot_read(
2868            memory_app.handle().clone(),
2869            json!([{ "sql": "SELECT * FROM does_not_exist" }]),
2870            None,
2871            None,
2872            None,
2873            Some(json!({ "id": "queries:memory", "tables": ["todo"] })),
2874        ))
2875        .expect("read reply");
2876        assert!(memory_failed["error"].is_object(), "{memory_failed}");
2877        let memory_diagnostics =
2878            memory_command(json!({ "method": "diagnosticsSnapshot", "params": {} }));
2879        assert_eq!(
2880            memory_diagnostics["result"]["queryFailures"][0]["id"], "queries:memory",
2881            "{memory_diagnostics}"
2882        );
2883        let memory_recovered = tauri::async_runtime::block_on(syncular_snapshot_read(
2884            memory_app.handle().clone(),
2885            json!([{ "sql": "SELECT 1 AS n" }]),
2886            None,
2887            None,
2888            None,
2889            Some(json!({ "id": "queries:memory", "tables": ["todo"] })),
2890        ))
2891        .expect("read reply");
2892        assert!(memory_recovered["result"].is_object(), "{memory_recovered}");
2893        let memory_cleared =
2894            memory_command(json!({ "method": "diagnosticsSnapshot", "params": {} }));
2895        assert_eq!(
2896            memory_cleared["result"]["queryFailures"],
2897            json!([]),
2898            "a successful owned in-memory read clears the failure: {memory_cleared}"
2899        );
2900
2901        let _ = std::fs::remove_file(&path);
2902    }
2903
2904    #[test]
2905    fn a_file_backed_owned_read_serves_the_same_reply_on_both_public_routes() {
2906        use tauri::test::{mock_builder, mock_context, noop_assets};
2907
2908        let path = std::env::temp_dir().join(format!(
2909            "syncular-tauri-read-routes-{}.db",
2910            std::process::id()
2911        ));
2912        let _ = std::fs::remove_file(&path);
2913        let app = mock_builder()
2914            .plugin(init(SyncularConfig {
2915                db_path: Some(path.to_string_lossy().into_owned()),
2916                auto_sync: false,
2917                ..Default::default()
2918            }))
2919            .build(mock_context(noop_assets()))
2920            .expect("build file app");
2921        let command = |value: Value| {
2922            tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
2923                .expect("command reply")
2924        };
2925        let dedicated_snapshot = |sql: &str, owner: Option<Value>| {
2926            tauri::async_runtime::block_on(syncular_query_snapshot(
2927                app.handle().clone(),
2928                sql.to_owned(),
2929                None,
2930                None,
2931                owner,
2932            ))
2933            .expect("dedicated snapshot reply")
2934        };
2935        let dedicated_read = |statements: Value, owner: Option<Value>| {
2936            tauri::async_runtime::block_on(syncular_snapshot_read(
2937                app.handle().clone(),
2938                statements,
2939                None,
2940                None,
2941                None,
2942                owner,
2943            ))
2944            .expect("dedicated read reply")
2945        };
2946        let ids = || -> Vec<String> {
2947            command(json!({ "method": "diagnosticsSnapshot", "params": {} }))["result"]
2948                ["queryFailures"]
2949                .as_array()
2950                .expect("query failures array")
2951                .iter()
2952                .map(|failure| failure["id"].as_str().expect("failure id").to_owned())
2953                .collect()
2954        };
2955        assert_eq!(
2956            command(json!({ "method": "create", "params": {
2957                "clientId": "read-routes",
2958                "schema": { "version": 1, "tables": [] },
2959            } }))["result"],
2960            json!({})
2961        );
2962        let owner = json!({ "id": "queries:routes", "tables": ["tasks"] });
2963
2964        // A successful owned read has one reply shape on both public routes,
2965        // because the generic entry is normalized onto the file sidecar.
2966        assert_eq!(
2967            command(json!({ "method": "querySnapshot", "params": {
2968                "sql": "SELECT 1 AS value",
2969                "owner": owner,
2970            } })),
2971            dedicated_snapshot("SELECT 1 AS value", Some(owner.clone())),
2972            "querySnapshot serves one reply on both routes"
2973        );
2974        assert_eq!(
2975            command(json!({ "method": "snapshotRead", "params": {
2976                "statements": [{ "sql": "SELECT 1 AS value" }],
2977                "owner": owner,
2978            } })),
2979            dedicated_read(json!([{ "sql": "SELECT 1 AS value" }]), Some(owner.clone()),),
2980            "snapshotRead serves one reply on both routes"
2981        );
2982        assert_eq!(ids(), Vec::<String>::new(), "clean reads record nothing");
2983
2984        // A failure on either route is cleared by a success on the other.
2985        assert!(command(json!({ "method": "querySnapshot", "params": {
2986            "sql": "SELECT value FROM absent_owner_table",
2987            "owner": owner,
2988        } }))["error"]
2989            .is_object());
2990        assert_eq!(ids(), ["queries:routes"]);
2991        assert!(
2992            dedicated_read(json!([{ "sql": "SELECT 1 AS value" }]), Some(owner.clone()),)["result"]
2993                .is_object()
2994        );
2995        assert_eq!(
2996            ids(),
2997            Vec::<String>::new(),
2998            "the dedicated route cleared the generic route's failure"
2999        );
3000        assert!(
3001            dedicated_snapshot("SELECT value FROM absent_owner_table", Some(owner.clone()))
3002                ["error"]
3003                .is_object()
3004        );
3005        assert_eq!(ids(), ["queries:routes"]);
3006        assert!(command(json!({ "method": "snapshotRead", "params": {
3007            "statements": [{ "sql": "SELECT 1 AS value" }],
3008            "owner": owner,
3009        } }))["result"]
3010            .is_object());
3011        assert_eq!(
3012            ids(),
3013            Vec::<String>::new(),
3014            "the generic route cleared the dedicated route's failure"
3015        );
3016
3017        // Required fields and malformed shapes keep the errors they had before
3018        // the delegation, so nothing about invalid requests changes.
3019        let missing_sql = command(json!({ "method": "querySnapshot", "params": {} }));
3020        assert_eq!(missing_sql["error"]["code"], "client.failed");
3021        assert_eq!(missing_sql["error"]["message"], "querySnapshot missing sql");
3022        let malformed = command(json!({ "method": "querySnapshot", "params": {
3023            "sql": "SELECT 1",
3024            "params": "oops",
3025            "owner": owner,
3026        } }));
3027        assert_eq!(malformed["error"]["code"], "client.failed");
3028        assert_eq!(
3029            malformed["error"]["message"],
3030            "querySnapshot params must be a list"
3031        );
3032        let malformed = command(json!({ "method": "snapshotRead", "params": {
3033            "statements": "oops",
3034            "owner": owner,
3035        } }));
3036        assert_eq!(malformed["error"]["code"], "sync.invalid_request");
3037        assert_eq!(
3038            malformed["error"]["message"],
3039            "sync.invalid_request: snapshotRead statements must be a list"
3040        );
3041
3042        let _ = std::fs::remove_file(&path);
3043    }
3044
3045    #[test]
3046    fn a_delayed_owner_receives_only_reader_failure_transitions() {
3047        use std::sync::mpsc::channel;
3048
3049        let path = std::env::temp_dir().join(format!(
3050            "syncular-tauri-read-transitions-{}.db",
3051            std::process::id()
3052        ));
3053        let _ = std::fs::remove_file(&path);
3054        // A real replica: the sidecar's snapshot read needs the bookkeeping
3055        // tables the client creates on open, and the absent table drives the
3056        // owned failures.
3057        drop(
3058            syncular_client::SyncClient::open_path(
3059                "read-transitions".to_owned(),
3060                &json!({ "version": 1, "tables": [] }),
3061                syncular_client::ClientLimits::default(),
3062                &path.to_string_lossy(),
3063            )
3064            .expect("create replica"),
3065        );
3066
3067        let (owner_tx, owner_rx) = channel::<Request>();
3068        let (read_tx, read_rx) = channel::<ReadRequest>();
3069        let read_path = path.to_string_lossy().into_owned();
3070        // The owning thread is deliberately absent: it neither records nor
3071        // publishes anything, so the reader has to decide every transition on
3072        // its own and must never wait for the owner to drain an earlier message.
3073        let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
3074
3075        let clean = "SELECT 1 AS value";
3076        let failing = "SELECT value FROM absent_owner_table";
3077        let owner = |id: &str, tables: &[&str]| {
3078            Some((
3079                id.to_owned(),
3080                tables.iter().map(|table| (*table).to_owned()).collect(),
3081            ))
3082        };
3083        let query_snapshot = |sql: &str, owner: Option<(String, Vec<String>)>| {
3084            let (reply, result) = channel();
3085            read_tx
3086                .send(ReadRequest::QuerySnapshot {
3087                    sql: sql.to_owned(),
3088                    params: Vec::new(),
3089                    coverage: Vec::new(),
3090                    owner,
3091                    reply,
3092                })
3093                .expect("post snapshot read");
3094            result.recv().expect("snapshot reply")
3095        };
3096        let snapshot_read = |sql: &str, owner: Option<(String, Vec<String>)>| {
3097            let (reply, result) = channel();
3098            read_tx
3099                .send(ReadRequest::SnapshotRead {
3100                    statements: vec![(sql.to_owned(), Vec::<syncular_client::QueryValue>::new())],
3101                    coverage: Vec::new(),
3102                    subscriptions: Vec::new(),
3103                    commit_ids: Vec::new(),
3104                    owner,
3105                    reply,
3106                })
3107                .expect("post multi-statement read");
3108            result.recv().expect("multi-statement read reply")
3109        };
3110        /// Drain the queued owner messages without processing them: the count
3111        /// and each outcome are the assertion.
3112        fn drain(owner_rx: &Receiver<Request>) -> Vec<Option<QueryReadFailure>> {
3113            let mut messages = Vec::new();
3114            while let Ok(Request::QueryRead { failure, .. }) = owner_rx.try_recv() {
3115                messages.push(failure);
3116            }
3117            messages
3118        }
3119
3120        // Failure, then success, then many successes, with the owner never
3121        // draining: exactly the failure and the one clearing success are sent.
3122        assert!(
3123            query_snapshot(failing, owner("queries:transition", &["tasks"]))["error"].is_object()
3124        );
3125        assert!(
3126            query_snapshot(clean, owner("queries:transition", &["tasks"]))["result"].is_object()
3127        );
3128        for round in 0..8 {
3129            assert!(
3130                query_snapshot(clean, owner("queries:transition", &["tasks"]))["result"]
3131                    .is_object(),
3132                "{round}"
3133            );
3134        }
3135        let transitions = drain(&owner_rx);
3136        assert_eq!(
3137            transitions.len(),
3138            2,
3139            "a delayed owner sees the failure and one clear, not one message per read"
3140        );
3141        assert!(transitions[0].is_some(), "the failure is reported");
3142        assert!(transitions[1].is_none(), "the clearing success is reported");
3143
3144        // The same transitions hold on the SnapshotRead route.
3145        assert!(
3146            snapshot_read(failing, owner("queries:transition", &["tasks"]))["error"].is_object()
3147        );
3148        assert!(
3149            snapshot_read(clean, owner("queries:transition", &["tasks"]))["result"].is_object()
3150        );
3151        for round in 0..8 {
3152            assert!(
3153                snapshot_read(clean, owner("queries:transition", &["tasks"]))["result"].is_object(),
3154                "{round}"
3155            );
3156        }
3157        let transitions = drain(&owner_rx);
3158        assert_eq!(transitions.len(), 2);
3159        assert!(transitions[0].is_some());
3160        assert!(transitions[1].is_none());
3161
3162        // A recovered owner reports nothing further while it stays clean.
3163        for round in 0..16 {
3164            assert!(
3165                query_snapshot(clean, owner("queries:transition", &["tasks"]))["result"]
3166                    .is_object(),
3167                "{round}"
3168            );
3169            assert!(
3170                snapshot_read(clean, owner("queries:transition", &["tasks"]))["result"].is_object(),
3171                "{round}"
3172            );
3173        }
3174        assert!(
3175            drain(&owner_rx).is_empty(),
3176            "a clean success loop enqueues nothing"
3177        );
3178
3179        // A changed failure signature still clears on the next success.
3180        assert!(
3181            query_snapshot(failing, owner("queries:signature", &["tasks"]))["error"].is_object()
3182        );
3183        assert!(
3184            query_snapshot(failing, owner("queries:signature", &["docs"]))["error"].is_object()
3185        );
3186        assert!(!drain(&owner_rx).is_empty(), "both failures are reported");
3187        assert!(query_snapshot(clean, owner("queries:signature", &["docs"]))["result"].is_object());
3188        let transitions = drain(&owner_rx);
3189        assert_eq!(transitions.len(), 1);
3190        assert!(
3191            transitions[0].is_none(),
3192            "a changed signature keeps the recovery report"
3193        );
3194        assert!(query_snapshot(clean, owner("queries:signature", &["docs"]))["result"].is_object());
3195        assert!(drain(&owner_rx).is_empty());
3196
3197        // A fresh failure re-arms the transition even for the same owner.
3198        assert!(
3199            query_snapshot(failing, owner("queries:signature", &["docs"]))["error"].is_object()
3200        );
3201        assert!(query_snapshot(clean, owner("queries:signature", &["docs"]))["result"].is_object());
3202        let transitions = drain(&owner_rx);
3203        assert_eq!(transitions.len(), 2);
3204        assert!(transitions[0].is_some());
3205        assert!(transitions[1].is_none());
3206
3207        // Keep the same bounded retention order as the owner, even while its
3208        // mailbox is paused. An unchanged repeat must not rescue the oldest id.
3209        for index in 0..MAX_DIAGNOSTIC_QUERY_FAILURES {
3210            assert!(
3211                query_snapshot(failing, owner(&format!("bounded-{index}"), &["tasks"]))["error"]
3212                    .is_object()
3213            );
3214        }
3215        assert!(query_snapshot(failing, owner("bounded-0", &["tasks"]))["error"].is_object());
3216        assert!(query_snapshot(failing, owner("bounded-1", &["changed"]))["error"].is_object());
3217        assert!(query_snapshot(failing, owner("bounded-new", &["tasks"]))["error"].is_object());
3218        assert_eq!(drain(&owner_rx).len(), MAX_DIAGNOSTIC_QUERY_FAILURES + 3);
3219        assert!(query_snapshot(clean, owner("bounded-0", &["tasks"]))["result"].is_object());
3220        assert!(
3221            drain(&owner_rx).is_empty(),
3222            "the evicted oldest id has no retained failure to clear"
3223        );
3224        assert!(snapshot_read(clean, owner("bounded-1", &["changed"]))["result"].is_object());
3225        let retained = drain(&owner_rx);
3226        assert_eq!(retained.len(), 1);
3227        assert!(
3228            retained[0].is_none(),
3229            "the changed, retained signature still clears"
3230        );
3231
3232        read_tx.send(ReadRequest::Shutdown).expect("stop reader");
3233        reader.join().expect("join reader");
3234        std::fs::remove_file(path).expect("remove replica file");
3235    }
3236
3237    #[test]
3238    fn owned_read_failures_clear_from_either_route_and_keep_journal_order() {
3239        use tauri::test::{mock_builder, mock_context, noop_assets};
3240
3241        let path = std::env::temp_dir().join(format!(
3242            "syncular-tauri-read-order-{}.db",
3243            std::process::id()
3244        ));
3245        let _ = std::fs::remove_file(&path);
3246        let app = mock_builder()
3247            .plugin(init(SyncularConfig {
3248                db_path: Some(path.to_string_lossy().into_owned()),
3249                auto_sync: false,
3250                ..Default::default()
3251            }))
3252            .build(mock_context(noop_assets()))
3253            .expect("build file app");
3254        let command = |value: Value| {
3255            tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
3256                .expect("command reply")
3257        };
3258        let read = |statements: Value, owner: Value| {
3259            tauri::async_runtime::block_on(syncular_snapshot_read(
3260                app.handle().clone(),
3261                statements,
3262                None,
3263                None,
3264                None,
3265                Some(owner),
3266            ))
3267            .expect("read reply")
3268        };
3269        let failures = || {
3270            command(json!({ "method": "diagnosticsSnapshot", "params": {} }))["result"]
3271                ["queryFailures"]
3272                .clone()
3273        };
3274        let ids = || -> Vec<String> {
3275            failures()
3276                .as_array()
3277                .expect("query failures array")
3278                .iter()
3279                .map(|failure| failure["id"].as_str().expect("failure id").to_owned())
3280                .collect()
3281        };
3282        let owner = |id: &str, tables: Value| json!({ "id": id, "tables": tables });
3283        // The generic command entry, carrying an owner. In file-backed mode it
3284        // is normalized onto the same sidecar as the dedicated commands, so one
3285        // producer owns the transition bookkeeping.
3286        let owned_command = |id: &str, tables: Value, sql: &str| {
3287            command(json!({ "method": "snapshotRead", "params": {
3288                "statements": [{ "sql": sql, "params": [] }],
3289                "owner": owner(id, tables),
3290            } }))
3291        };
3292        assert_eq!(
3293            command(json!({ "method": "create", "params": {
3294                "clientId": "read-routes",
3295                "schema": { "version": 1, "tables": [] },
3296            } }))["result"],
3297            json!({}),
3298            "the config db_path opens the replica the sidecar reads"
3299        );
3300
3301        // Journal retention: an unchanged failure signature keeps its position,
3302        // a changed one moves last.
3303        for (id, tables) in [
3304            ("owner-x", json!(["tasks"])),
3305            ("owner-y", json!(["docs"])),
3306            ("owner-x", json!(["tasks"])),
3307        ] {
3308            assert!(
3309                owned_command(id, tables, "SELECT value FROM absent_owner_table")["error"]
3310                    .is_object()
3311            );
3312        }
3313        assert_eq!(
3314            ids(),
3315            ["owner-x", "owner-y"],
3316            "an unchanged failure keeps its journal position"
3317        );
3318        assert!(owned_command(
3319            "owner-x",
3320            json!(["other"]),
3321            "SELECT value FROM absent_owner_table"
3322        )["error"]
3323            .is_object());
3324        assert_eq!(
3325            ids(),
3326            ["owner-y", "owner-x"],
3327            "a changed failure signature moves the entry last"
3328        );
3329
3330        // A success on one read route clears the failure the other route had
3331        // the sidecar report, and the journal keeps rejection order.
3332        assert!(read(
3333            json!([{ "sql": "SELECT 1 AS value" }]),
3334            owner("owner-x", json!(["tasks"]))
3335        )["result"]
3336            .is_object());
3337        assert_eq!(ids(), ["owner-y"], "the sidecar success cleared owner-x");
3338        assert!(read(
3339            json!([{ "sql": "SELECT 1 AS value" }]),
3340            owner("owner-y", json!(["docs"]))
3341        )["result"]
3342            .is_object());
3343        assert_eq!(ids(), Vec::<String>::new());
3344
3345        assert!(owned_command(
3346            "owner-z",
3347            json!(["tasks"]),
3348            "SELECT value FROM absent_owner_table"
3349        )["error"]
3350            .is_object());
3351        assert_eq!(ids(), ["owner-z"]);
3352        assert!(read(
3353            json!([{ "sql": "SELECT 1 AS value" }]),
3354            owner("owner-z", json!(["tasks"]))
3355        )["result"]
3356            .is_object());
3357        assert_eq!(
3358            ids(),
3359            Vec::<String>::new(),
3360            "a success on one route clears the other route's failure"
3361        );
3362
3363        assert!(read(
3364            json!([{ "sql": "SELECT value FROM absent_owner_table" }]),
3365            owner("owner-w", json!(["tasks"]))
3366        )["error"]
3367            .is_object());
3368        assert_eq!(ids(), ["owner-w"]);
3369        assert!(
3370            owned_command("owner-w", json!(["tasks"]), "SELECT 1 AS value")["result"].is_object()
3371        );
3372        assert_eq!(
3373            ids(),
3374            Vec::<String>::new(),
3375            "the generic route's success clears the sidecar failure"
3376        );
3377
3378        // Clean sidecar reads never enter the journal.
3379        for round in 0..8 {
3380            assert!(
3381                read(
3382                    json!([{ "sql": "SELECT 1 AS value" }]),
3383                    owner("owner-clean", json!(["tasks"]))
3384                )["result"]
3385                    .is_object(),
3386                "{round}"
3387            );
3388        }
3389        assert_eq!(ids(), Vec::<String>::new());
3390
3391        let _ = std::fs::remove_file(&path);
3392    }
3393}