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