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    interactive_sync: bool,
52    background_sync_ms: Option<u64>,
53}
54
55impl SyncularCore {
56    /// Build a core from the plugin config JSON (`baseUrl`, `headers`, …). A
57    /// `baseUrl` under the `native-transport` feature owns a real HTTP+WS
58    /// transport; without it the core is client-local only (tests, offline).
59    pub fn new(config: &Value) -> Result<Self, String> {
60        Self::new_with_notify(config, None)
61    }
62
63    pub fn new_with_notify(
64        config: &Value,
65        notify: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
66    ) -> Result<Self, String> {
67        let transport = HostTransport::from_config_with_notify(config, notify)?;
68        Ok(SyncularCore {
69            client: None,
70            transport,
71            effects: CreateEffects::default(),
72            queue: VecDeque::new(),
73            last_diagnostics_fingerprint: None,
74            interactive_sync: false,
75            background_sync_ms: None,
76        })
77    }
78
79    /// Run one JSON command (`{"method","params"}`) through the shared router,
80    /// then drain inbound realtime traffic and exact core events. Returns the
81    /// driver-protocol `{"result"|"error"}` reply.
82    pub fn command(&mut self, command: &Value) -> Value {
83        let method = command.get("method").and_then(Value::as_str).unwrap_or("");
84        let params = command.get("params").cloned().unwrap_or(Value::Null);
85        let result = dispatch(
86            &mut self.transport,
87            &mut self.client,
88            &mut self.effects,
89            method,
90            &params,
91        );
92        if method == "create" {
93            self.last_diagnostics_fingerprint = None;
94            self.transport.set_signed_urls(self.effects.signed_urls);
95        }
96        if method == "beginSecurityPreflight"
97            || method == "shutdown"
98            || (method == "create"
99                && params
100                    .get("securityPreflight")
101                    .and_then(Value::as_bool)
102                    .unwrap_or(false))
103        {
104            self.interactive_sync = false;
105            self.background_sync_ms = None;
106        }
107        if let Ok(value) = &result {
108            if value.pointer("/effects/sync/kind").and_then(Value::as_str) == Some("interactive") {
109                self.interactive_sync = true;
110            }
111        }
112        self.drain_realtime();
113        self.drain_core_outputs();
114        self.emit_diagnostics_if_changed();
115        match result {
116            Ok(value) => json!({ "result": value }),
117            Err((code, message)) => json!({ "error": { "code": code, "message": message } }),
118        }
119    }
120
121    /// The `syncular_query` fast path: arbitrary read-only SQL over the local
122    /// database. Routed through the same `query` command so there is one
123    /// implementation (the router owns it); this wrapper spares the JS bridge
124    /// from wrapping the method/params envelope for the hot live-query path.
125    pub fn query(&mut self, sql: &str, params: Value) -> Value {
126        let bind = match params {
127            Value::Null => Value::Array(Vec::new()),
128            other => other,
129        };
130        self.command(&json!({ "method": "query", "params": { "sql": sql, "params": bind } }))
131    }
132
133    /// Consume the next coalesced host schedule. Interactive work preempts a
134    /// pending retry; background work keeps the earliest real deadline.
135    pub fn take_sync_intent(&mut self) -> SyncIntent {
136        if std::mem::take(&mut self.interactive_sync) {
137            self.background_sync_ms = None;
138            SyncIntent::Interactive
139        } else if let Some(delay_ms) = self.background_sync_ms.take() {
140            SyncIntent::Background { delay_ms }
141        } else {
142            SyncIntent::None
143        }
144    }
145
146    /// Run one `syncUntilIdle` round for the background host loop, deriving
147    /// events afterwards. A no-op (empty reply) before `create`.
148    pub fn sync_until_idle(&mut self) -> Value {
149        if self.client.is_none() {
150            return json!({ "result": null });
151        }
152        self.command(&json!({ "method": "syncUntilIdle", "params": {} }))
153    }
154
155    /// Owner-mailbox wake from the native realtime reader.
156    pub fn poll_transport(&mut self) {
157        self.drain_realtime();
158        self.drain_core_outputs();
159        self.emit_diagnostics_if_changed();
160    }
161
162    /// Drain every event queued since the last call (the host thread pushes
163    /// them onto the Tauri channel). Mirrors the FFI `poll_event`, batched.
164    pub fn drain_events(&mut self) -> Vec<Event> {
165        self.queue.drain(..).collect()
166    }
167
168    /// Replace the transport's request headers (RFC 0002 §2.3 — rotating
169    /// auth without tearing the plugin down). See
170    /// `HostTransport::set_headers` for the HTTP/WS pickup semantics.
171    pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
172        self.transport.set_headers(headers);
173    }
174
175    /// Release the socket/reader thread. Idempotent.
176    pub fn shutdown(&mut self) {
177        if let Some(mut client) = self.client.take() {
178            client.disconnect_realtime(&mut self.transport);
179            client.begin_security_preflight();
180        }
181        self.interactive_sync = false;
182        self.background_sync_ms = None;
183        self.transport.shutdown();
184    }
185
186    fn push(&mut self, json: Value) {
187        self.queue.push_back(Event { json });
188    }
189
190    /// Feed buffered inbound WS frames to the client (which may ack back through
191    /// the same transport). A no-op without a native socket.
192    fn drain_realtime(&mut self) {
193        if self.client.is_none() {
194            return;
195        }
196        let frames = self.transport.take_inbound();
197        for frame in frames {
198            match frame {
199                transport::Inbound::Text(text) => {
200                    if is_presence_control(&text) {
201                        self.push(json!({ "type": "presence" }));
202                    }
203                    if let Some(client) = self.client.as_mut() {
204                        client.on_realtime_text(&text);
205                    }
206                }
207                transport::Inbound::Binary(bytes) => {
208                    if let Some(client) = self.client.as_mut() {
209                        client.on_realtime_binary(&mut self.transport, &bytes);
210                    }
211                }
212            }
213        }
214    }
215
216    /// Drain exact observer batches and sync intents produced by the Rust core.
217    fn drain_core_outputs(&mut self) {
218        let Some(client) = self.client.as_mut() else {
219            return;
220        };
221        let batches = client.drain_change_batches();
222        let intents = client.drain_sync_intents();
223        for batch in batches {
224            self.push(json!({ "type": "change", "batch": batch }));
225        }
226        for intent in intents {
227            match intent {
228                SyncIntent::Interactive => self.interactive_sync = true,
229                SyncIntent::Background { delay_ms } => {
230                    self.background_sync_ms = Some(
231                        self.background_sync_ms
232                            .map_or(delay_ms, |current| current.min(delay_ms)),
233                    );
234                }
235                SyncIntent::None => {}
236            }
237        }
238    }
239
240    /// Emit one privacy-safe snapshot only when durable/client status changed.
241    /// `capturedAtMs` is excluded from the fingerprint so polling and read-only
242    /// commands do not create event noise. Expected-but-unregistered intent is
243    /// request-local and is obtained through `diagnosticsSnapshot` directly.
244    fn emit_diagnostics_if_changed(&mut self) {
245        let Some(client) = self.client.as_ref() else {
246            return;
247        };
248        if client.security_preflight() {
249            return;
250        }
251        let Ok(snapshot) = client.diagnostics_snapshot(&ClientDiagnosticsRequest::default()) else {
252            return;
253        };
254        let Ok(mut fingerprint) = serde_json::to_value(&snapshot) else {
255            return;
256        };
257        if let Some(object) = fingerprint.as_object_mut() {
258            object.remove("capturedAtMs");
259        }
260        if self.last_diagnostics_fingerprint.as_ref() == Some(&fingerprint) {
261            return;
262        }
263        self.last_diagnostics_fingerprint = Some(fingerprint);
264        self.push(json!({ "type": "diagnostics", "snapshot": snapshot }));
265    }
266}
267
268/// A presence fanout control frame (§8.6.2) — `{"event":"presence",...}`, the
269/// one inbound realtime event a native host surfaces directly.
270fn is_presence_control(text: &str) -> bool {
271    serde_json::from_str::<Value>(text)
272        .ok()
273        .and_then(|v| {
274            v.get("event")
275                .and_then(Value::as_str)
276                .map(|e| e == "presence")
277        })
278        .unwrap_or(false)
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn simple_schema() -> Value {
286        json!({
287            "version": 1,
288            "tables": [{
289                "name": "todo",
290                "primaryKey": "id",
291                "columns": [
292                    { "name": "id", "type": "string", "nullable": false },
293                    { "name": "title", "type": "string", "nullable": false },
294                    { "name": "done", "type": "boolean", "nullable": false }
295                ],
296                "scopes": []
297            }]
298        })
299    }
300
301    fn create(core: &mut SyncularCore) {
302        let reply = core.command(&json!({
303            "method": "create",
304            "params": { "clientId": "c1", "schema": simple_schema() }
305        }));
306        assert_eq!(reply["result"], json!({}), "create ok: {reply}");
307    }
308
309    #[test]
310    fn command_round_trip_create_mutate_query() {
311        let mut core = SyncularCore::new(&json!({})).unwrap();
312        create(&mut core);
313
314        let sub = core.command(&json!({
315            "method": "subscribe",
316            "params": { "id": "s1", "table": "todo", "scopes": {} }
317        }));
318        assert_eq!(sub["result"], json!({}));
319
320        let mutate = core.command(&json!({
321            "method": "mutate",
322            "params": { "mutations": [{
323                "op": "upsert", "table": "todo",
324                "values": { "id": "t1", "title": "hello", "done": false }
325            }] }
326        }));
327        assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
328
329        // The query fast path sees the optimistic overlay immediately.
330        let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
331        let list = rows["result"]["rows"].as_array().expect("rows");
332        assert_eq!(list.len(), 1);
333        assert_eq!(list[0]["title"], "hello");
334        assert_eq!(list[0]["id"], "t1");
335    }
336
337    #[test]
338    fn query_binds_params() {
339        let mut core = SyncularCore::new(&json!({})).unwrap();
340        create(&mut core);
341        core.command(&json!({
342            "method": "mutate",
343            "params": { "mutations": [
344                { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
345                { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
346            ] }
347        }));
348        let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
349        let list = rows["result"]["rows"].as_array().expect("rows");
350        assert_eq!(list.len(), 1);
351        assert_eq!(list[0]["id"], "b");
352    }
353
354    #[test]
355    fn events_derived_after_mutate() {
356        let mut core = SyncularCore::new(&json!({})).unwrap();
357        // Before create, no events.
358        create(&mut core);
359        let _ = core.drain_events();
360        core.command(&json!({
361            "method": "mutate",
362            "params": { "mutations": [{
363                "op": "upsert", "table": "todo",
364                "values": { "id": "t1", "title": "x", "done": false }
365            }] }
366        }));
367        let events = core.drain_events();
368        // A local mutate writes the optimistic overlay and revision in one
369        // transaction, then emits the exact committed change batch.
370        let kinds: Vec<&str> = events
371            .iter()
372            .filter_map(|e| e.json.get("type").and_then(Value::as_str))
373            .collect();
374        assert!(kinds.contains(&"change"), "kinds: {kinds:?}");
375        assert!(kinds.contains(&"diagnostics"), "kinds: {kinds:?}");
376        let change = events
377            .iter()
378            .find(|event| event.json["type"] == "change")
379            .expect("change event");
380        assert_eq!(change.json["batch"]["revision"], "1");
381        assert_eq!(change.json["batch"]["tables"][0]["table"], "todo");
382        assert_eq!(change.json["batch"]["status"]["outbox"], 1);
383        // `syncNeeded` is an inbound pull/catch-up signal. Local push work is
384        // represented exactly by `outbox` and by the interactive sync intent.
385        assert_eq!(change.json["batch"]["status"]["syncNeeded"], false);
386        assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
387        // Draining is exhaustive.
388        assert!(core.drain_events().is_empty());
389    }
390
391    #[test]
392    fn diagnostics_are_versioned_bounded_and_payload_free() {
393        let mut core = SyncularCore::new(&json!({})).unwrap();
394        create(&mut core);
395        let reply = core.command(&json!({
396            "method": "diagnosticsSnapshot",
397            "params": {
398                "expectedSubscriptions": [{ "id": "membership", "table": "todo" }]
399            }
400        }));
401        assert_eq!(reply["result"]["version"], 1);
402        assert_eq!(reply["result"]["subscriptions"][0]["state"], "unregistered");
403        let encoded = reply.to_string();
404        assert!(!encoded.contains("clientId"));
405        assert!(!encoded.contains("dbPath"));
406        assert!(!encoded.contains("operations"));
407    }
408
409    #[test]
410    fn sync_without_native_transport_fails_loud() {
411        let mut core = SyncularCore::new(&json!({})).unwrap();
412        create(&mut core);
413        let outcome = core.command(&json!({ "method": "sync", "params": {} }));
414        assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
415        assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
416    }
417
418    #[test]
419    fn file_db_persists_across_reopen() {
420        let dir = std::env::temp_dir();
421        let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
422        let path_str = path.to_string_lossy().to_string();
423        let _ = std::fs::remove_file(&path);
424
425        {
426            let mut core = SyncularCore::new(&json!({})).unwrap();
427            let reply = core.command(&json!({
428                "method": "create",
429                "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
430            }));
431            assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
432            core.command(&json!({
433                "method": "mutate",
434                "params": { "mutations": [{
435                    "op": "upsert", "table": "todo",
436                    "values": { "id": "persisted", "title": "kept", "done": false }
437                }] }
438            }));
439            let revision = core.command(&json!({
440                "method": "localRevision", "params": {}
441            }));
442            assert_eq!(revision["result"]["revision"], "1");
443        }
444        // Reopen without supplying an id: identity, revision, outbox/status,
445        // and the optimistic visible row all come from the durable database.
446        {
447            let mut core = SyncularCore::new(&json!({})).unwrap();
448            let reopened = core.command(&json!({
449                "method": "create",
450                "params": { "schema": simple_schema(), "dbPath": path_str }
451            }));
452            assert_eq!(reopened["result"], json!({}), "reopen: {reopened}");
453            let rows = core.query("SELECT title FROM todo", Value::Null);
454            let list = rows["result"]["rows"].as_array().expect("rows");
455            assert_eq!(list.len(), 1, "reopened db: {rows}");
456            assert_eq!(list[0]["title"], "kept");
457            let revision = core.command(&json!({
458                "method": "localRevision", "params": {}
459            }));
460            assert_eq!(revision["result"]["revision"], "1");
461            let pending = core.command(&json!({
462                "method": "pendingCommitIds", "params": {}
463            }));
464            assert_eq!(pending["result"]["ids"].as_array().map(Vec::len), Some(1));
465            let status = core.command(&json!({
466                "method": "statusSnapshot", "params": {}
467            }));
468            assert_eq!(status["result"]["outbox"], 1);
469            assert_eq!(status["result"]["syncNeeded"], true);
470            assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
471        }
472        {
473            let mut core = SyncularCore::new(&json!({})).unwrap();
474            let mismatch = core.command(&json!({
475                "method": "create",
476                "params": { "clientId": "different", "schema": simple_schema(), "dbPath": path_str }
477            }));
478            assert_eq!(mismatch["error"]["code"], "client.identity_mismatch");
479        }
480        let _ = std::fs::remove_file(&path);
481    }
482
483    #[test]
484    fn config_validation_rejects_baseurl_without_native_feature() {
485        let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
486        #[cfg(not(feature = "native-transport"))]
487        assert!(
488            result.is_err(),
489            "baseUrl must be refused without native-transport"
490        );
491        #[cfg(feature = "native-transport")]
492        assert!(result.is_ok(), "baseUrl builds with native-transport");
493    }
494}