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