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 a derived 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;
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/// Snapshot of the client's observable state after the last drain, for
43/// diffing into events (the FFI derive-events pattern).
44#[derive(Debug, Default, Clone)]
45struct ObservedState {
46    sync_needed: bool,
47    conflicts: usize,
48    rejections: usize,
49    pending_commits: usize,
50    schema_floor: Option<Value>,
51    lease_error: Option<String>,
52}
53
54/// The Tauri-free core: one client, its owned transport, the derived-event
55/// diff state, and the pending event queue. Lives on ONE owning thread.
56pub struct SyncularCore {
57    client: Option<SyncClient>,
58    transport: HostTransport,
59    effects: CreateEffects,
60    last: ObservedState,
61    queue: VecDeque<Event>,
62}
63
64impl SyncularCore {
65    /// Build a core from the plugin config JSON (`baseUrl`, `headers`, …). A
66    /// `baseUrl` under the `native-transport` feature owns a real HTTP+WS
67    /// transport; without it the core is client-local only (tests, offline).
68    pub fn new(config: &Value) -> Result<Self, String> {
69        let transport = HostTransport::from_config(config)?;
70        Ok(SyncularCore {
71            client: None,
72            transport,
73            effects: CreateEffects::default(),
74            last: ObservedState::default(),
75            queue: VecDeque::new(),
76        })
77    }
78
79    /// Run one JSON command (`{"method","params"}`) through the shared router,
80    /// then drain inbound realtime traffic and derive 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.transport.set_signed_urls(self.effects.signed_urls);
94        }
95        self.drain_realtime();
96        self.derive_events(method);
97        match result {
98            Ok(value) => json!({ "result": value }),
99            Err((code, message)) => json!({ "error": { "code": code, "message": message } }),
100        }
101    }
102
103    /// The `syncular_query` fast path: arbitrary read-only SQL over the local
104    /// database. Routed through the same `query` command so there is one
105    /// implementation (the router owns it); this wrapper spares the JS bridge
106    /// from wrapping the method/params envelope for the hot live-query path.
107    pub fn query(&mut self, sql: &str, params: Value) -> Value {
108        let bind = match params {
109            Value::Null => Value::Array(Vec::new()),
110            other => other,
111        };
112        self.command(&json!({ "method": "query", "params": { "sql": sql, "params": bind } }))
113    }
114
115    /// True when the core wants a sync round (§8.4 coalesced signal). The host
116    /// loop polls this to decide whether to run `syncUntilIdle`.
117    pub fn sync_needed(&self) -> bool {
118        self.client
119            .as_ref()
120            .map(SyncClient::sync_needed)
121            .unwrap_or(false)
122    }
123
124    /// Run one `syncUntilIdle` round for the background host loop, deriving
125    /// events afterwards. A no-op (empty reply) before `create`.
126    pub fn sync_until_idle(&mut self) -> Value {
127        if self.client.is_none() {
128            return json!({ "result": null });
129        }
130        self.command(&json!({ "method": "syncUntilIdle", "params": {} }))
131    }
132
133    /// Drain every event queued since the last call (the host thread pushes
134    /// them onto the Tauri channel). Mirrors the FFI `poll_event`, batched.
135    pub fn drain_events(&mut self) -> Vec<Event> {
136        self.queue.drain(..).collect()
137    }
138
139    /// Replace the transport's request headers (RFC 0002 §2.3 — rotating
140    /// auth without tearing the plugin down). See
141    /// `HostTransport::set_headers` for the HTTP/WS pickup semantics.
142    pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
143        self.transport.set_headers(headers);
144    }
145
146    /// Release the socket/reader thread. Idempotent.
147    pub fn shutdown(&mut self) {
148        self.transport.shutdown();
149    }
150
151    fn push(&mut self, json: Value) {
152        self.queue.push_back(Event { json });
153    }
154
155    /// Feed buffered inbound WS frames to the client (which may ack back through
156    /// the same transport). A no-op without a native socket.
157    fn drain_realtime(&mut self) {
158        if self.client.is_none() {
159            return;
160        }
161        let frames = self.transport.take_inbound();
162        for frame in frames {
163            match frame {
164                transport::Inbound::Text(text) => {
165                    if is_presence_control(&text) {
166                        self.push(json!({ "type": "presence" }));
167                    }
168                    if let Some(client) = self.client.as_mut() {
169                        client.on_realtime_text(&text);
170                    }
171                }
172                transport::Inbound::Binary(bytes) => {
173                    if let Some(client) = self.client.as_mut() {
174                        client.on_realtime_binary(&mut self.transport, &bytes);
175                    }
176                }
177            }
178        }
179    }
180
181    /// Diff observable state against the last snapshot and enqueue one event
182    /// per change (the invalidation-equivalent set). Same policy as the FFI,
183    /// plus a coarse `invalidate` whenever the local database plausibly
184    /// changed, so the webview's live queries re-run.
185    ///
186    /// `method` is the command that just ran (or `""` for a realtime-only
187    /// drain). A `mutate` writes the optimistic overlay immediately — visible
188    /// to local queries before any sync — so it always invalidates; sync
189    /// rounds invalidate when they applied commits/segment rows or changed
190    /// pending state.
191    fn derive_events(&mut self, method: &str) {
192        let Some(client) = self.client.as_ref() else {
193            return;
194        };
195        let now = ObservedState {
196            sync_needed: client.sync_needed(),
197            conflicts: client.conflicts().len(),
198            rejections: client.rejections().len(),
199            pending_commits: client.pending_commit_ids().len(),
200            schema_floor: client
201                .schema_floor()
202                .map(|f| serde_json::to_value(f).unwrap_or(Value::Null)),
203            lease_error: client.lease_state().and_then(|l| l.error_code.clone()),
204        };
205        let mut pending: Vec<Value> = Vec::new();
206        if now.sync_needed && !self.last.sync_needed {
207            pending.push(json!({ "type": "sync-needed" }));
208        }
209        if now.conflicts > self.last.conflicts {
210            pending.push(json!({ "type": "conflict", "count": now.conflicts }));
211        }
212        if now.rejections > self.last.rejections {
213            pending.push(json!({ "type": "rejection", "count": now.rejections }));
214        }
215        if now.schema_floor != self.last.schema_floor {
216            if let Some(floor) = &now.schema_floor {
217                pending.push(json!({ "type": "schema-floor", "floor": floor }));
218            }
219        }
220        if now.lease_error != self.last.lease_error {
221            if let Some(code) = &now.lease_error {
222                pending.push(json!({ "type": "lease", "errorCode": code }));
223            }
224        }
225        // A local apply changed rows the webview's live queries depend on:
226        // emit a coarse `invalidate` so the JS bridge re-runs its queries. A
227        // `mutate` always writes the optimistic overlay; a sync round changes
228        // data when its pending-commit count moved or conflicts appeared.
229        let data_changed = method == "mutate"
230            || now.pending_commits != self.last.pending_commits
231            || now.conflicts != self.last.conflicts;
232        if data_changed {
233            pending.push(json!({ "type": "invalidate" }));
234        }
235        self.last = now;
236        for event in pending {
237            self.push(event);
238        }
239    }
240}
241
242/// A presence fanout control frame (§8.6.2) — `{"event":"presence",...}`, the
243/// one inbound realtime event a native host surfaces directly.
244fn is_presence_control(text: &str) -> bool {
245    serde_json::from_str::<Value>(text)
246        .ok()
247        .and_then(|v| {
248            v.get("event")
249                .and_then(Value::as_str)
250                .map(|e| e == "presence")
251        })
252        .unwrap_or(false)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    fn simple_schema() -> Value {
260        json!({
261            "version": 1,
262            "tables": [{
263                "name": "todo",
264                "primaryKey": "id",
265                "columns": [
266                    { "name": "id", "type": "string", "nullable": false },
267                    { "name": "title", "type": "string", "nullable": false },
268                    { "name": "done", "type": "boolean", "nullable": false }
269                ],
270                "scopes": []
271            }]
272        })
273    }
274
275    fn create(core: &mut SyncularCore) {
276        let reply = core.command(&json!({
277            "method": "create",
278            "params": { "clientId": "c1", "schema": simple_schema() }
279        }));
280        assert_eq!(reply["result"], json!({}), "create ok: {reply}");
281    }
282
283    #[test]
284    fn command_round_trip_create_mutate_query() {
285        let mut core = SyncularCore::new(&json!({})).unwrap();
286        create(&mut core);
287
288        let sub = core.command(&json!({
289            "method": "subscribe",
290            "params": { "id": "s1", "table": "todo", "scopes": {} }
291        }));
292        assert_eq!(sub["result"], json!({}));
293
294        let mutate = core.command(&json!({
295            "method": "mutate",
296            "params": { "mutations": [{
297                "op": "upsert", "table": "todo",
298                "values": { "id": "t1", "title": "hello", "done": false }
299            }] }
300        }));
301        assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
302
303        // The query fast path sees the optimistic overlay immediately.
304        let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
305        let list = rows["result"]["rows"].as_array().expect("rows");
306        assert_eq!(list.len(), 1);
307        assert_eq!(list[0]["title"], "hello");
308        assert_eq!(list[0]["id"], "t1");
309    }
310
311    #[test]
312    fn query_binds_params() {
313        let mut core = SyncularCore::new(&json!({})).unwrap();
314        create(&mut core);
315        core.command(&json!({
316            "method": "mutate",
317            "params": { "mutations": [
318                { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
319                { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
320            ] }
321        }));
322        let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
323        let list = rows["result"]["rows"].as_array().expect("rows");
324        assert_eq!(list.len(), 1);
325        assert_eq!(list[0]["id"], "b");
326    }
327
328    #[test]
329    fn events_derived_after_mutate() {
330        let mut core = SyncularCore::new(&json!({})).unwrap();
331        // Before create, no events.
332        create(&mut core);
333        let _ = core.drain_events();
334        core.command(&json!({
335            "method": "mutate",
336            "params": { "mutations": [{
337                "op": "upsert", "table": "todo",
338                "values": { "id": "t1", "title": "x", "done": false }
339            }] }
340        }));
341        let events = core.drain_events();
342        // A local mutate writes the optimistic overlay immediately (a pending
343        // outbox commit appears) → an `invalidate` so live queries re-run. It
344        // does NOT flag sync_needed (that is a realtime-wake signal per SPEC).
345        let kinds: Vec<&str> = events
346            .iter()
347            .filter_map(|e| e.json.get("type").and_then(Value::as_str))
348            .collect();
349        assert!(kinds.contains(&"invalidate"), "kinds: {kinds:?}");
350        // Draining is exhaustive.
351        assert!(core.drain_events().is_empty());
352    }
353
354    #[test]
355    fn sync_without_native_transport_fails_loud() {
356        let mut core = SyncularCore::new(&json!({})).unwrap();
357        create(&mut core);
358        let outcome = core.command(&json!({ "method": "sync", "params": {} }));
359        assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
360        assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
361    }
362
363    #[test]
364    fn file_db_persists_across_reopen() {
365        let dir = std::env::temp_dir();
366        let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
367        let path_str = path.to_string_lossy().to_string();
368        let _ = std::fs::remove_file(&path);
369
370        {
371            let mut core = SyncularCore::new(&json!({})).unwrap();
372            let reply = core.command(&json!({
373                "method": "create",
374                "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
375            }));
376            assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
377            core.command(&json!({
378                "method": "mutate",
379                "params": { "mutations": [{
380                    "op": "upsert", "table": "todo",
381                    "values": { "id": "persisted", "title": "kept", "done": false }
382                }] }
383            }));
384        }
385        // Reopen the same file: the persisted row is still there.
386        {
387            let mut core = SyncularCore::new(&json!({})).unwrap();
388            core.command(&json!({
389                "method": "create",
390                "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
391            }));
392            let rows = core.query("SELECT title FROM todo", Value::Null);
393            let list = rows["result"]["rows"].as_array().expect("rows");
394            assert_eq!(list.len(), 1, "reopened db: {rows}");
395            assert_eq!(list[0]["title"], "kept");
396        }
397        let _ = std::fs::remove_file(&path);
398    }
399
400    #[test]
401    fn config_validation_rejects_baseurl_without_native_feature() {
402        let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
403        #[cfg(not(feature = "native-transport"))]
404        assert!(
405            result.is_err(),
406            "baseUrl must be refused without native-transport"
407        );
408        #[cfg(feature = "native-transport")]
409        assert!(result.is_ok(), "baseUrl builds with native-transport");
410    }
411}