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