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