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