Skip to main content

tauri_plugin_syncular/
core.rs

1//! The transport-agnostic, Tauri-free CORE of the plugin.
2//!
3//! Everything here is plain Rust with no dependency on the `tauri` crate, so
4//! it is unit-testable without a window or a mock runtime. The Tauri shell
5//! (see `lib.rs`) is a thin layer that owns one [`SyncularCore`] on a
6//! dedicated thread, forwards mutable commands and raw queries to it, and pumps
7//! drained events onto the `syncular://event` Tauri channel. The shell's
8//! file-backed atomic snapshot sidecar is intentionally outside this mutable
9//! core and uses a separate read-only SQLite connection.
10//!
11//! This mirrors the `syncular-ffi` `Handle`: one owned [`SyncClient`], one
12//! owned [`HostTransport`] (native HTTP+WS via the `native-transport` feature),
13//! and an exact core-output event queue. The plugin is the THIRD consumer of the shared
14//! `syncular-command` router (after the conformance shim and the FFI core), so
15//! the command surface stays conformance-locked.
16//!
17//! ## Thread-safety, honestly
18//!
19//! [`SyncClient`] is synchronous and NOT `Sync` — it owns a rusqlite
20//! connection. Exactly one thread owns the mutable core, and all mutable access
21//! arrives through a command mailbox (an mpsc channel). Tauri commands never
22//! touch that client directly. The background host loop (§8.4) runs on the same
23//! owner, so the mutable connection is never accessed concurrently. The shell
24//! may independently read the file database through SQLite's snapshot model;
25//! it does not access this `SyncClient`.
26
27use std::collections::VecDeque;
28
29use serde_json::{json, Value};
30use syncular_client::{
31    ClientDiagnosticsRequest, ClientDiagnosticsSnapshot, SyncClient, SyncIntent,
32};
33use syncular_command::{dispatch, CreateEffects};
34
35use crate::transport::{self, HostTransport};
36
37/// One client-observable event (§8 realtime signals + §6 conflicts + §1.6
38/// schema floor + §7.3 lease). JSON-able; delivered onto the Tauri channel.
39/// The same event vocabulary the FFI `poll_event` surfaces.
40#[derive(Debug, Clone)]
41pub struct Event {
42    pub json: Value,
43}
44
45/// The Tauri-free core: one client, its owned transport, explicit scheduling
46/// state, and the pending exact-event queue. Lives on ONE owning thread.
47pub struct SyncularCore {
48    pub progress_listener: Option<syncular_client::SyncProgressListener>,
49    progress_subscription: Option<syncular_client::ProgressSubscription>,
50    client: Option<SyncClient>,
51    transport: HostTransport,
52    effects: CreateEffects,
53    queue: VecDeque<Event>,
54    last_diagnostics_snapshot: Option<ClientDiagnosticsSnapshot>,
55    /// Diagnostics snapshots are computed only after a consumer registers —
56    /// the analogue of the web client's `ClientDiagnosticsEmitter.observed`.
57    /// Set by the explicit `enableDiagnostics` command and by the first
58    /// `diagnosticsSnapshot` pull (the devtools attach signal).
59    diagnostics_observed: bool,
60    interactive_sync: bool,
61    background_sync_ms: Option<u64>,
62}
63
64impl SyncularCore {
65    /// Build a core from the plugin config JSON (`baseUrl`, `headers`, …). A
66    /// `baseUrl` under the `native-transport` feature owns a real HTTP+WS
67    /// transport; without it the core is client-local only (tests, offline).
68    pub fn new(config: &Value) -> Result<Self, String> {
69        Self::new_with_notify(config, None)
70    }
71
72    pub fn new_with_notify(
73        config: &Value,
74        notify: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
75    ) -> Result<Self, String> {
76        let transport = HostTransport::from_config_with_notify(config, notify)?;
77        Ok(SyncularCore {
78            progress_listener: None,
79            progress_subscription: None,
80            client: None,
81            transport,
82            effects: CreateEffects::default(),
83            queue: VecDeque::new(),
84            last_diagnostics_snapshot: None,
85            diagnostics_observed: false,
86            interactive_sync: false,
87            background_sync_ms: None,
88        })
89    }
90
91    /// Run one JSON command (`{"method","params"}`) through the shared router,
92    /// then drain inbound realtime traffic and exact core events. Returns the
93    /// driver-protocol `{"result"|"error"}` reply.
94    pub fn command(&mut self, command: &Value) -> Value {
95        let method = command.get("method").and_then(Value::as_str).unwrap_or("");
96        let params = command.get("params").cloned().unwrap_or(Value::Null);
97        if method == "enableDiagnostics" {
98            // Host-local registration signal (the Tauri event channel carries
99            // no listener count). Emission starts on this drain: the reset
100            // fingerprint guarantees the observer receives a first snapshot.
101            self.diagnostics_observed = true;
102            self.last_diagnostics_snapshot = None;
103            self.drain_realtime();
104            self.drain_core_outputs();
105            self.emit_diagnostics_if_changed();
106            return json!({ "result": {} });
107        }
108        if method == "diagnosticsSnapshot" {
109            // A direct pull proves a diagnostics consumer exists; keep the
110            // pushed snapshots flowing for it from here on.
111            self.diagnostics_observed = true;
112        }
113        let result = dispatch(
114            &mut self.transport,
115            &mut self.client,
116            &mut self.effects,
117            method,
118            &params,
119        );
120        if method == "create" && result.is_ok() {
121            self.progress_subscription = self.client.as_ref().and_then(|client| {
122                self.progress_listener.clone().map(|listener| {
123                    client
124                        .progress()
125                        .subscribe(move |progress| listener(progress))
126                })
127            });
128        }
129        if method == "shutdown" {
130            self.progress_subscription = None;
131        }
132        if method == "create" {
133            self.last_diagnostics_snapshot = None;
134            self.transport.set_signed_urls(self.effects.signed_urls);
135        }
136        if method == "beginSecurityPreflight"
137            || method == "shutdown"
138            || (method == "create"
139                && params
140                    .get("securityPreflight")
141                    .and_then(Value::as_bool)
142                    .unwrap_or(false))
143        {
144            self.interactive_sync = false;
145            self.background_sync_ms = None;
146        }
147        if let Ok(value) = &result {
148            if value.pointer("/effects/sync/kind").and_then(Value::as_str) == Some("interactive") {
149                self.interactive_sync = true;
150            }
151        }
152        self.drain_realtime();
153        self.drain_core_outputs();
154        self.emit_diagnostics_if_changed();
155        match result {
156            Ok(mut value) => {
157                // The router's effects are consumed by this native host above;
158                // they are not part of the public command acknowledgement.
159                if let Some(object) = value.as_object_mut() {
160                    object.remove("effects");
161                }
162                json!({ "result": value })
163            }
164            Err((code, message)) => json!({ "error": { "code": code, "message": message } }),
165        }
166    }
167
168    /// The `syncular_query` fast path: arbitrary read-only SQL over the local
169    /// database. Routed through the same `query` command so there is one
170    /// implementation (the router owns it); this wrapper spares the JS bridge
171    /// from wrapping the method/params envelope for the hot live-query path.
172    pub fn query(&mut self, sql: &str, params: Value) -> Value {
173        let bind = match params {
174            Value::Null => Value::Array(Vec::new()),
175            other => other,
176        };
177        self.command(&json!({ "method": "query", "params": { "sql": sql, "params": bind } }))
178    }
179
180    /// Consume the next coalesced host schedule. Interactive work preempts a
181    /// pending retry; background work keeps the earliest real deadline.
182    pub fn take_sync_intent(&mut self) -> SyncIntent {
183        if std::mem::take(&mut self.interactive_sync) {
184            self.background_sync_ms = None;
185            SyncIntent::Interactive
186        } else if let Some(delay_ms) = self.background_sync_ms.take() {
187            SyncIntent::Background { delay_ms }
188        } else {
189            SyncIntent::None
190        }
191    }
192
193    /// Run one `syncUntilIdle` round for the background host loop, deriving
194    /// events afterwards. A no-op (empty reply) before `create`.
195    pub fn sync_until_idle(&mut self) -> Value {
196        if self.client.is_none() {
197            return json!({ "result": null });
198        }
199        self.command(&json!({ "method": "syncUntilIdle", "params": {} }))
200    }
201
202    /// Owner-mailbox wake from the native realtime reader.
203    pub fn poll_transport(&mut self) {
204        self.drain_realtime();
205        self.drain_core_outputs();
206        self.emit_diagnostics_if_changed();
207    }
208
209    /// Drain every event queued since the last call (the host thread pushes
210    /// them onto the Tauri channel). Mirrors the FFI `poll_event`, batched.
211    pub fn drain_events(&mut self) -> Vec<Event> {
212        self.queue.drain(..).collect()
213    }
214
215    /// Replace the transport's request headers. Rotating
216    /// auth without tearing the plugin down). See
217    /// `HostTransport::set_headers` for the HTTP/WS pickup semantics.
218    pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
219        self.transport.set_headers(headers);
220    }
221
222    /// Release the socket/reader thread. Idempotent.
223    pub fn shutdown(&mut self) {
224        if let Some(mut client) = self.client.take() {
225            client.disconnect_realtime(&mut self.transport);
226            // Teardown barrier, not a quarantine: persisting the gate here
227            // would refuse every later plain create against this replica.
228            client.seal_security_on_teardown();
229        }
230        self.interactive_sync = false;
231        self.background_sync_ms = None;
232        // The TS driver clears its diagnostics listeners on dispose; require
233        // a fresh registration after any restart.
234        self.diagnostics_observed = false;
235        self.transport.shutdown();
236    }
237
238    fn push(&mut self, json: Value) {
239        self.queue.push_back(Event { json });
240    }
241
242    /// Feed buffered inbound WS frames to the client (which may ack back through
243    /// the same transport). A no-op without a native socket.
244    fn drain_realtime(&mut self) {
245        if self.client.is_none() {
246            return;
247        }
248        let frames = self.transport.take_inbound();
249        for frame in frames {
250            match frame {
251                transport::Inbound::Text(text) => {
252                    if is_presence_control(&text) {
253                        self.push(json!({ "type": "presence" }));
254                    }
255                    if let Some(client) = self.client.as_mut() {
256                        client.on_realtime_text(&text);
257                    }
258                }
259                transport::Inbound::Binary(bytes) => {
260                    if let Some(client) = self.client.as_mut() {
261                        client.on_realtime_binary(&mut self.transport, &bytes);
262                    }
263                }
264            }
265        }
266    }
267
268    /// Drain exact observer batches and sync intents produced by the Rust core.
269    fn drain_core_outputs(&mut self) {
270        let Some(client) = self.client.as_mut() else {
271            return;
272        };
273        let batches = client.drain_change_batches();
274        let intents = client.drain_sync_intents();
275        for batch in batches {
276            self.push(json!({ "type": "change", "batch": batch }));
277        }
278        for intent in intents {
279            match intent {
280                SyncIntent::Interactive => self.interactive_sync = true,
281                SyncIntent::Background { delay_ms } => {
282                    self.background_sync_ms = Some(
283                        self.background_sync_ms
284                            .map_or(delay_ms, |current| current.min(delay_ms)),
285                    );
286                }
287                SyncIntent::None => {}
288            }
289        }
290    }
291
292    /// Emit one privacy-safe snapshot only when a diagnostics consumer has
293    /// registered (`diagnostics_observed`) AND durable/client status changed.
294    /// The observer gate keeps the per-command snapshot work (subscription
295    /// scan, PRAGMA page counts, SUM aggregates, serialization) off every
296    /// unobserved command and transport poll.
297    /// `capturedAtMs` is excluded from the fingerprint so polling and read-only
298    /// commands do not create event noise. Expected-but-unregistered intent is
299    /// request-local and is obtained through `diagnosticsSnapshot` directly.
300    fn emit_diagnostics_if_changed(&mut self) {
301        if !self.diagnostics_observed {
302            return;
303        }
304        let Some(client) = self.client.as_ref() else {
305            return;
306        };
307        if client.security_preflight() {
308            return;
309        }
310        let Ok(snapshot) = client.diagnostics_snapshot(&ClientDiagnosticsRequest::default()) else {
311            return;
312        };
313        if let Some(previous) = &mut self.last_diagnostics_snapshot {
314            // Capture time is observation metadata, excluded from evidence equality.
315            previous.captured_at_ms = snapshot.captured_at_ms;
316            if previous == &snapshot {
317                return;
318            }
319        }
320        let event = json!({ "type": "diagnostics", "snapshot": snapshot });
321        self.last_diagnostics_snapshot = Some(snapshot);
322        self.push(event);
323    }
324}
325
326/// A presence fanout control frame (§8.6.2) — `{"event":"presence",...}`, the
327/// one inbound realtime event a native host surfaces directly.
328fn is_presence_control(text: &str) -> bool {
329    serde_json::from_str::<Value>(text)
330        .ok()
331        .and_then(|v| {
332            v.get("event")
333                .and_then(Value::as_str)
334                .map(|e| e == "presence")
335        })
336        .unwrap_or(false)
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    fn simple_schema() -> Value {
344        json!({
345            "version": 1,
346            "tables": [{
347                "name": "todo",
348                "primaryKey": "id",
349                "columns": [
350                    { "name": "id", "type": "string", "nullable": false },
351                    { "name": "title", "type": "string", "nullable": false },
352                    { "name": "done", "type": "boolean", "nullable": false }
353                ],
354                "scopes": []
355            }]
356        })
357    }
358
359    fn create(core: &mut SyncularCore) {
360        let reply = core.command(&json!({
361            "method": "create",
362            "params": { "clientId": "c1", "schema": simple_schema() }
363        }));
364        assert_eq!(reply["result"], json!({}), "create ok: {reply}");
365    }
366
367    #[test]
368    fn progress_sink_runs_before_the_sync_command_returns() {
369        use std::sync::{
370            atomic::{AtomicBool, Ordering},
371            Arc, Mutex,
372        };
373        let mut core = SyncularCore::new(&json!({})).unwrap();
374        let running = Arc::new(AtomicBool::new(false));
375        let observed = Arc::new(Mutex::new(Vec::new()));
376        let in_command = running.clone();
377        let events = observed.clone();
378        core.progress_listener = Some(Arc::new(move |progress| {
379            events
380                .lock()
381                .unwrap()
382                .push((in_command.load(Ordering::SeqCst), progress.state));
383        }));
384        create(&mut core);
385        running.store(true, Ordering::SeqCst);
386        core.command(&json!({ "method": "sync" }));
387        running.store(false, Ordering::SeqCst);
388        assert_eq!(
389            *observed.lock().unwrap(),
390            vec![
391                (true, syncular_client::ProgressState::Running),
392                (true, syncular_client::ProgressState::Failed)
393            ]
394        );
395    }
396
397    #[test]
398    fn command_round_trip_create_mutate_query() {
399        let mut core = SyncularCore::new(&json!({})).unwrap();
400        create(&mut core);
401
402        let sub = core.command(&json!({
403            "method": "subscribe",
404            "params": { "id": "s1", "table": "todo", "scopes": {} }
405        }));
406        assert_eq!(sub["result"], json!({}));
407
408        let mutate = core.command(&json!({
409            "method": "mutate",
410            "params": { "mutations": [{
411                "op": "upsert", "table": "todo",
412                "values": { "id": "t1", "title": "hello", "done": false }
413            }] }
414        }));
415        assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
416
417        // The query fast path sees the optimistic overlay immediately.
418        let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
419        let list = rows["result"]["rows"].as_array().expect("rows");
420        assert_eq!(list.len(), 1);
421        assert_eq!(list[0]["title"], "hello");
422        assert_eq!(list[0]["id"], "t1");
423    }
424
425    #[test]
426    fn query_binds_params() {
427        let mut core = SyncularCore::new(&json!({})).unwrap();
428        create(&mut core);
429        core.command(&json!({
430            "method": "mutate",
431            "params": { "mutations": [
432                { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
433                { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
434            ] }
435        }));
436        let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
437        let list = rows["result"]["rows"].as_array().expect("rows");
438        assert_eq!(list.len(), 1);
439        assert_eq!(list[0]["id"], "b");
440    }
441
442    #[test]
443    fn events_derived_after_mutate() {
444        let mut core = SyncularCore::new(&json!({})).unwrap();
445        // Before create, no events.
446        create(&mut core);
447        // Register a diagnostics consumer so mutate-driven snapshots flow.
448        let enabled = core.command(&json!({ "method": "enableDiagnostics", "params": {} }));
449        assert_eq!(enabled["result"], json!({}));
450        let _ = core.drain_events();
451        core.command(&json!({
452            "method": "mutate",
453            "params": { "mutations": [{
454                "op": "upsert", "table": "todo",
455                "values": { "id": "t1", "title": "x", "done": false }
456            }] }
457        }));
458        let events = core.drain_events();
459        // A local mutate writes the optimistic overlay and revision in one
460        // transaction, then emits the exact committed change batch.
461        let kinds: Vec<&str> = events
462            .iter()
463            .filter_map(|e| e.json.get("type").and_then(Value::as_str))
464            .collect();
465        assert!(kinds.contains(&"change"), "kinds: {kinds:?}");
466        assert!(kinds.contains(&"diagnostics"), "kinds: {kinds:?}");
467        let change = events
468            .iter()
469            .find(|event| event.json["type"] == "change")
470            .expect("change event");
471        assert_eq!(change.json["batch"]["revision"], "1");
472        assert_eq!(change.json["batch"]["tables"][0]["table"], "todo");
473        assert_eq!(change.json["batch"]["status"]["outbox"], 1);
474        // `syncNeeded` is an inbound pull/catch-up signal. Local push work is
475        // represented exactly by `outbox` and by the interactive sync intent.
476        assert_eq!(change.json["batch"]["status"]["syncNeeded"], false);
477        assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
478        // Draining is exhaustive.
479        assert!(core.drain_events().is_empty());
480    }
481
482    #[test]
483    fn diagnostics_keep_fresh_capture_times_and_revision_independent_changes() {
484        let mut core = SyncularCore::new(&json!({})).unwrap();
485        let created = core.command(&json!({"method": "create", "params": {
486            "clientId": "diagnostics-comparison", "schema": simple_schema(), "nowMs": 1000
487        }}));
488        assert!(created.get("error").is_none(), "{created}");
489        assert!(core.drain_events().is_empty());
490        core.command(&json!({"method": "enableDiagnostics", "params": {}}));
491        let initial = core.drain_events();
492        assert_eq!(initial.len(), 1);
493        assert_eq!(initial[0].json["snapshot"]["capturedAtMs"], 1000);
494
495        core.client.as_mut().unwrap().set_now_ms(2000);
496        let read = json!({"method": "query", "params": {"sql": "SELECT 1 AS id"}});
497        assert_eq!(core.command(&read)["result"]["rows"], json!([{"id": 1}]));
498        assert!(core.drain_events().is_empty());
499        let changed = core.command(&json!({"method": "mutate", "params": {
500            "mutations": [{"op": "upsert", "table": "todo", "values": {
501                "id": "first", "title": "private value", "done": false
502            }}]
503        }}));
504        assert!(changed.get("error").is_none(), "{changed}");
505        let expected = serde_json::to_value(
506            core.client
507                .as_ref()
508                .unwrap()
509                .diagnostics_snapshot(&Default::default())
510                .unwrap(),
511        )
512        .unwrap();
513        let diagnostics: Vec<Value> = core
514            .drain_events()
515            .into_iter()
516            .filter(|event| event.json["type"] == "diagnostics")
517            .map(|event| event.json)
518            .collect();
519        assert_eq!(
520            diagnostics,
521            vec![json!({"type": "diagnostics", "snapshot": expected})]
522        );
523        assert_eq!(diagnostics[0]["snapshot"]["capturedAtMs"], 2000);
524
525        core.client.as_mut().unwrap().set_now_ms(3000);
526        assert!(core.command(&read).get("error").is_none());
527        assert!(core.drain_events().is_empty());
528        let revision = core.client.as_ref().unwrap().local_revision();
529        core.command(&json!({"method": "sync", "params": {}}));
530        assert_eq!(core.client.as_ref().unwrap().local_revision(), revision);
531        let expected = serde_json::to_value(
532            core.client
533                .as_ref()
534                .unwrap()
535                .diagnostics_snapshot(&Default::default())
536                .unwrap(),
537        )
538        .unwrap();
539        let diagnostics: Vec<Value> = core
540            .drain_events()
541            .into_iter()
542            .filter(|event| event.json["type"] == "diagnostics")
543            .map(|event| event.json)
544            .collect();
545        assert_eq!(
546            diagnostics,
547            vec![json!({"type": "diagnostics", "snapshot": expected})]
548        );
549        assert_eq!(diagnostics[0]["snapshot"]["capturedAtMs"], 3000);
550        assert_eq!(diagnostics[0]["snapshot"]["lastRound"]["status"], "failed");
551
552        let preflight = core.command(&json!({"method": "beginSecurityPreflight", "params": {}}));
553        assert!(preflight.get("error").is_none(), "{preflight}");
554        assert_eq!(
555            core.command(&read)["error"]["code"],
556            "client.security_preflight_required"
557        );
558        assert!(core
559            .drain_events()
560            .iter()
561            .all(|event| event.json["type"] != "diagnostics"));
562    }
563
564    #[test]
565    fn diagnostics_events_wait_for_a_registered_consumer() {
566        let mut core = SyncularCore::new(&json!({})).unwrap();
567        create(&mut core);
568        let _ = core.drain_events();
569        core.command(&json!({
570            "method": "mutate",
571            "params": { "mutations": [{
572                "op": "upsert", "table": "todo",
573                "values": { "id": "t1", "title": "x", "done": false }
574            }] }
575        }));
576        let kinds: Vec<String> = core
577            .drain_events()
578            .iter()
579            .filter_map(|e| e.json.get("type").and_then(Value::as_str))
580            .map(str::to_owned)
581            .collect();
582        assert!(kinds.contains(&"change".to_owned()), "kinds: {kinds:?}");
583        // Without a registered consumer the snapshot is never computed.
584        assert!(
585            !kinds.contains(&"diagnostics".to_owned()),
586            "kinds: {kinds:?}"
587        );
588
589        // Registration delivers a first snapshot on the same drain.
590        let enabled = core.command(&json!({ "method": "enableDiagnostics", "params": {} }));
591        assert_eq!(enabled["result"], json!({}));
592        let events = core.drain_events();
593        assert!(
594            events.iter().any(|e| e.json["type"] == "diagnostics"),
595            "events: {events:?}"
596        );
597
598        // Subsequent status changes keep flowing through the fingerprint gate.
599        core.command(&json!({
600            "method": "mutate",
601            "params": { "mutations": [{
602                "op": "upsert", "table": "todo",
603                "values": { "id": "t2", "title": "y", "done": false }
604            }] }
605        }));
606        let events = core.drain_events();
607        assert!(
608            events.iter().any(|e| e.json["type"] == "diagnostics"),
609            "events: {events:?}"
610        );
611    }
612
613    #[test]
614    fn a_snapshot_pull_registers_the_diagnostics_consumer() {
615        let mut core = SyncularCore::new(&json!({})).unwrap();
616        create(&mut core);
617        let _ = core.drain_events();
618        let reply = core.command(&json!({ "method": "diagnosticsSnapshot", "params": {} }));
619        assert_eq!(reply["result"]["version"], 1);
620        let _ = core.drain_events();
621        core.command(&json!({
622            "method": "mutate",
623            "params": { "mutations": [{
624                "op": "upsert", "table": "todo",
625                "values": { "id": "t1", "title": "x", "done": false }
626            }] }
627        }));
628        let events = core.drain_events();
629        assert!(
630            events.iter().any(|e| e.json["type"] == "diagnostics"),
631            "events: {events:?}"
632        );
633    }
634
635    #[test]
636    fn diagnostics_are_versioned_bounded_and_payload_free() {
637        let mut core = SyncularCore::new(&json!({})).unwrap();
638        create(&mut core);
639        let reply = core.command(&json!({
640            "method": "diagnosticsSnapshot",
641            "params": {
642                "expectedSubscriptions": [{ "id": "membership", "table": "todo" }]
643            }
644        }));
645        assert_eq!(reply["result"]["version"], 1);
646        assert_eq!(reply["result"]["subscriptions"][0]["state"], "unregistered");
647        let encoded = reply.to_string();
648        assert!(!encoded.contains("clientId"));
649        assert!(!encoded.contains("dbPath"));
650        assert!(!encoded.contains("operations"));
651    }
652
653    #[test]
654    fn sync_without_native_transport_fails_loud() {
655        let mut core = SyncularCore::new(&json!({})).unwrap();
656        create(&mut core);
657        let outcome = core.command(&json!({ "method": "sync", "params": {} }));
658        assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
659        assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
660    }
661
662    #[test]
663    fn file_db_persists_across_reopen() {
664        let dir = std::env::temp_dir();
665        let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
666        let path_str = path.to_string_lossy().to_string();
667        let _ = std::fs::remove_file(&path);
668
669        {
670            let mut core = SyncularCore::new(&json!({})).unwrap();
671            let reply = core.command(&json!({
672                "method": "create",
673                "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
674            }));
675            assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
676            core.command(&json!({
677                "method": "mutate",
678                "params": { "mutations": [{
679                    "op": "upsert", "table": "todo",
680                    "values": { "id": "persisted", "title": "kept", "done": false }
681                }] }
682            }));
683            let revision = core.command(&json!({
684                "method": "localRevision", "params": {}
685            }));
686            assert_eq!(revision["result"]["revision"], "1");
687        }
688        // Reopen without supplying an id: identity, revision, outbox/status,
689        // and the optimistic visible row all come from the durable database.
690        {
691            let mut core = SyncularCore::new(&json!({})).unwrap();
692            let reopened = core.command(&json!({
693                "method": "create",
694                "params": { "schema": simple_schema(), "dbPath": path_str }
695            }));
696            assert_eq!(reopened["result"], json!({}), "reopen: {reopened}");
697            let rows = core.query("SELECT title FROM todo", Value::Null);
698            let list = rows["result"]["rows"].as_array().expect("rows");
699            assert_eq!(list.len(), 1, "reopened db: {rows}");
700            assert_eq!(list[0]["title"], "kept");
701            let revision = core.command(&json!({
702                "method": "localRevision", "params": {}
703            }));
704            assert_eq!(revision["result"]["revision"], "1");
705            let pending = core.command(&json!({
706                "method": "pendingCommitIds", "params": {}
707            }));
708            assert_eq!(pending["result"]["ids"].as_array().map(Vec::len), Some(1));
709            let status = core.command(&json!({
710                "method": "statusSnapshot", "params": {}
711            }));
712            assert_eq!(status["result"]["outbox"], 1);
713            assert_eq!(status["result"]["syncNeeded"], true);
714            assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
715        }
716        {
717            let mut core = SyncularCore::new(&json!({})).unwrap();
718            let mismatch = core.command(&json!({
719                "method": "create",
720                "params": { "clientId": "different", "schema": simple_schema(), "dbPath": path_str }
721            }));
722            assert_eq!(mismatch["error"]["code"], "client.identity_mismatch");
723        }
724        let _ = std::fs::remove_file(&path);
725    }
726
727    #[test]
728    fn config_validation_rejects_baseurl_without_native_feature() {
729        let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
730        #[cfg(not(feature = "native-transport"))]
731        assert!(
732            result.is_err(),
733            "baseUrl must be refused without native-transport"
734        );
735        #[cfg(feature = "native-transport")]
736        assert!(result.is_ok(), "baseUrl builds with native-transport");
737    }
738}