1use std::collections::VecDeque;
28
29use serde_json::{json, Value};
30use syncular_client::{ClientDiagnosticsRequest, 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 last_diagnostics_fingerprint: Option<Value>,
51 interactive_sync: bool,
52 background_sync_ms: Option<u64>,
53}
54
55impl SyncularCore {
56 pub fn new(config: &Value) -> Result<Self, String> {
60 Self::new_with_notify(config, None)
61 }
62
63 pub fn new_with_notify(
64 config: &Value,
65 notify: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
66 ) -> Result<Self, String> {
67 let transport = HostTransport::from_config_with_notify(config, notify)?;
68 Ok(SyncularCore {
69 client: None,
70 transport,
71 effects: CreateEffects::default(),
72 queue: VecDeque::new(),
73 last_diagnostics_fingerprint: None,
74 interactive_sync: false,
75 background_sync_ms: None,
76 })
77 }
78
79 pub fn command(&mut self, command: &Value) -> Value {
83 let method = command.get("method").and_then(Value::as_str).unwrap_or("");
84 let params = command.get("params").cloned().unwrap_or(Value::Null);
85 let result = dispatch(
86 &mut self.transport,
87 &mut self.client,
88 &mut self.effects,
89 method,
90 ¶ms,
91 );
92 if method == "create" {
93 self.last_diagnostics_fingerprint = None;
94 self.transport.set_signed_urls(self.effects.signed_urls);
95 }
96 if method == "beginSecurityPreflight"
97 || method == "shutdown"
98 || (method == "create"
99 && params
100 .get("securityPreflight")
101 .and_then(Value::as_bool)
102 .unwrap_or(false))
103 {
104 self.interactive_sync = false;
105 self.background_sync_ms = None;
106 }
107 if let Ok(value) = &result {
108 if value.pointer("/effects/sync/kind").and_then(Value::as_str) == Some("interactive") {
109 self.interactive_sync = true;
110 }
111 }
112 self.drain_realtime();
113 self.drain_core_outputs();
114 self.emit_diagnostics_if_changed();
115 match result {
116 Ok(value) => json!({ "result": value }),
117 Err((code, message)) => json!({ "error": { "code": code, "message": message } }),
118 }
119 }
120
121 pub fn query(&mut self, sql: &str, params: Value) -> Value {
126 let bind = match params {
127 Value::Null => Value::Array(Vec::new()),
128 other => other,
129 };
130 self.command(&json!({ "method": "query", "params": { "sql": sql, "params": bind } }))
131 }
132
133 pub fn take_sync_intent(&mut self) -> SyncIntent {
136 if std::mem::take(&mut self.interactive_sync) {
137 self.background_sync_ms = None;
138 SyncIntent::Interactive
139 } else if let Some(delay_ms) = self.background_sync_ms.take() {
140 SyncIntent::Background { delay_ms }
141 } else {
142 SyncIntent::None
143 }
144 }
145
146 pub fn sync_until_idle(&mut self) -> Value {
149 if self.client.is_none() {
150 return json!({ "result": null });
151 }
152 self.command(&json!({ "method": "syncUntilIdle", "params": {} }))
153 }
154
155 pub fn poll_transport(&mut self) {
157 self.drain_realtime();
158 self.drain_core_outputs();
159 self.emit_diagnostics_if_changed();
160 }
161
162 pub fn drain_events(&mut self) -> Vec<Event> {
165 self.queue.drain(..).collect()
166 }
167
168 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
172 self.transport.set_headers(headers);
173 }
174
175 pub fn shutdown(&mut self) {
177 if let Some(mut client) = self.client.take() {
178 client.disconnect_realtime(&mut self.transport);
179 client.begin_security_preflight();
180 }
181 self.interactive_sync = false;
182 self.background_sync_ms = None;
183 self.transport.shutdown();
184 }
185
186 fn push(&mut self, json: Value) {
187 self.queue.push_back(Event { json });
188 }
189
190 fn drain_realtime(&mut self) {
193 if self.client.is_none() {
194 return;
195 }
196 let frames = self.transport.take_inbound();
197 for frame in frames {
198 match frame {
199 transport::Inbound::Text(text) => {
200 if is_presence_control(&text) {
201 self.push(json!({ "type": "presence" }));
202 }
203 if let Some(client) = self.client.as_mut() {
204 client.on_realtime_text(&text);
205 }
206 }
207 transport::Inbound::Binary(bytes) => {
208 if let Some(client) = self.client.as_mut() {
209 client.on_realtime_binary(&mut self.transport, &bytes);
210 }
211 }
212 }
213 }
214 }
215
216 fn drain_core_outputs(&mut self) {
218 let Some(client) = self.client.as_mut() else {
219 return;
220 };
221 let batches = client.drain_change_batches();
222 let intents = client.drain_sync_intents();
223 for batch in batches {
224 self.push(json!({ "type": "change", "batch": batch }));
225 }
226 for intent in intents {
227 match intent {
228 SyncIntent::Interactive => self.interactive_sync = true,
229 SyncIntent::Background { delay_ms } => {
230 self.background_sync_ms = Some(
231 self.background_sync_ms
232 .map_or(delay_ms, |current| current.min(delay_ms)),
233 );
234 }
235 SyncIntent::None => {}
236 }
237 }
238 }
239
240 fn emit_diagnostics_if_changed(&mut self) {
245 let Some(client) = self.client.as_ref() else {
246 return;
247 };
248 if client.security_preflight() {
249 return;
250 }
251 let Ok(snapshot) = client.diagnostics_snapshot(&ClientDiagnosticsRequest::default()) else {
252 return;
253 };
254 let Ok(mut fingerprint) = serde_json::to_value(&snapshot) else {
255 return;
256 };
257 if let Some(object) = fingerprint.as_object_mut() {
258 object.remove("capturedAtMs");
259 }
260 if self.last_diagnostics_fingerprint.as_ref() == Some(&fingerprint) {
261 return;
262 }
263 self.last_diagnostics_fingerprint = Some(fingerprint);
264 self.push(json!({ "type": "diagnostics", "snapshot": snapshot }));
265 }
266}
267
268fn is_presence_control(text: &str) -> bool {
271 serde_json::from_str::<Value>(text)
272 .ok()
273 .and_then(|v| {
274 v.get("event")
275 .and_then(Value::as_str)
276 .map(|e| e == "presence")
277 })
278 .unwrap_or(false)
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 fn simple_schema() -> Value {
286 json!({
287 "version": 1,
288 "tables": [{
289 "name": "todo",
290 "primaryKey": "id",
291 "columns": [
292 { "name": "id", "type": "string", "nullable": false },
293 { "name": "title", "type": "string", "nullable": false },
294 { "name": "done", "type": "boolean", "nullable": false }
295 ],
296 "scopes": []
297 }]
298 })
299 }
300
301 fn create(core: &mut SyncularCore) {
302 let reply = core.command(&json!({
303 "method": "create",
304 "params": { "clientId": "c1", "schema": simple_schema() }
305 }));
306 assert_eq!(reply["result"], json!({}), "create ok: {reply}");
307 }
308
309 #[test]
310 fn command_round_trip_create_mutate_query() {
311 let mut core = SyncularCore::new(&json!({})).unwrap();
312 create(&mut core);
313
314 let sub = core.command(&json!({
315 "method": "subscribe",
316 "params": { "id": "s1", "table": "todo", "scopes": {} }
317 }));
318 assert_eq!(sub["result"], json!({}));
319
320 let mutate = core.command(&json!({
321 "method": "mutate",
322 "params": { "mutations": [{
323 "op": "upsert", "table": "todo",
324 "values": { "id": "t1", "title": "hello", "done": false }
325 }] }
326 }));
327 assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
328
329 let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
331 let list = rows["result"]["rows"].as_array().expect("rows");
332 assert_eq!(list.len(), 1);
333 assert_eq!(list[0]["title"], "hello");
334 assert_eq!(list[0]["id"], "t1");
335 }
336
337 #[test]
338 fn query_binds_params() {
339 let mut core = SyncularCore::new(&json!({})).unwrap();
340 create(&mut core);
341 core.command(&json!({
342 "method": "mutate",
343 "params": { "mutations": [
344 { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
345 { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
346 ] }
347 }));
348 let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
349 let list = rows["result"]["rows"].as_array().expect("rows");
350 assert_eq!(list.len(), 1);
351 assert_eq!(list[0]["id"], "b");
352 }
353
354 #[test]
355 fn events_derived_after_mutate() {
356 let mut core = SyncularCore::new(&json!({})).unwrap();
357 create(&mut core);
359 let _ = core.drain_events();
360 core.command(&json!({
361 "method": "mutate",
362 "params": { "mutations": [{
363 "op": "upsert", "table": "todo",
364 "values": { "id": "t1", "title": "x", "done": false }
365 }] }
366 }));
367 let events = core.drain_events();
368 let kinds: Vec<&str> = events
371 .iter()
372 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
373 .collect();
374 assert!(kinds.contains(&"change"), "kinds: {kinds:?}");
375 assert!(kinds.contains(&"diagnostics"), "kinds: {kinds:?}");
376 let change = events
377 .iter()
378 .find(|event| event.json["type"] == "change")
379 .expect("change event");
380 assert_eq!(change.json["batch"]["revision"], "1");
381 assert_eq!(change.json["batch"]["tables"][0]["table"], "todo");
382 assert_eq!(change.json["batch"]["status"]["outbox"], 1);
383 assert_eq!(change.json["batch"]["status"]["syncNeeded"], false);
386 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
387 assert!(core.drain_events().is_empty());
389 }
390
391 #[test]
392 fn diagnostics_are_versioned_bounded_and_payload_free() {
393 let mut core = SyncularCore::new(&json!({})).unwrap();
394 create(&mut core);
395 let reply = core.command(&json!({
396 "method": "diagnosticsSnapshot",
397 "params": {
398 "expectedSubscriptions": [{ "id": "membership", "table": "todo" }]
399 }
400 }));
401 assert_eq!(reply["result"]["version"], 1);
402 assert_eq!(reply["result"]["subscriptions"][0]["state"], "unregistered");
403 let encoded = reply.to_string();
404 assert!(!encoded.contains("clientId"));
405 assert!(!encoded.contains("dbPath"));
406 assert!(!encoded.contains("operations"));
407 }
408
409 #[test]
410 fn sync_without_native_transport_fails_loud() {
411 let mut core = SyncularCore::new(&json!({})).unwrap();
412 create(&mut core);
413 let outcome = core.command(&json!({ "method": "sync", "params": {} }));
414 assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
415 assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
416 }
417
418 #[test]
419 fn file_db_persists_across_reopen() {
420 let dir = std::env::temp_dir();
421 let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
422 let path_str = path.to_string_lossy().to_string();
423 let _ = std::fs::remove_file(&path);
424
425 {
426 let mut core = SyncularCore::new(&json!({})).unwrap();
427 let reply = core.command(&json!({
428 "method": "create",
429 "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
430 }));
431 assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
432 core.command(&json!({
433 "method": "mutate",
434 "params": { "mutations": [{
435 "op": "upsert", "table": "todo",
436 "values": { "id": "persisted", "title": "kept", "done": false }
437 }] }
438 }));
439 let revision = core.command(&json!({
440 "method": "localRevision", "params": {}
441 }));
442 assert_eq!(revision["result"]["revision"], "1");
443 }
444 {
447 let mut core = SyncularCore::new(&json!({})).unwrap();
448 let reopened = core.command(&json!({
449 "method": "create",
450 "params": { "schema": simple_schema(), "dbPath": path_str }
451 }));
452 assert_eq!(reopened["result"], json!({}), "reopen: {reopened}");
453 let rows = core.query("SELECT title FROM todo", Value::Null);
454 let list = rows["result"]["rows"].as_array().expect("rows");
455 assert_eq!(list.len(), 1, "reopened db: {rows}");
456 assert_eq!(list[0]["title"], "kept");
457 let revision = core.command(&json!({
458 "method": "localRevision", "params": {}
459 }));
460 assert_eq!(revision["result"]["revision"], "1");
461 let pending = core.command(&json!({
462 "method": "pendingCommitIds", "params": {}
463 }));
464 assert_eq!(pending["result"]["ids"].as_array().map(Vec::len), Some(1));
465 let status = core.command(&json!({
466 "method": "statusSnapshot", "params": {}
467 }));
468 assert_eq!(status["result"]["outbox"], 1);
469 assert_eq!(status["result"]["syncNeeded"], true);
470 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
471 }
472 {
473 let mut core = SyncularCore::new(&json!({})).unwrap();
474 let mismatch = core.command(&json!({
475 "method": "create",
476 "params": { "clientId": "different", "schema": simple_schema(), "dbPath": path_str }
477 }));
478 assert_eq!(mismatch["error"]["code"], "client.identity_mismatch");
479 }
480 let _ = std::fs::remove_file(&path);
481 }
482
483 #[test]
484 fn config_validation_rejects_baseurl_without_native_feature() {
485 let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
486 #[cfg(not(feature = "native-transport"))]
487 assert!(
488 result.is_err(),
489 "baseUrl must be refused without native-transport"
490 );
491 #[cfg(feature = "native-transport")]
492 assert!(result.is_ok(), "baseUrl builds with native-transport");
493 }
494}