Skip to main content

tauri_plugin_syncular/
lib.rs

1//! # tauri-plugin-syncular — a native syncular instance inside the Tauri process
2//!
3//! A NATIVE syncular client (the Rust `syncular-client` core, consumed
4//! DIRECTLY — no FFI) runs in the Tauri host process and is exposed to the
5//! webview as Tauri commands + events. The JS bridge (`@syncular/tauri`)
6//! implements the same `SyncClientLike` interface the React package
7//! normalizes, so the hooks work unchanged — the fourth host of one interface
8//! after direct / worker-leader / follower.
9//!
10//! Decided architecture (ROADMAP.md block 1): NOT JS syncular in the
11//! webview — webview OPFS is eviction-prone and inconsistent across
12//! WKWebView/webkitgtk; the Rust core gives a real file DB and native perf.
13//!
14//! ## The surface (mirrors the FFI / conformance shim)
15//!
16//! - `syncular_command(command_json)` — the WHOLE command surface in one
17//!   command (`{"method","params"}`), dispatched through the shared
18//!   `syncular-command` router (the plugin is its THIRD consumer, so the
19//!   surface stays conformance-locked).
20//! - `syncular_query(sql, params)` — the React live-query fast path (arbitrary
21//!   read-only SQL); routed through the same `query` command.
22//! - `syncular://event` — a Tauri event carrying the derived client-observable
23//!   events (`sync-needed` / `conflict` / `presence` / `invalidate` / …), the
24//!   invalidation-equivalent set the FFI `poll_event` surfaces.
25//!
26//! ## Thread-safety, honestly
27//!
28//! [`core::SyncularCore`] owns a rusqlite connection and is NOT `Sync`. One
29//! owning thread holds it; every command arrives over a mailbox (mpsc). The
30//! background host loop (§8.4 wake-driven `syncUntilIdle` with jitter) runs ON
31//! that same thread, interleaved with mailbox requests, so the connection is
32//! never touched concurrently — the same one-owning-thread pattern as the shim.
33
34use std::sync::mpsc::{Receiver, Sender};
35use std::sync::Mutex;
36use std::time::{Duration, Instant};
37
38use serde_json::{json, Value};
39use tauri::plugin::{Builder, TauriPlugin};
40use tauri::{Emitter, Manager, RunEvent, Runtime};
41
42pub mod core;
43pub mod transport;
44
45use core::SyncularCore;
46
47/// The Tauri event name carrying derived client-observable events.
48pub const EVENT_NAME: &str = "syncular://event";
49
50/// Plugin configuration. Passed to [`init`]; every field is optional except a
51/// caller almost always wants a `base_url` (for real network sync) and a
52/// `db_path` (for persistence — defaults to an in-memory core if absent).
53#[derive(Debug, Clone, Default)]
54pub struct SyncularConfig {
55    /// Server base URL for the native HTTP+WS transport (needs the
56    /// `native-transport` feature). Absent → client-local only.
57    pub base_url: Option<String>,
58    /// Optional realtime WS URL; derived from `base_url` when absent.
59    pub ws_url: Option<String>,
60    /// Extra request headers (auth, actor/project ids) as (name, value).
61    pub headers: Vec<(String, String)>,
62    /// On-disk SQLite path. Absent → in-memory (nothing survives a restart).
63    /// Apps usually set this to a file under the app-data dir; see [`init`].
64    pub db_path: Option<String>,
65    /// §8.4 host-loop jitter cap (ms) applied to each wake before a
66    /// `syncUntilIdle`. 0 disables jitter. Default 250.
67    pub wake_jitter_ms: u64,
68    /// Run the background host loop (§8.4). Default true.
69    pub auto_sync: bool,
70}
71
72impl SyncularConfig {
73    /// Build the JSON config the core's transport reads.
74    fn to_transport_json(&self) -> Value {
75        let mut map = serde_json::Map::new();
76        if let Some(base) = &self.base_url {
77            map.insert("baseUrl".to_owned(), Value::from(base.clone()));
78        }
79        if let Some(ws) = &self.ws_url {
80            map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
81        }
82        if !self.headers.is_empty() {
83            let headers: serde_json::Map<String, Value> = self
84                .headers
85                .iter()
86                .map(|(k, v)| (k.clone(), Value::from(v.clone())))
87                .collect();
88            map.insert("headers".to_owned(), Value::Object(headers));
89        }
90        Value::Object(map)
91    }
92}
93
94/// A request posted to the owning thread's mailbox. Each carries a one-shot
95/// reply channel; the Tauri command blocks on it (`spawn_blocking`-friendly).
96enum Request {
97    Command {
98        command: Value,
99        reply: Sender<Value>,
100    },
101    Query {
102        sql: String,
103        params: Value,
104        reply: Sender<Value>,
105    },
106    /// Replace the transport's request headers (RFC 0002 §2.3). Header state
107    /// lives on the core-owned transport, so mutation rides the same mailbox
108    /// as every other access — the one-owning-thread invariant holds.
109    SetHeaders {
110        headers: Vec<(String, String)>,
111        reply: Sender<Value>,
112    },
113    Shutdown,
114}
115
116/// The plugin's managed state: the mailbox sender the commands post to. Wrapped
117/// in a `Mutex` only to be `Sync` for Tauri state (the `Sender` is `Send`).
118struct SyncularState {
119    sender: Mutex<Sender<Request>>,
120}
121
122impl SyncularState {
123    fn send(&self, request: Request) -> Result<(), String> {
124        self.sender
125            .lock()
126            .map_err(|_| "syncular mailbox poisoned".to_owned())?
127            .send(request)
128            .map_err(|_| "the syncular core thread has stopped".to_owned())
129    }
130}
131
132/// The owning thread: builds the core, then loops over the mailbox and the
133/// background host policy. `emit` pushes drained events onto the Tauri channel.
134fn run_owner_thread<F>(config: SyncularConfig, rx: Receiver<Request>, emit: F)
135where
136    F: Fn(&Value) + Send + 'static,
137{
138    let transport_json = config.to_transport_json();
139    let mut core = match SyncularCore::new(&transport_json) {
140        Ok(core) => core,
141        Err(message) => {
142            // A construction failure is terminal for this instance; surface it
143            // once on the channel so the webview can show it, then stop.
144            emit(&json!({ "type": "error", "message": message }));
145            return;
146        }
147    };
148
149    // The host loop cadence: block on the mailbox with a timeout so we wake
150    // periodically to run a sync round when the core wants one (§8.4). The
151    // db_path is injected into the FIRST `create` command so the app does not
152    // have to thread it through the JS side.
153    let idle_poll = Duration::from_millis(200);
154    let jitter_cap = config.wake_jitter_ms;
155    let mut next_seed: u64 = std::process::id() as u64 ^ 0x9E37_79B9_7F4A_7C15;
156
157    // A tiny xorshift for jitter — no rand dependency for one number.
158    let mut jitter = || {
159        if jitter_cap == 0 {
160            return Duration::ZERO;
161        }
162        next_seed ^= next_seed << 13;
163        next_seed ^= next_seed >> 7;
164        next_seed ^= next_seed << 17;
165        Duration::from_millis(next_seed % (jitter_cap + 1))
166    };
167
168    let mut last_sync = Instant::now();
169    loop {
170        let request = rx.recv_timeout(idle_poll);
171        match request {
172            Ok(Request::Command { command, reply }) => {
173                let command = inject_db_path(command, &config);
174                let result = core.command(&command);
175                let _ = reply.send(result);
176                pump_events(&mut core, &emit);
177            }
178            Ok(Request::Query { sql, params, reply }) => {
179                let result = core.query(&sql, params);
180                let _ = reply.send(result);
181                pump_events(&mut core, &emit);
182            }
183            Ok(Request::SetHeaders { headers, reply }) => {
184                core.set_headers(headers);
185                let _ = reply.send(json!({ "result": null }));
186            }
187            Ok(Request::Shutdown) => {
188                core.shutdown();
189                return;
190            }
191            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
192                // Background host loop (§8.4): if the core wants a sync and the
193                // transport can reach the network, run one syncUntilIdle round
194                // after a jittered wait. Client-local (`Null` transport) cores
195                // simply never report sync_needed usefully → cheap no-op.
196                if config.auto_sync && core.sync_needed() {
197                    let wait = jitter();
198                    if !wait.is_zero() {
199                        std::thread::sleep(wait);
200                    }
201                    core.sync_until_idle();
202                    last_sync = Instant::now();
203                    pump_events(&mut core, &emit);
204                }
205                let _ = last_sync;
206            }
207            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
208                core.shutdown();
209                return;
210            }
211        }
212    }
213}
214
215/// Inject the configured `db_path` into a `create` command's params if the JS
216/// side did not already supply one — so persistence is a plugin-config concern,
217/// not something every app must thread through the bridge.
218fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
219    if command.get("method").and_then(Value::as_str) != Some("create") {
220        return command;
221    }
222    let Some(db_path) = &config.db_path else {
223        return command;
224    };
225    let params = command.get_mut("params").and_then(Value::as_object_mut);
226    if let Some(params) = params {
227        params
228            .entry("dbPath")
229            .or_insert_with(|| Value::from(db_path.clone()));
230    } else if let Some(obj) = command.as_object_mut() {
231        obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
232    }
233    command
234}
235
236fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
237    for event in core.drain_events() {
238        emit(&event.json);
239    }
240}
241
242// -- Tauri commands (the thin shell) -----------------------------------------
243
244#[tauri::command]
245async fn syncular_command<R: Runtime>(
246    app: tauri::AppHandle<R>,
247    command: Value,
248) -> Result<Value, String> {
249    let state = app.state::<SyncularState>();
250    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
251    state.send(Request::Command {
252        command,
253        reply: reply_tx,
254    })?;
255    reply_rx
256        .recv()
257        .map_err(|_| "the syncular core dropped the reply".to_owned())
258}
259
260/// Replace the native transport's request headers at runtime — the auth
261/// rotation path (RFC 0002 §2.3): a fresh JWT reaches the transport without
262/// re-registering the plugin. HTTP requests use the new set from the next
263/// call; the realtime socket applies it on its next (re)connect.
264#[tauri::command]
265async fn syncular_set_headers<R: Runtime>(
266    app: tauri::AppHandle<R>,
267    headers: std::collections::BTreeMap<String, String>,
268) -> Result<Value, String> {
269    let state = app.state::<SyncularState>();
270    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
271    state.send(Request::SetHeaders {
272        headers: headers.into_iter().collect(),
273        reply: reply_tx,
274    })?;
275    reply_rx
276        .recv()
277        .map_err(|_| "the syncular core dropped the reply".to_owned())
278}
279
280#[tauri::command]
281async fn syncular_query<R: Runtime>(
282    app: tauri::AppHandle<R>,
283    sql: String,
284    params: Option<Value>,
285) -> Result<Value, String> {
286    let state = app.state::<SyncularState>();
287    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
288    state.send(Request::Query {
289        sql,
290        params: params.unwrap_or(Value::Null),
291        reply: reply_tx,
292    })?;
293    reply_rx
294        .recv()
295        .map_err(|_| "the syncular core dropped the reply".to_owned())
296}
297
298/// Initialize the plugin with a config. Register with
299/// `tauri::Builder::default().plugin(tauri_plugin_syncular::init(config))`.
300///
301/// The owning thread is spawned in `setup`; it builds the core (native
302/// transport if `base_url` + the `native-transport` feature), pumps events onto
303/// [`EVENT_NAME`], and runs the §8.4 host loop. The mailbox `Sender` is managed
304/// as plugin state and torn down on `RunEvent::Exit`.
305pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
306    // Default the auto_sync flag on (the caller-built Default has it false).
307    let config = SyncularConfig {
308        auto_sync: true,
309        wake_jitter_ms: if config.wake_jitter_ms == 0 && config.base_url.is_none() {
310            0
311        } else if config.wake_jitter_ms == 0 {
312            250
313        } else {
314            config.wake_jitter_ms
315        },
316        ..config
317    };
318
319    Builder::<R>::new("syncular")
320        .invoke_handler(tauri::generate_handler![
321            syncular_command,
322            syncular_query,
323            syncular_set_headers
324        ])
325        .setup(move |app, _api| {
326            let (tx, rx) = std::sync::mpsc::channel::<Request>();
327            app.manage(SyncularState {
328                sender: Mutex::new(tx),
329            });
330            let app_handle = app.clone();
331            let emit = move |value: &Value| {
332                // Best-effort: a webview that has gone away must not crash the
333                // owning thread. Emit to all windows on the syncular channel.
334                let _ = app_handle.emit(EVENT_NAME, value.clone());
335            };
336            std::thread::Builder::new()
337                .name("syncular-core".to_owned())
338                .spawn(move || run_owner_thread(config, rx, emit))
339                .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
340            Ok(())
341        })
342        .on_event(|app, event| {
343            if let RunEvent::Exit = event {
344                if let Some(state) = app.try_state::<SyncularState>() {
345                    let _ = state.send(Request::Shutdown);
346                }
347            }
348        })
349        .build()
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn config_to_transport_json_shapes_fields() {
358        let config = SyncularConfig {
359            base_url: Some("https://api.example.com".to_owned()),
360            headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
361            ..Default::default()
362        };
363        let json = config.to_transport_json();
364        assert_eq!(json["baseUrl"], "https://api.example.com");
365        assert_eq!(json["headers"]["authorization"], "Bearer x");
366    }
367
368    #[test]
369    fn inject_db_path_adds_to_create_only() {
370        let config = SyncularConfig {
371            db_path: Some("/tmp/app.db".to_owned()),
372            ..Default::default()
373        };
374        // create gains the path…
375        let created = inject_db_path(
376            json!({ "method": "create", "params": { "clientId": "c1" } }),
377            &config,
378        );
379        assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
380        // …a create with no params object gets one…
381        let created2 = inject_db_path(json!({ "method": "create" }), &config);
382        assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
383        // …an explicit dbPath is preserved…
384        let explicit = inject_db_path(
385            json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
386            &config,
387        );
388        assert_eq!(explicit["params"]["dbPath"], "/other.db");
389        // …and a non-create command is untouched.
390        let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
391        assert!(mutate["params"].get("dbPath").is_none());
392    }
393
394    /// The owner-thread mailbox loop end-to-end, without any Tauri window: post
395    /// commands, collect emitted events. This is the real host path — the Tauri
396    /// commands are a two-line channel forward over exactly this.
397    #[test]
398    fn owner_thread_round_trips_over_mailbox() {
399        use std::sync::mpsc::channel;
400        use std::sync::{Arc, Mutex as StdMutex};
401
402        let (tx, rx) = channel::<Request>();
403        let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
404        let events_for_thread = Arc::clone(&events);
405        let config = SyncularConfig {
406            auto_sync: false,
407            ..Default::default()
408        };
409        let handle = std::thread::spawn(move || {
410            run_owner_thread(config, rx, move |v| {
411                events_for_thread.lock().unwrap().push(v.clone());
412            });
413        });
414
415        let call = |command: Value| -> Value {
416            let (rtx, rrx) = channel();
417            tx.send(Request::Command {
418                command,
419                reply: rtx,
420            })
421            .unwrap();
422            rrx.recv().unwrap()
423        };
424
425        let schema = json!({
426            "version": 1,
427            "tables": [{
428                "name": "todo", "primaryKey": "id",
429                "columns": [
430                    { "name": "id", "type": "string", "nullable": false },
431                    { "name": "title", "type": "string", "nullable": false }
432                ],
433                "scopes": []
434            }]
435        });
436        assert_eq!(
437            call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
438                ["result"],
439            json!({})
440        );
441        call(json!({ "method": "mutate", "params": { "mutations": [{
442            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
443        }] } }));
444
445        // A query over the mailbox.
446        let (qtx, qrx) = channel();
447        tx.send(Request::Query {
448            sql: "SELECT title FROM todo".to_owned(),
449            params: Value::Null,
450            reply: qtx,
451        })
452        .unwrap();
453        let rows = qrx.recv().unwrap();
454        assert_eq!(rows["result"]["rows"][0]["title"], "hi");
455
456        // RFC 0002 §2.3: header rotation rides the same mailbox; a
457        // client-local (Null-transport) core accepts and ignores the set.
458        let (htx, hrx) = channel();
459        tx.send(Request::SetHeaders {
460            headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
461            reply: htx,
462        })
463        .unwrap();
464        assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
465
466        tx.send(Request::Shutdown).unwrap();
467        handle.join().unwrap();
468
469        let seen = events.lock().unwrap();
470        let kinds: Vec<String> = seen
471            .iter()
472            .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
473            .collect();
474        // The local mutate emits an `invalidate` onto the channel (the signal
475        // the JS bridge fans out to onInvalidate so live queries re-run).
476        assert!(kinds.iter().any(|k| k == "invalidate"), "kinds: {kinds:?}");
477    }
478}