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 let Ok(value) = &result {
94 if value.pointer("/effects/sync/kind").and_then(Value::as_str) == Some("interactive") {
95 self.interactive_sync = true;
96 }
97 }
98 self.drain_realtime();
99 self.drain_core_outputs();
100 match result {
101 Ok(value) => json!({ "result": value }),
102 Err((code, message)) => json!({ "error": { "code": code, "message": message } }),
103 }
104 }
105
106 pub fn query(&mut self, sql: &str, params: Value) -> Value {
111 let bind = match params {
112 Value::Null => Value::Array(Vec::new()),
113 other => other,
114 };
115 self.command(&json!({ "method": "query", "params": { "sql": sql, "params": bind } }))
116 }
117
118 pub fn take_sync_intent(&mut self) -> SyncIntent {
121 if std::mem::take(&mut self.interactive_sync) {
122 self.background_sync_ms = None;
123 SyncIntent::Interactive
124 } else if let Some(delay_ms) = self.background_sync_ms.take() {
125 SyncIntent::Background { delay_ms }
126 } else {
127 SyncIntent::None
128 }
129 }
130
131 pub fn sync_until_idle(&mut self) -> Value {
134 if self.client.is_none() {
135 return json!({ "result": null });
136 }
137 self.command(&json!({ "method": "syncUntilIdle", "params": {} }))
138 }
139
140 pub fn poll_transport(&mut self) {
142 self.drain_realtime();
143 self.drain_core_outputs();
144 }
145
146 pub fn drain_events(&mut self) -> Vec<Event> {
149 self.queue.drain(..).collect()
150 }
151
152 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
156 self.transport.set_headers(headers);
157 }
158
159 pub fn shutdown(&mut self) {
161 self.transport.shutdown();
162 }
163
164 fn push(&mut self, json: Value) {
165 self.queue.push_back(Event { json });
166 }
167
168 fn drain_realtime(&mut self) {
171 if self.client.is_none() {
172 return;
173 }
174 let frames = self.transport.take_inbound();
175 for frame in frames {
176 match frame {
177 transport::Inbound::Text(text) => {
178 if is_presence_control(&text) {
179 self.push(json!({ "type": "presence" }));
180 }
181 if let Some(client) = self.client.as_mut() {
182 client.on_realtime_text(&text);
183 }
184 }
185 transport::Inbound::Binary(bytes) => {
186 if let Some(client) = self.client.as_mut() {
187 client.on_realtime_binary(&mut self.transport, &bytes);
188 }
189 }
190 }
191 }
192 }
193
194 fn drain_core_outputs(&mut self) {
196 let Some(client) = self.client.as_mut() else {
197 return;
198 };
199 let batches = client.drain_change_batches();
200 let intents = client.drain_sync_intents();
201 for batch in batches {
202 self.push(json!({ "type": "change", "batch": batch }));
203 }
204 for intent in intents {
205 match intent {
206 SyncIntent::Interactive => self.interactive_sync = true,
207 SyncIntent::Background { delay_ms } => {
208 self.background_sync_ms = Some(
209 self.background_sync_ms
210 .map_or(delay_ms, |current| current.min(delay_ms)),
211 );
212 }
213 SyncIntent::None => {}
214 }
215 }
216 }
217}
218
219fn is_presence_control(text: &str) -> bool {
222 serde_json::from_str::<Value>(text)
223 .ok()
224 .and_then(|v| {
225 v.get("event")
226 .and_then(Value::as_str)
227 .map(|e| e == "presence")
228 })
229 .unwrap_or(false)
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 fn simple_schema() -> Value {
237 json!({
238 "version": 1,
239 "tables": [{
240 "name": "todo",
241 "primaryKey": "id",
242 "columns": [
243 { "name": "id", "type": "string", "nullable": false },
244 { "name": "title", "type": "string", "nullable": false },
245 { "name": "done", "type": "boolean", "nullable": false }
246 ],
247 "scopes": []
248 }]
249 })
250 }
251
252 fn create(core: &mut SyncularCore) {
253 let reply = core.command(&json!({
254 "method": "create",
255 "params": { "clientId": "c1", "schema": simple_schema() }
256 }));
257 assert_eq!(reply["result"], json!({}), "create ok: {reply}");
258 }
259
260 #[test]
261 fn command_round_trip_create_mutate_query() {
262 let mut core = SyncularCore::new(&json!({})).unwrap();
263 create(&mut core);
264
265 let sub = core.command(&json!({
266 "method": "subscribe",
267 "params": { "id": "s1", "table": "todo", "scopes": {} }
268 }));
269 assert_eq!(sub["result"], json!({}));
270
271 let mutate = core.command(&json!({
272 "method": "mutate",
273 "params": { "mutations": [{
274 "op": "upsert", "table": "todo",
275 "values": { "id": "t1", "title": "hello", "done": false }
276 }] }
277 }));
278 assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
279
280 let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
282 let list = rows["result"]["rows"].as_array().expect("rows");
283 assert_eq!(list.len(), 1);
284 assert_eq!(list[0]["title"], "hello");
285 assert_eq!(list[0]["id"], "t1");
286 }
287
288 #[test]
289 fn query_binds_params() {
290 let mut core = SyncularCore::new(&json!({})).unwrap();
291 create(&mut core);
292 core.command(&json!({
293 "method": "mutate",
294 "params": { "mutations": [
295 { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
296 { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
297 ] }
298 }));
299 let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
300 let list = rows["result"]["rows"].as_array().expect("rows");
301 assert_eq!(list.len(), 1);
302 assert_eq!(list[0]["id"], "b");
303 }
304
305 #[test]
306 fn events_derived_after_mutate() {
307 let mut core = SyncularCore::new(&json!({})).unwrap();
308 create(&mut core);
310 let _ = core.drain_events();
311 core.command(&json!({
312 "method": "mutate",
313 "params": { "mutations": [{
314 "op": "upsert", "table": "todo",
315 "values": { "id": "t1", "title": "x", "done": false }
316 }] }
317 }));
318 let events = core.drain_events();
319 let kinds: Vec<&str> = events
322 .iter()
323 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
324 .collect();
325 assert!(kinds.contains(&"change"), "kinds: {kinds:?}");
326 let change = events
327 .iter()
328 .find(|event| event.json["type"] == "change")
329 .expect("change event");
330 assert_eq!(change.json["batch"]["revision"], "1");
331 assert_eq!(change.json["batch"]["tables"][0]["table"], "todo");
332 assert_eq!(change.json["batch"]["status"]["outbox"], 1);
333 assert_eq!(change.json["batch"]["status"]["syncNeeded"], false);
336 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
337 assert!(core.drain_events().is_empty());
339 }
340
341 #[test]
342 fn sync_without_native_transport_fails_loud() {
343 let mut core = SyncularCore::new(&json!({})).unwrap();
344 create(&mut core);
345 let outcome = core.command(&json!({ "method": "sync", "params": {} }));
346 assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
347 assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
348 }
349
350 #[test]
351 fn file_db_persists_across_reopen() {
352 let dir = std::env::temp_dir();
353 let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
354 let path_str = path.to_string_lossy().to_string();
355 let _ = std::fs::remove_file(&path);
356
357 {
358 let mut core = SyncularCore::new(&json!({})).unwrap();
359 let reply = core.command(&json!({
360 "method": "create",
361 "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
362 }));
363 assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
364 core.command(&json!({
365 "method": "mutate",
366 "params": { "mutations": [{
367 "op": "upsert", "table": "todo",
368 "values": { "id": "persisted", "title": "kept", "done": false }
369 }] }
370 }));
371 let revision = core.command(&json!({
372 "method": "localRevision", "params": {}
373 }));
374 assert_eq!(revision["result"]["revision"], "1");
375 }
376 {
379 let mut core = SyncularCore::new(&json!({})).unwrap();
380 let reopened = core.command(&json!({
381 "method": "create",
382 "params": { "schema": simple_schema(), "dbPath": path_str }
383 }));
384 assert_eq!(reopened["result"], json!({}), "reopen: {reopened}");
385 let rows = core.query("SELECT title FROM todo", Value::Null);
386 let list = rows["result"]["rows"].as_array().expect("rows");
387 assert_eq!(list.len(), 1, "reopened db: {rows}");
388 assert_eq!(list[0]["title"], "kept");
389 let revision = core.command(&json!({
390 "method": "localRevision", "params": {}
391 }));
392 assert_eq!(revision["result"]["revision"], "1");
393 let pending = core.command(&json!({
394 "method": "pendingCommitIds", "params": {}
395 }));
396 assert_eq!(pending["result"]["ids"].as_array().map(Vec::len), Some(1));
397 let status = core.command(&json!({
398 "method": "statusSnapshot", "params": {}
399 }));
400 assert_eq!(status["result"]["outbox"], 1);
401 assert_eq!(status["result"]["syncNeeded"], true);
402 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
403 }
404 {
405 let mut core = SyncularCore::new(&json!({})).unwrap();
406 let mismatch = core.command(&json!({
407 "method": "create",
408 "params": { "clientId": "different", "schema": simple_schema(), "dbPath": path_str }
409 }));
410 assert_eq!(mismatch["error"]["code"], "client.identity_mismatch");
411 }
412 let _ = std::fs::remove_file(&path);
413 }
414
415 #[test]
416 fn config_validation_rejects_baseurl_without_native_feature() {
417 let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
418 #[cfg(not(feature = "native-transport"))]
419 assert!(
420 result.is_err(),
421 "baseUrl must be refused without native-transport"
422 );
423 #[cfg(feature = "native-transport")]
424 assert!(result.is_ok(), "baseUrl builds with native-transport");
425 }
426}