use std::sync::mpsc::{Receiver, Sender};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use tauri::plugin::{Builder, TauriPlugin};
use tauri::{Emitter, Manager, RunEvent, Runtime};
pub mod core;
pub mod transport;
use core::SyncularCore;
pub const EVENT_NAME: &str = "syncular://event";
#[derive(Debug, Clone, Default)]
pub struct SyncularConfig {
pub base_url: Option<String>,
pub ws_url: Option<String>,
pub headers: Vec<(String, String)>,
pub db_path: Option<String>,
pub wake_jitter_ms: u64,
pub auto_sync: bool,
}
impl SyncularConfig {
fn to_transport_json(&self) -> Value {
let mut map = serde_json::Map::new();
if let Some(base) = &self.base_url {
map.insert("baseUrl".to_owned(), Value::from(base.clone()));
}
if let Some(ws) = &self.ws_url {
map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
}
if !self.headers.is_empty() {
let headers: serde_json::Map<String, Value> = self
.headers
.iter()
.map(|(k, v)| (k.clone(), Value::from(v.clone())))
.collect();
map.insert("headers".to_owned(), Value::Object(headers));
}
Value::Object(map)
}
}
enum Request {
Command {
command: Value,
reply: Sender<Value>,
},
Query {
sql: String,
params: Value,
reply: Sender<Value>,
},
SetHeaders {
headers: Vec<(String, String)>,
reply: Sender<Value>,
},
Shutdown,
}
struct SyncularState {
sender: Mutex<Sender<Request>>,
}
impl SyncularState {
fn send(&self, request: Request) -> Result<(), String> {
self.sender
.lock()
.map_err(|_| "syncular mailbox poisoned".to_owned())?
.send(request)
.map_err(|_| "the syncular core thread has stopped".to_owned())
}
}
fn run_owner_thread<F>(config: SyncularConfig, rx: Receiver<Request>, emit: F)
where
F: Fn(&Value) + Send + 'static,
{
let transport_json = config.to_transport_json();
let mut core = match SyncularCore::new(&transport_json) {
Ok(core) => core,
Err(message) => {
emit(&json!({ "type": "error", "message": message }));
return;
}
};
let idle_poll = Duration::from_millis(200);
let jitter_cap = config.wake_jitter_ms;
let mut next_seed: u64 = std::process::id() as u64 ^ 0x9E37_79B9_7F4A_7C15;
let mut jitter = || {
if jitter_cap == 0 {
return Duration::ZERO;
}
next_seed ^= next_seed << 13;
next_seed ^= next_seed >> 7;
next_seed ^= next_seed << 17;
Duration::from_millis(next_seed % (jitter_cap + 1))
};
let mut last_sync = Instant::now();
loop {
let request = rx.recv_timeout(idle_poll);
match request {
Ok(Request::Command { command, reply }) => {
let command = inject_db_path(command, &config);
let result = core.command(&command);
let _ = reply.send(result);
pump_events(&mut core, &emit);
}
Ok(Request::Query { sql, params, reply }) => {
let result = core.query(&sql, params);
let _ = reply.send(result);
pump_events(&mut core, &emit);
}
Ok(Request::SetHeaders { headers, reply }) => {
core.set_headers(headers);
let _ = reply.send(json!({ "result": null }));
}
Ok(Request::Shutdown) => {
core.shutdown();
return;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if config.auto_sync && core.sync_needed() {
let wait = jitter();
if !wait.is_zero() {
std::thread::sleep(wait);
}
core.sync_until_idle();
last_sync = Instant::now();
pump_events(&mut core, &emit);
}
let _ = last_sync;
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
core.shutdown();
return;
}
}
}
}
fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
if command.get("method").and_then(Value::as_str) != Some("create") {
return command;
}
let Some(db_path) = &config.db_path else {
return command;
};
let params = command.get_mut("params").and_then(Value::as_object_mut);
if let Some(params) = params {
params
.entry("dbPath")
.or_insert_with(|| Value::from(db_path.clone()));
} else if let Some(obj) = command.as_object_mut() {
obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
}
command
}
fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
for event in core.drain_events() {
emit(&event.json);
}
}
#[tauri::command]
async fn syncular_command<R: Runtime>(
app: tauri::AppHandle<R>,
command: Value,
) -> Result<Value, String> {
let state = app.state::<SyncularState>();
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
state.send(Request::Command {
command,
reply: reply_tx,
})?;
reply_rx
.recv()
.map_err(|_| "the syncular core dropped the reply".to_owned())
}
#[tauri::command]
async fn syncular_set_headers<R: Runtime>(
app: tauri::AppHandle<R>,
headers: std::collections::BTreeMap<String, String>,
) -> Result<Value, String> {
let state = app.state::<SyncularState>();
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
state.send(Request::SetHeaders {
headers: headers.into_iter().collect(),
reply: reply_tx,
})?;
reply_rx
.recv()
.map_err(|_| "the syncular core dropped the reply".to_owned())
}
#[tauri::command]
async fn syncular_query<R: Runtime>(
app: tauri::AppHandle<R>,
sql: String,
params: Option<Value>,
) -> Result<Value, String> {
let state = app.state::<SyncularState>();
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
state.send(Request::Query {
sql,
params: params.unwrap_or(Value::Null),
reply: reply_tx,
})?;
reply_rx
.recv()
.map_err(|_| "the syncular core dropped the reply".to_owned())
}
pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
let config = SyncularConfig {
auto_sync: true,
wake_jitter_ms: if config.wake_jitter_ms == 0 && config.base_url.is_none() {
0
} else if config.wake_jitter_ms == 0 {
250
} else {
config.wake_jitter_ms
},
..config
};
Builder::<R>::new("syncular")
.invoke_handler(tauri::generate_handler![
syncular_command,
syncular_query,
syncular_set_headers
])
.setup(move |app, _api| {
let (tx, rx) = std::sync::mpsc::channel::<Request>();
app.manage(SyncularState {
sender: Mutex::new(tx),
});
let app_handle = app.clone();
let emit = move |value: &Value| {
let _ = app_handle.emit(EVENT_NAME, value.clone());
};
std::thread::Builder::new()
.name("syncular-core".to_owned())
.spawn(move || run_owner_thread(config, rx, emit))
.map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
Ok(())
})
.on_event(|app, event| {
if let RunEvent::Exit = event {
if let Some(state) = app.try_state::<SyncularState>() {
let _ = state.send(Request::Shutdown);
}
}
})
.build()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_to_transport_json_shapes_fields() {
let config = SyncularConfig {
base_url: Some("https://api.example.com".to_owned()),
headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
..Default::default()
};
let json = config.to_transport_json();
assert_eq!(json["baseUrl"], "https://api.example.com");
assert_eq!(json["headers"]["authorization"], "Bearer x");
}
#[test]
fn inject_db_path_adds_to_create_only() {
let config = SyncularConfig {
db_path: Some("/tmp/app.db".to_owned()),
..Default::default()
};
let created = inject_db_path(
json!({ "method": "create", "params": { "clientId": "c1" } }),
&config,
);
assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
let created2 = inject_db_path(json!({ "method": "create" }), &config);
assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
let explicit = inject_db_path(
json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
&config,
);
assert_eq!(explicit["params"]["dbPath"], "/other.db");
let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
assert!(mutate["params"].get("dbPath").is_none());
}
#[test]
fn owner_thread_round_trips_over_mailbox() {
use std::sync::mpsc::channel;
use std::sync::{Arc, Mutex as StdMutex};
let (tx, rx) = channel::<Request>();
let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
let events_for_thread = Arc::clone(&events);
let config = SyncularConfig {
auto_sync: false,
..Default::default()
};
let handle = std::thread::spawn(move || {
run_owner_thread(config, rx, move |v| {
events_for_thread.lock().unwrap().push(v.clone());
});
});
let call = |command: Value| -> Value {
let (rtx, rrx) = channel();
tx.send(Request::Command {
command,
reply: rtx,
})
.unwrap();
rrx.recv().unwrap()
};
let schema = json!({
"version": 1,
"tables": [{
"name": "todo", "primaryKey": "id",
"columns": [
{ "name": "id", "type": "string", "nullable": false },
{ "name": "title", "type": "string", "nullable": false }
],
"scopes": []
}]
});
assert_eq!(
call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
["result"],
json!({})
);
call(json!({ "method": "mutate", "params": { "mutations": [{
"op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
}] } }));
let (qtx, qrx) = channel();
tx.send(Request::Query {
sql: "SELECT title FROM todo".to_owned(),
params: Value::Null,
reply: qtx,
})
.unwrap();
let rows = qrx.recv().unwrap();
assert_eq!(rows["result"]["rows"][0]["title"], "hi");
let (htx, hrx) = channel();
tx.send(Request::SetHeaders {
headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
reply: htx,
})
.unwrap();
assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
tx.send(Request::Shutdown).unwrap();
handle.join().unwrap();
let seen = events.lock().unwrap();
let kinds: Vec<String> = seen
.iter()
.filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
.collect();
assert!(kinds.iter().any(|k| k == "invalidate"), "kinds: {kinds:?}");
}
}