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