1use std::collections::VecDeque;
28
29use serde_json::{json, Value};
30use syncular_client::{SyncClient, SyncIntent};
31use syncular_command::{dispatch, CreateEffects};
32
33use crate::transport::{self, HostTransport};
34
35#[derive(Debug, Clone)]
39pub struct Event {
40 pub json: Value,
41}
42
43pub struct SyncularCore {
46 client: Option<SyncClient>,
47 transport: HostTransport,
48 effects: CreateEffects,
49 queue: VecDeque<Event>,
50 interactive_sync: bool,
51 background_sync_ms: Option<u64>,
52}
53
54impl SyncularCore {
55 pub fn new(config: &Value) -> Result<Self, String> {
59 Self::new_with_notify(config, None)
60 }
61
62 pub fn new_with_notify(
63 config: &Value,
64 notify: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
65 ) -> Result<Self, String> {
66 let transport = HostTransport::from_config_with_notify(config, notify)?;
67 Ok(SyncularCore {
68 client: None,
69 transport,
70 effects: CreateEffects::default(),
71 queue: VecDeque::new(),
72 interactive_sync: false,
73 background_sync_ms: None,
74 })
75 }
76
77 pub fn command(&mut self, command: &Value) -> Value {
81 let method = command.get("method").and_then(Value::as_str).unwrap_or("");
82 let params = command.get("params").cloned().unwrap_or(Value::Null);
83 let result = dispatch(
84 &mut self.transport,
85 &mut self.client,
86 &mut self.effects,
87 method,
88 ¶ms,
89 );
90 if method == "create" {
91 self.transport.set_signed_urls(self.effects.signed_urls);
92 }
93 if method == "beginSecurityPreflight"
94 || method == "shutdown"
95 || (method == "create"
96 && params
97 .get("securityPreflight")
98 .and_then(Value::as_bool)
99 .unwrap_or(false))
100 {
101 self.interactive_sync = false;
102 self.background_sync_ms = None;
103 }
104 if let Ok(value) = &result {
105 if value.pointer("/effects/sync/kind").and_then(Value::as_str) == Some("interactive") {
106 self.interactive_sync = true;
107 }
108 }
109 self.drain_realtime();
110 self.drain_core_outputs();
111 match result {
112 Ok(value) => json!({ "result": value }),
113 Err((code, message)) => json!({ "error": { "code": code, "message": message } }),
114 }
115 }
116
117 pub fn query(&mut self, sql: &str, params: Value) -> Value {
122 let bind = match params {
123 Value::Null => Value::Array(Vec::new()),
124 other => other,
125 };
126 self.command(&json!({ "method": "query", "params": { "sql": sql, "params": bind } }))
127 }
128
129 pub fn take_sync_intent(&mut self) -> SyncIntent {
132 if std::mem::take(&mut self.interactive_sync) {
133 self.background_sync_ms = None;
134 SyncIntent::Interactive
135 } else if let Some(delay_ms) = self.background_sync_ms.take() {
136 SyncIntent::Background { delay_ms }
137 } else {
138 SyncIntent::None
139 }
140 }
141
142 pub fn sync_until_idle(&mut self) -> Value {
145 if self.client.is_none() {
146 return json!({ "result": null });
147 }
148 self.command(&json!({ "method": "syncUntilIdle", "params": {} }))
149 }
150
151 pub fn poll_transport(&mut self) {
153 self.drain_realtime();
154 self.drain_core_outputs();
155 }
156
157 pub fn drain_events(&mut self) -> Vec<Event> {
160 self.queue.drain(..).collect()
161 }
162
163 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
167 self.transport.set_headers(headers);
168 }
169
170 pub fn shutdown(&mut self) {
172 if let Some(mut client) = self.client.take() {
173 client.disconnect_realtime(&mut self.transport);
174 client.begin_security_preflight();
175 }
176 self.interactive_sync = false;
177 self.background_sync_ms = None;
178 self.transport.shutdown();
179 }
180
181 fn push(&mut self, json: Value) {
182 self.queue.push_back(Event { json });
183 }
184
185 fn drain_realtime(&mut self) {
188 if self.client.is_none() {
189 return;
190 }
191 let frames = self.transport.take_inbound();
192 for frame in frames {
193 match frame {
194 transport::Inbound::Text(text) => {
195 if is_presence_control(&text) {
196 self.push(json!({ "type": "presence" }));
197 }
198 if let Some(client) = self.client.as_mut() {
199 client.on_realtime_text(&text);
200 }
201 }
202 transport::Inbound::Binary(bytes) => {
203 if let Some(client) = self.client.as_mut() {
204 client.on_realtime_binary(&mut self.transport, &bytes);
205 }
206 }
207 }
208 }
209 }
210
211 fn drain_core_outputs(&mut self) {
213 let Some(client) = self.client.as_mut() else {
214 return;
215 };
216 let batches = client.drain_change_batches();
217 let intents = client.drain_sync_intents();
218 for batch in batches {
219 self.push(json!({ "type": "change", "batch": batch }));
220 }
221 for intent in intents {
222 match intent {
223 SyncIntent::Interactive => self.interactive_sync = true,
224 SyncIntent::Background { delay_ms } => {
225 self.background_sync_ms = Some(
226 self.background_sync_ms
227 .map_or(delay_ms, |current| current.min(delay_ms)),
228 );
229 }
230 SyncIntent::None => {}
231 }
232 }
233 }
234}
235
236fn is_presence_control(text: &str) -> bool {
239 serde_json::from_str::<Value>(text)
240 .ok()
241 .and_then(|v| {
242 v.get("event")
243 .and_then(Value::as_str)
244 .map(|e| e == "presence")
245 })
246 .unwrap_or(false)
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 fn simple_schema() -> Value {
254 json!({
255 "version": 1,
256 "tables": [{
257 "name": "todo",
258 "primaryKey": "id",
259 "columns": [
260 { "name": "id", "type": "string", "nullable": false },
261 { "name": "title", "type": "string", "nullable": false },
262 { "name": "done", "type": "boolean", "nullable": false }
263 ],
264 "scopes": []
265 }]
266 })
267 }
268
269 fn create(core: &mut SyncularCore) {
270 let reply = core.command(&json!({
271 "method": "create",
272 "params": { "clientId": "c1", "schema": simple_schema() }
273 }));
274 assert_eq!(reply["result"], json!({}), "create ok: {reply}");
275 }
276
277 #[test]
278 fn command_round_trip_create_mutate_query() {
279 let mut core = SyncularCore::new(&json!({})).unwrap();
280 create(&mut core);
281
282 let sub = core.command(&json!({
283 "method": "subscribe",
284 "params": { "id": "s1", "table": "todo", "scopes": {} }
285 }));
286 assert_eq!(sub["result"], json!({}));
287
288 let mutate = core.command(&json!({
289 "method": "mutate",
290 "params": { "mutations": [{
291 "op": "upsert", "table": "todo",
292 "values": { "id": "t1", "title": "hello", "done": false }
293 }] }
294 }));
295 assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
296
297 let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
299 let list = rows["result"]["rows"].as_array().expect("rows");
300 assert_eq!(list.len(), 1);
301 assert_eq!(list[0]["title"], "hello");
302 assert_eq!(list[0]["id"], "t1");
303 }
304
305 #[test]
306 fn query_binds_params() {
307 let mut core = SyncularCore::new(&json!({})).unwrap();
308 create(&mut core);
309 core.command(&json!({
310 "method": "mutate",
311 "params": { "mutations": [
312 { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
313 { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
314 ] }
315 }));
316 let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
317 let list = rows["result"]["rows"].as_array().expect("rows");
318 assert_eq!(list.len(), 1);
319 assert_eq!(list[0]["id"], "b");
320 }
321
322 #[test]
323 fn events_derived_after_mutate() {
324 let mut core = SyncularCore::new(&json!({})).unwrap();
325 create(&mut core);
327 let _ = core.drain_events();
328 core.command(&json!({
329 "method": "mutate",
330 "params": { "mutations": [{
331 "op": "upsert", "table": "todo",
332 "values": { "id": "t1", "title": "x", "done": false }
333 }] }
334 }));
335 let events = core.drain_events();
336 let kinds: Vec<&str> = events
339 .iter()
340 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
341 .collect();
342 assert!(kinds.contains(&"change"), "kinds: {kinds:?}");
343 let change = events
344 .iter()
345 .find(|event| event.json["type"] == "change")
346 .expect("change event");
347 assert_eq!(change.json["batch"]["revision"], "1");
348 assert_eq!(change.json["batch"]["tables"][0]["table"], "todo");
349 assert_eq!(change.json["batch"]["status"]["outbox"], 1);
350 assert_eq!(change.json["batch"]["status"]["syncNeeded"], false);
353 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
354 assert!(core.drain_events().is_empty());
356 }
357
358 #[test]
359 fn sync_without_native_transport_fails_loud() {
360 let mut core = SyncularCore::new(&json!({})).unwrap();
361 create(&mut core);
362 let outcome = core.command(&json!({ "method": "sync", "params": {} }));
363 assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
364 assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
365 }
366
367 #[test]
368 fn file_db_persists_across_reopen() {
369 let dir = std::env::temp_dir();
370 let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
371 let path_str = path.to_string_lossy().to_string();
372 let _ = std::fs::remove_file(&path);
373
374 {
375 let mut core = SyncularCore::new(&json!({})).unwrap();
376 let reply = core.command(&json!({
377 "method": "create",
378 "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
379 }));
380 assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
381 core.command(&json!({
382 "method": "mutate",
383 "params": { "mutations": [{
384 "op": "upsert", "table": "todo",
385 "values": { "id": "persisted", "title": "kept", "done": false }
386 }] }
387 }));
388 let revision = core.command(&json!({
389 "method": "localRevision", "params": {}
390 }));
391 assert_eq!(revision["result"]["revision"], "1");
392 }
393 {
396 let mut core = SyncularCore::new(&json!({})).unwrap();
397 let reopened = core.command(&json!({
398 "method": "create",
399 "params": { "schema": simple_schema(), "dbPath": path_str }
400 }));
401 assert_eq!(reopened["result"], json!({}), "reopen: {reopened}");
402 let rows = core.query("SELECT title FROM todo", Value::Null);
403 let list = rows["result"]["rows"].as_array().expect("rows");
404 assert_eq!(list.len(), 1, "reopened db: {rows}");
405 assert_eq!(list[0]["title"], "kept");
406 let revision = core.command(&json!({
407 "method": "localRevision", "params": {}
408 }));
409 assert_eq!(revision["result"]["revision"], "1");
410 let pending = core.command(&json!({
411 "method": "pendingCommitIds", "params": {}
412 }));
413 assert_eq!(pending["result"]["ids"].as_array().map(Vec::len), Some(1));
414 let status = core.command(&json!({
415 "method": "statusSnapshot", "params": {}
416 }));
417 assert_eq!(status["result"]["outbox"], 1);
418 assert_eq!(status["result"]["syncNeeded"], true);
419 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
420 }
421 {
422 let mut core = SyncularCore::new(&json!({})).unwrap();
423 let mismatch = core.command(&json!({
424 "method": "create",
425 "params": { "clientId": "different", "schema": simple_schema(), "dbPath": path_str }
426 }));
427 assert_eq!(mismatch["error"]["code"], "client.identity_mismatch");
428 }
429 let _ = std::fs::remove_file(&path);
430 }
431
432 #[test]
433 fn config_validation_rejects_baseurl_without_native_feature() {
434 let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
435 #[cfg(not(feature = "native-transport"))]
436 assert!(
437 result.is_err(),
438 "baseUrl must be refused without native-transport"
439 );
440 #[cfg(feature = "native-transport")]
441 assert!(result.is_ok(), "baseUrl builds with native-transport");
442 }
443}