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