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` — exact revisioned `change` batches plus ephemeral
23//!   `presence`; command/realtime sync intents stay inside the event-driven
24//!   owner loop.
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 deadlines) 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, RecvTimeoutError, 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)]
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    /// Run the background host loop (§8.4). Default true.
66    pub auto_sync: bool,
67}
68
69impl Default for SyncularConfig {
70    fn default() -> Self {
71        Self {
72            base_url: None,
73            ws_url: None,
74            headers: Vec::new(),
75            db_path: None,
76            auto_sync: true,
77        }
78    }
79}
80
81impl SyncularConfig {
82    /// Build the JSON config the core's transport reads.
83    fn to_transport_json(&self) -> Value {
84        let mut map = serde_json::Map::new();
85        if let Some(base) = &self.base_url {
86            map.insert("baseUrl".to_owned(), Value::from(base.clone()));
87        }
88        if let Some(ws) = &self.ws_url {
89            map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
90        }
91        if !self.headers.is_empty() {
92            let headers: serde_json::Map<String, Value> = self
93                .headers
94                .iter()
95                .map(|(k, v)| (k.clone(), Value::from(v.clone())))
96                .collect();
97            map.insert("headers".to_owned(), Value::Object(headers));
98        }
99        Value::Object(map)
100    }
101}
102
103/// A request posted to the owning thread's mailbox. Each carries a one-shot
104/// reply channel; the Tauri command blocks on it (`spawn_blocking`-friendly).
105enum Request {
106    Command {
107        command: Value,
108        reply: Sender<Value>,
109    },
110    Query {
111        sql: String,
112        params: Value,
113        reply: Sender<Value>,
114    },
115    /// Replace the transport's request headers (RFC 0002 §2.3). Header state
116    /// lives on the core-owned transport, so mutation rides the same mailbox
117    /// as every other access — the one-owning-thread invariant holds.
118    SetHeaders {
119        headers: Vec<(String, String)>,
120        reply: Sender<Value>,
121    },
122    /// Native realtime reader wake; contains no data (the transport buffer does).
123    TransportWake,
124    Shutdown,
125}
126
127/// The plugin's managed state: the mailbox sender the commands post to. Wrapped
128/// in a `Mutex` only to be `Sync` for Tauri state (the `Sender` is `Send`).
129struct SyncularState {
130    sender: Mutex<Sender<Request>>,
131}
132
133impl SyncularState {
134    fn send(&self, request: Request) -> Result<(), String> {
135        self.sender
136            .lock()
137            .map_err(|_| "syncular mailbox poisoned".to_owned())?
138            .send(request)
139            .map_err(|_| "the syncular core thread has stopped".to_owned())
140    }
141}
142
143/// The owning thread: builds the core, then loops over the mailbox and the
144/// background host policy. `emit` pushes drained events onto the Tauri channel.
145fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
146where
147    F: Fn(&Value) + Send + 'static,
148{
149    let transport_json = config.to_transport_json();
150    let wake_tx = tx.clone();
151    let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
152        let _ = wake_tx.send(Request::TransportWake);
153    });
154    let mut core = match SyncularCore::new_with_notify(&transport_json, Some(notify)) {
155        Ok(core) => core,
156        Err(message) => {
157            // A construction failure is terminal for this instance; surface it
158            // once on the channel so the webview can show it, then stop.
159            emit(&json!({ "type": "error", "message": message }));
160            return;
161        }
162    };
163
164    // No idle poll: commands/realtime wake the mailbox, while a retryable
165    // transport failure contributes one real monotonic deadline.
166    let mut background_deadline: Option<Instant> = None;
167    loop {
168        if config.auto_sync {
169            match core.take_sync_intent() {
170                syncular_client::SyncIntent::Interactive => {
171                    background_deadline = None;
172                    core.sync_until_idle();
173                    pump_events(&mut core, &emit);
174                    continue;
175                }
176                syncular_client::SyncIntent::Background { delay_ms } => {
177                    let candidate = Instant::now()
178                        .checked_add(Duration::from_millis(delay_ms))
179                        .unwrap_or_else(Instant::now);
180                    background_deadline = Some(
181                        background_deadline.map_or(candidate, |current| current.min(candidate)),
182                    );
183                }
184                syncular_client::SyncIntent::None => {}
185            }
186        }
187
188        let request = if let Some(deadline) = background_deadline {
189            let now = Instant::now();
190            if deadline <= now {
191                background_deadline = None;
192                core.sync_until_idle();
193                pump_events(&mut core, &emit);
194                continue;
195            }
196            match rx.recv_timeout(deadline.saturating_duration_since(now)) {
197                Ok(request) => request,
198                Err(RecvTimeoutError::Timeout) => {
199                    background_deadline = None;
200                    core.sync_until_idle();
201                    pump_events(&mut core, &emit);
202                    continue;
203                }
204                Err(RecvTimeoutError::Disconnected) => {
205                    core.shutdown();
206                    return;
207                }
208            }
209        } else {
210            match rx.recv() {
211                Ok(request) => request,
212                Err(std::sync::mpsc::RecvError) => {
213                    core.shutdown();
214                    return;
215                }
216            }
217        };
218
219        match request {
220            Request::Command { command, reply } => {
221                let command = inject_db_path(command, &config);
222                let result = core.command(&command);
223                let _ = reply.send(result);
224                pump_events(&mut core, &emit);
225            }
226            Request::Query { sql, params, reply } => {
227                let result = core.query(&sql, params);
228                let _ = reply.send(result);
229                pump_events(&mut core, &emit);
230            }
231            Request::SetHeaders { headers, reply } => {
232                core.set_headers(headers);
233                let _ = reply.send(json!({ "result": null }));
234            }
235            Request::TransportWake => {
236                core.poll_transport();
237                pump_events(&mut core, &emit);
238            }
239            Request::Shutdown => {
240                core.shutdown();
241                return;
242            }
243        }
244    }
245}
246
247/// Inject the configured `db_path` into a `create` command's params if the JS
248/// side did not already supply one — so persistence is a plugin-config concern,
249/// not something every app must thread through the bridge.
250fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
251    if command.get("method").and_then(Value::as_str) != Some("create") {
252        return command;
253    }
254    let Some(db_path) = &config.db_path else {
255        return command;
256    };
257    let params = command.get_mut("params").and_then(Value::as_object_mut);
258    if let Some(params) = params {
259        params
260            .entry("dbPath")
261            .or_insert_with(|| Value::from(db_path.clone()));
262    } else if let Some(obj) = command.as_object_mut() {
263        obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
264    }
265    command
266}
267
268fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
269    for event in core.drain_events() {
270        emit(&event.json);
271    }
272}
273
274// -- Tauri commands (the thin shell) -----------------------------------------
275
276#[tauri::command]
277async fn syncular_command<R: Runtime>(
278    app: tauri::AppHandle<R>,
279    command: Value,
280) -> Result<Value, String> {
281    let state = app.state::<SyncularState>();
282    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
283    state.send(Request::Command {
284        command,
285        reply: reply_tx,
286    })?;
287    reply_rx
288        .recv()
289        .map_err(|_| "the syncular core dropped the reply".to_owned())
290}
291
292/// Replace the native transport's request headers at runtime — the auth
293/// rotation path (RFC 0002 §2.3): a fresh JWT reaches the transport without
294/// re-registering the plugin. HTTP requests use the new set from the next
295/// call; the realtime socket applies it on its next (re)connect.
296#[tauri::command]
297async fn syncular_set_headers<R: Runtime>(
298    app: tauri::AppHandle<R>,
299    headers: std::collections::BTreeMap<String, String>,
300) -> Result<Value, String> {
301    let state = app.state::<SyncularState>();
302    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
303    state.send(Request::SetHeaders {
304        headers: headers.into_iter().collect(),
305        reply: reply_tx,
306    })?;
307    reply_rx
308        .recv()
309        .map_err(|_| "the syncular core dropped the reply".to_owned())
310}
311
312#[tauri::command]
313async fn syncular_query<R: Runtime>(
314    app: tauri::AppHandle<R>,
315    sql: String,
316    params: Option<Value>,
317) -> Result<Value, String> {
318    let state = app.state::<SyncularState>();
319    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
320    state.send(Request::Query {
321        sql,
322        params: params.unwrap_or(Value::Null),
323        reply: reply_tx,
324    })?;
325    reply_rx
326        .recv()
327        .map_err(|_| "the syncular core dropped the reply".to_owned())
328}
329
330/// Initialize the plugin with a config. Register with
331/// `tauri::Builder::default().plugin(tauri_plugin_syncular::init(config))`.
332///
333/// The owning thread is spawned in `setup`; it builds the core (native
334/// transport if `base_url` + the `native-transport` feature), pumps events onto
335/// [`EVENT_NAME`], and runs the §8.4 host loop. The mailbox `Sender` is managed
336/// as plugin state and torn down on `RunEvent::Exit`.
337pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
338    Builder::<R>::new("syncular")
339        .invoke_handler(tauri::generate_handler![
340            syncular_command,
341            syncular_query,
342            syncular_set_headers
343        ])
344        .setup(move |app, _api| {
345            let (tx, rx) = std::sync::mpsc::channel::<Request>();
346            app.manage(SyncularState {
347                sender: Mutex::new(tx.clone()),
348            });
349            let app_handle = app.clone();
350            let emit = move |value: &Value| {
351                // Best-effort: a webview that has gone away must not crash the
352                // owning thread. Emit to all windows on the syncular channel.
353                let _ = app_handle.emit(EVENT_NAME, value.clone());
354            };
355            std::thread::Builder::new()
356                .name("syncular-core".to_owned())
357                .spawn(move || run_owner_thread(config, tx, rx, emit))
358                .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
359            Ok(())
360        })
361        .on_event(|app, event| {
362            if let RunEvent::Exit = event {
363                if let Some(state) = app.try_state::<SyncularState>() {
364                    let _ = state.send(Request::Shutdown);
365                }
366            }
367        })
368        .build()
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn config_to_transport_json_shapes_fields() {
377        let config = SyncularConfig {
378            base_url: Some("https://api.example.com".to_owned()),
379            headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
380            ..Default::default()
381        };
382        let json = config.to_transport_json();
383        assert_eq!(json["baseUrl"], "https://api.example.com");
384        assert_eq!(json["headers"]["authorization"], "Bearer x");
385    }
386
387    #[test]
388    fn inject_db_path_adds_to_create_only() {
389        let config = SyncularConfig {
390            db_path: Some("/tmp/app.db".to_owned()),
391            ..Default::default()
392        };
393        // create gains the path…
394        let created = inject_db_path(
395            json!({ "method": "create", "params": { "clientId": "c1" } }),
396            &config,
397        );
398        assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
399        // …a create with no params object gets one…
400        let created2 = inject_db_path(json!({ "method": "create" }), &config);
401        assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
402        // …an explicit dbPath is preserved…
403        let explicit = inject_db_path(
404            json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
405            &config,
406        );
407        assert_eq!(explicit["params"]["dbPath"], "/other.db");
408        // …and a non-create command is untouched.
409        let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
410        assert!(mutate["params"].get("dbPath").is_none());
411    }
412
413    /// The owner-thread mailbox loop end-to-end, without any Tauri window: post
414    /// commands, collect emitted events. This is the real host path — the Tauri
415    /// commands are a two-line channel forward over exactly this.
416    #[test]
417    fn owner_thread_round_trips_over_mailbox() {
418        use std::sync::mpsc::channel;
419        use std::sync::{Arc, Mutex as StdMutex};
420
421        let (tx, rx) = channel::<Request>();
422        let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
423        let events_for_thread = Arc::clone(&events);
424        let config = SyncularConfig {
425            auto_sync: false,
426            ..Default::default()
427        };
428        let owner_tx = tx.clone();
429        let handle = std::thread::spawn(move || {
430            run_owner_thread(config, owner_tx, rx, move |v| {
431                events_for_thread.lock().unwrap().push(v.clone());
432            });
433        });
434
435        let call = |command: Value| -> Value {
436            let (rtx, rrx) = channel();
437            tx.send(Request::Command {
438                command,
439                reply: rtx,
440            })
441            .unwrap();
442            rrx.recv().unwrap()
443        };
444
445        let schema = json!({
446            "version": 1,
447            "tables": [{
448                "name": "todo", "primaryKey": "id",
449                "columns": [
450                    { "name": "id", "type": "string", "nullable": false },
451                    { "name": "title", "type": "string", "nullable": false }
452                ],
453                "scopes": []
454            }]
455        });
456        assert_eq!(
457            call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
458                ["result"],
459            json!({})
460        );
461        call(json!({ "method": "mutate", "params": { "mutations": [{
462            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
463        }] } }));
464
465        // A query over the mailbox.
466        let (qtx, qrx) = channel();
467        tx.send(Request::Query {
468            sql: "SELECT title FROM todo".to_owned(),
469            params: Value::Null,
470            reply: qtx,
471        })
472        .unwrap();
473        let rows = qrx.recv().unwrap();
474        assert_eq!(rows["result"]["rows"][0]["title"], "hi");
475
476        // RFC 0002 §2.3: header rotation rides the same mailbox; a
477        // client-local (Null-transport) core accepts and ignores the set.
478        let (htx, hrx) = channel();
479        tx.send(Request::SetHeaders {
480            headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
481            reply: htx,
482        })
483        .unwrap();
484        assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
485
486        tx.send(Request::Shutdown).unwrap();
487        handle.join().unwrap();
488
489        let seen = events.lock().unwrap();
490        let kinds: Vec<String> = seen
491            .iter()
492            .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
493            .collect();
494        // The local mutate emits the exact revisioned batch onto the channel.
495        assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
496    }
497}