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