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