1use std::collections::VecDeque;
28
29use serde_json::{json, Value};
30use syncular_client::{
31 ClientDiagnosticsRequest, ClientDiagnosticsSnapshot, QueryOwner, QueryReadFailure, SyncClient,
32 SyncIntent,
33};
34use syncular_command::{dispatch, CreateEffects};
35
36use crate::transport::{self, HostTransport};
37
38#[derive(Debug, Clone)]
42pub struct Event {
43 pub json: Value,
44}
45
46pub struct SyncularCore {
49 pub progress_listener: Option<syncular_client::SyncProgressListener>,
50 progress_subscription: Option<syncular_client::ProgressSubscription>,
51 client: Option<SyncClient>,
52 transport: HostTransport,
53 effects: CreateEffects,
54 queue: VecDeque<Event>,
55 last_diagnostics_snapshot: Option<ClientDiagnosticsSnapshot>,
56 diagnostics_observed: bool,
61 interactive_sync: bool,
62 background_sync_ms: Option<u64>,
63}
64
65impl SyncularCore {
66 pub fn new(config: &Value) -> Result<Self, String> {
70 Self::new_with_notify(config, None)
71 }
72
73 pub fn new_with_notify(
74 config: &Value,
75 notify: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
76 ) -> Result<Self, String> {
77 let transport = HostTransport::from_config_with_notify(config, notify)?;
78 Ok(SyncularCore {
79 progress_listener: None,
80 progress_subscription: None,
81 client: None,
82 transport,
83 effects: CreateEffects::default(),
84 queue: VecDeque::new(),
85 last_diagnostics_snapshot: None,
86 diagnostics_observed: false,
87 interactive_sync: false,
88 background_sync_ms: None,
89 })
90 }
91
92 pub fn command(&mut self, command: &Value) -> Value {
96 let method = command.get("method").and_then(Value::as_str).unwrap_or("");
97 let params = command.get("params").cloned().unwrap_or(Value::Null);
98 if method == "enableDiagnostics" {
99 self.diagnostics_observed = true;
103 self.last_diagnostics_snapshot = None;
104 self.drain_realtime();
105 self.drain_core_outputs();
106 self.emit_diagnostics_if_changed();
107 return json!({ "result": {} });
108 }
109 if method == "diagnosticsSnapshot" {
110 self.diagnostics_observed = true;
113 }
114 let result = dispatch(
115 &mut self.transport,
116 &mut self.client,
117 &mut self.effects,
118 method,
119 ¶ms,
120 );
121 if method == "create" && result.is_ok() {
122 self.progress_subscription = self.client.as_ref().and_then(|client| {
123 self.progress_listener.clone().map(|listener| {
124 client
125 .progress()
126 .subscribe(move |progress| listener(progress))
127 })
128 });
129 }
130 if method == "shutdown" {
131 self.progress_subscription = None;
132 }
133 if method == "create" {
134 self.last_diagnostics_snapshot = None;
135 self.transport.set_signed_urls(self.effects.signed_urls);
136 }
137 if method == "beginSecurityPreflight"
138 || method == "shutdown"
139 || (method == "create"
140 && params
141 .get("securityPreflight")
142 .and_then(Value::as_bool)
143 .unwrap_or(false))
144 {
145 self.interactive_sync = false;
146 self.background_sync_ms = None;
147 }
148 if let Ok(value) = &result {
149 if value.pointer("/effects/sync/kind").and_then(Value::as_str) == Some("interactive") {
150 self.interactive_sync = true;
151 }
152 }
153 self.drain_realtime();
154 self.drain_core_outputs();
155 self.emit_diagnostics_if_changed();
156 match result {
157 Ok(mut value) => {
158 if let Some(object) = value.as_object_mut() {
161 object.remove("effects");
162 }
163 json!({ "result": value })
164 }
165 Err((code, message)) => json!({ "error": { "code": code, "message": message } }),
166 }
167 }
168
169 pub fn query(&mut self, sql: &str, params: Value) -> Value {
174 let bind = match params {
175 Value::Null => Value::Array(Vec::new()),
176 other => other,
177 };
178 self.command(&json!({ "method": "query", "params": { "sql": sql, "params": bind } }))
179 }
180
181 pub fn take_sync_intent(&mut self) -> SyncIntent {
184 if std::mem::take(&mut self.interactive_sync) {
185 self.background_sync_ms = None;
186 SyncIntent::Interactive
187 } else if let Some(delay_ms) = self.background_sync_ms.take() {
188 SyncIntent::Background { delay_ms }
189 } else {
190 SyncIntent::None
191 }
192 }
193
194 pub fn sync_until_idle(&mut self) -> Value {
197 if self.client.is_none() {
198 return json!({ "result": null });
199 }
200 self.command(&json!({ "method": "syncUntilIdle", "params": {} }))
201 }
202
203 pub fn record_query_read(
205 &mut self,
206 id: &str,
207 tables: &[String],
208 failure: Option<&QueryReadFailure>,
209 ) {
210 let Some(client) = self.client.as_mut() else {
211 return;
212 };
213 let tables: Vec<&str> = tables.iter().map(String::as_str).collect();
214 client.record_query_read(
215 QueryOwner {
216 id,
217 tables: &tables,
218 },
219 failure,
220 );
221 self.emit_diagnostics_if_changed();
222 }
223
224 pub fn poll_transport(&mut self) {
226 self.drain_realtime();
227 self.drain_core_outputs();
228 self.emit_diagnostics_if_changed();
229 }
230
231 pub fn drain_events(&mut self) -> Vec<Event> {
234 self.queue.drain(..).collect()
235 }
236
237 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
241 self.transport.set_headers(headers);
242 }
243
244 pub fn shutdown(&mut self) {
246 if let Some(mut client) = self.client.take() {
247 client.disconnect_realtime(&mut self.transport);
248 client.seal_security_on_teardown();
251 }
252 self.interactive_sync = false;
253 self.background_sync_ms = None;
254 self.diagnostics_observed = false;
257 self.transport.shutdown();
258 }
259
260 fn push(&mut self, json: Value) {
261 self.queue.push_back(Event { json });
262 }
263
264 fn drain_realtime(&mut self) {
267 if self.client.is_none() {
268 return;
269 }
270 let frames = self.transport.take_inbound();
271 for frame in frames {
272 match frame {
273 transport::Inbound::Text(text) => {
274 if is_presence_control(&text) {
275 self.push(json!({ "type": "presence" }));
276 }
277 if let Some(client) = self.client.as_mut() {
278 client.on_realtime_text(&text);
279 }
280 }
281 transport::Inbound::Binary(bytes) => {
282 if let Some(client) = self.client.as_mut() {
283 client.on_realtime_binary(&mut self.transport, &bytes);
284 }
285 }
286 }
287 }
288 }
289
290 fn drain_core_outputs(&mut self) {
292 let Some(client) = self.client.as_mut() else {
293 return;
294 };
295 let batches = client.drain_change_batches();
296 let intents = client.drain_sync_intents();
297 for batch in batches {
298 self.push(json!({ "type": "change", "batch": batch }));
299 }
300 for intent in intents {
301 match intent {
302 SyncIntent::Interactive => self.interactive_sync = true,
303 SyncIntent::Background { delay_ms } => {
304 self.background_sync_ms = Some(
305 self.background_sync_ms
306 .map_or(delay_ms, |current| current.min(delay_ms)),
307 );
308 }
309 SyncIntent::None => {}
310 }
311 }
312 }
313
314 fn emit_diagnostics_if_changed(&mut self) {
323 if !self.diagnostics_observed {
324 return;
325 }
326 let Some(client) = self.client.as_ref() else {
327 return;
328 };
329 if client.security_preflight() {
330 return;
331 }
332 let Ok(snapshot) = client.diagnostics_snapshot(&ClientDiagnosticsRequest::default()) else {
333 return;
334 };
335 if let Some(previous) = &mut self.last_diagnostics_snapshot {
336 previous.captured_at_ms = snapshot.captured_at_ms;
338 if previous == &snapshot {
339 return;
340 }
341 }
342 let event = json!({ "type": "diagnostics", "snapshot": snapshot });
343 self.last_diagnostics_snapshot = Some(snapshot);
344 self.push(event);
345 }
346}
347
348fn is_presence_control(text: &str) -> bool {
351 serde_json::from_str::<Value>(text)
352 .ok()
353 .and_then(|v| {
354 v.get("event")
355 .and_then(Value::as_str)
356 .map(|e| e == "presence")
357 })
358 .unwrap_or(false)
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 fn simple_schema() -> Value {
366 json!({
367 "version": 1,
368 "tables": [{
369 "name": "todo",
370 "primaryKey": "id",
371 "columns": [
372 { "name": "id", "type": "string", "nullable": false },
373 { "name": "title", "type": "string", "nullable": false },
374 { "name": "done", "type": "boolean", "nullable": false }
375 ],
376 "scopes": []
377 }]
378 })
379 }
380
381 fn create(core: &mut SyncularCore) {
382 let reply = core.command(&json!({
383 "method": "create",
384 "params": { "clientId": "c1", "schema": simple_schema() }
385 }));
386 assert_eq!(reply["result"], json!({}), "create ok: {reply}");
387 }
388
389 #[test]
390 fn progress_sink_runs_before_the_sync_command_returns() {
391 use std::sync::{
392 atomic::{AtomicBool, Ordering},
393 Arc, Mutex,
394 };
395 let mut core = SyncularCore::new(&json!({})).unwrap();
396 let running = Arc::new(AtomicBool::new(false));
397 let observed = Arc::new(Mutex::new(Vec::new()));
398 let in_command = running.clone();
399 let events = observed.clone();
400 core.progress_listener = Some(Arc::new(move |progress| {
401 events
402 .lock()
403 .unwrap()
404 .push((in_command.load(Ordering::SeqCst), progress.state));
405 }));
406 create(&mut core);
407 running.store(true, Ordering::SeqCst);
408 core.command(&json!({ "method": "sync" }));
409 running.store(false, Ordering::SeqCst);
410 assert_eq!(
411 *observed.lock().unwrap(),
412 vec![
413 (true, syncular_client::ProgressState::Running),
414 (true, syncular_client::ProgressState::Failed)
415 ]
416 );
417 }
418
419 #[test]
420 fn command_round_trip_create_mutate_query() {
421 let mut core = SyncularCore::new(&json!({})).unwrap();
422 create(&mut core);
423
424 let sub = core.command(&json!({
425 "method": "subscribe",
426 "params": { "id": "s1", "table": "todo", "scopes": {} }
427 }));
428 assert_eq!(sub["result"], json!({}));
429
430 let mutate = core.command(&json!({
431 "method": "mutate",
432 "params": { "mutations": [{
433 "op": "upsert", "table": "todo",
434 "values": { "id": "t1", "title": "hello", "done": false }
435 }] }
436 }));
437 assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
438
439 let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
441 let list = rows["result"]["rows"].as_array().expect("rows");
442 assert_eq!(list.len(), 1);
443 assert_eq!(list[0]["title"], "hello");
444 assert_eq!(list[0]["id"], "t1");
445 }
446
447 #[test]
448 fn query_binds_params() {
449 let mut core = SyncularCore::new(&json!({})).unwrap();
450 create(&mut core);
451 core.command(&json!({
452 "method": "mutate",
453 "params": { "mutations": [
454 { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
455 { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
456 ] }
457 }));
458 let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
459 let list = rows["result"]["rows"].as_array().expect("rows");
460 assert_eq!(list.len(), 1);
461 assert_eq!(list[0]["id"], "b");
462 }
463
464 #[test]
465 fn events_derived_after_mutate() {
466 let mut core = SyncularCore::new(&json!({})).unwrap();
467 create(&mut core);
469 let enabled = core.command(&json!({ "method": "enableDiagnostics", "params": {} }));
471 assert_eq!(enabled["result"], json!({}));
472 let _ = core.drain_events();
473 core.command(&json!({
474 "method": "mutate",
475 "params": { "mutations": [{
476 "op": "upsert", "table": "todo",
477 "values": { "id": "t1", "title": "x", "done": false }
478 }] }
479 }));
480 let events = core.drain_events();
481 let kinds: Vec<&str> = events
484 .iter()
485 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
486 .collect();
487 assert!(kinds.contains(&"change"), "kinds: {kinds:?}");
488 assert!(kinds.contains(&"diagnostics"), "kinds: {kinds:?}");
489 let change = events
490 .iter()
491 .find(|event| event.json["type"] == "change")
492 .expect("change event");
493 assert_eq!(change.json["batch"]["revision"], "1");
494 assert_eq!(change.json["batch"]["tables"][0]["table"], "todo");
495 assert_eq!(change.json["batch"]["status"]["outbox"], 1);
496 assert_eq!(change.json["batch"]["status"]["syncNeeded"], false);
499 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
500 assert!(core.drain_events().is_empty());
502 }
503
504 #[test]
505 fn diagnostics_keep_fresh_capture_times_and_revision_independent_changes() {
506 let mut core = SyncularCore::new(&json!({})).unwrap();
507 let created = core.command(&json!({"method": "create", "params": {
508 "clientId": "diagnostics-comparison", "schema": simple_schema(), "nowMs": 1000
509 }}));
510 assert!(created.get("error").is_none(), "{created}");
511 assert!(core.drain_events().is_empty());
512 core.command(&json!({"method": "enableDiagnostics", "params": {}}));
513 let initial = core.drain_events();
514 assert_eq!(initial.len(), 1);
515 assert_eq!(initial[0].json["snapshot"]["capturedAtMs"], 1000);
516
517 core.client.as_mut().unwrap().set_now_ms(2000);
518 let read = json!({"method": "query", "params": {"sql": "SELECT 1 AS id"}});
519 assert_eq!(core.command(&read)["result"]["rows"], json!([{"id": 1}]));
520 assert!(core.drain_events().is_empty());
521 let changed = core.command(&json!({"method": "mutate", "params": {
522 "mutations": [{"op": "upsert", "table": "todo", "values": {
523 "id": "first", "title": "private value", "done": false
524 }}]
525 }}));
526 assert!(changed.get("error").is_none(), "{changed}");
527 let expected = serde_json::to_value(
528 core.client
529 .as_ref()
530 .unwrap()
531 .diagnostics_snapshot(&Default::default())
532 .unwrap(),
533 )
534 .unwrap();
535 let diagnostics: Vec<Value> = core
536 .drain_events()
537 .into_iter()
538 .filter(|event| event.json["type"] == "diagnostics")
539 .map(|event| event.json)
540 .collect();
541 assert_eq!(
542 diagnostics,
543 vec![json!({"type": "diagnostics", "snapshot": expected})]
544 );
545 assert_eq!(diagnostics[0]["snapshot"]["capturedAtMs"], 2000);
546
547 core.client.as_mut().unwrap().set_now_ms(3000);
548 assert!(core.command(&read).get("error").is_none());
549 assert!(core.drain_events().is_empty());
550 let revision = core.client.as_ref().unwrap().local_revision();
551 core.command(&json!({"method": "sync", "params": {}}));
552 assert_eq!(core.client.as_ref().unwrap().local_revision(), revision);
553 let expected = serde_json::to_value(
554 core.client
555 .as_ref()
556 .unwrap()
557 .diagnostics_snapshot(&Default::default())
558 .unwrap(),
559 )
560 .unwrap();
561 let diagnostics: Vec<Value> = core
562 .drain_events()
563 .into_iter()
564 .filter(|event| event.json["type"] == "diagnostics")
565 .map(|event| event.json)
566 .collect();
567 assert_eq!(
568 diagnostics,
569 vec![json!({"type": "diagnostics", "snapshot": expected})]
570 );
571 assert_eq!(diagnostics[0]["snapshot"]["capturedAtMs"], 3000);
572 assert_eq!(diagnostics[0]["snapshot"]["lastRound"]["status"], "failed");
573
574 let preflight = core.command(&json!({"method": "beginSecurityPreflight", "params": {}}));
575 assert!(preflight.get("error").is_none(), "{preflight}");
576 assert_eq!(
577 core.command(&read)["error"]["code"],
578 "client.security_preflight_required"
579 );
580 assert!(core
581 .drain_events()
582 .iter()
583 .all(|event| event.json["type"] != "diagnostics"));
584 }
585
586 #[test]
587 fn diagnostics_events_wait_for_a_registered_consumer() {
588 let mut core = SyncularCore::new(&json!({})).unwrap();
589 create(&mut core);
590 let _ = core.drain_events();
591 core.command(&json!({
592 "method": "mutate",
593 "params": { "mutations": [{
594 "op": "upsert", "table": "todo",
595 "values": { "id": "t1", "title": "x", "done": false }
596 }] }
597 }));
598 let kinds: Vec<String> = core
599 .drain_events()
600 .iter()
601 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
602 .map(str::to_owned)
603 .collect();
604 assert!(kinds.contains(&"change".to_owned()), "kinds: {kinds:?}");
605 assert!(
607 !kinds.contains(&"diagnostics".to_owned()),
608 "kinds: {kinds:?}"
609 );
610
611 let enabled = core.command(&json!({ "method": "enableDiagnostics", "params": {} }));
613 assert_eq!(enabled["result"], json!({}));
614 let events = core.drain_events();
615 assert!(
616 events.iter().any(|e| e.json["type"] == "diagnostics"),
617 "events: {events:?}"
618 );
619
620 core.command(&json!({
622 "method": "mutate",
623 "params": { "mutations": [{
624 "op": "upsert", "table": "todo",
625 "values": { "id": "t2", "title": "y", "done": false }
626 }] }
627 }));
628 let events = core.drain_events();
629 assert!(
630 events.iter().any(|e| e.json["type"] == "diagnostics"),
631 "events: {events:?}"
632 );
633 }
634
635 #[test]
636 fn a_snapshot_pull_registers_the_diagnostics_consumer() {
637 let mut core = SyncularCore::new(&json!({})).unwrap();
638 create(&mut core);
639 let _ = core.drain_events();
640 let reply = core.command(&json!({ "method": "diagnosticsSnapshot", "params": {} }));
641 assert_eq!(reply["result"]["version"], 1);
642 let _ = core.drain_events();
643 core.command(&json!({
644 "method": "mutate",
645 "params": { "mutations": [{
646 "op": "upsert", "table": "todo",
647 "values": { "id": "t1", "title": "x", "done": false }
648 }] }
649 }));
650 let events = core.drain_events();
651 assert!(
652 events.iter().any(|e| e.json["type"] == "diagnostics"),
653 "events: {events:?}"
654 );
655 }
656
657 #[test]
658 fn diagnostics_are_versioned_bounded_and_payload_free() {
659 let mut core = SyncularCore::new(&json!({})).unwrap();
660 create(&mut core);
661 let reply = core.command(&json!({
662 "method": "diagnosticsSnapshot",
663 "params": {
664 "expectedSubscriptions": [{ "id": "membership", "table": "todo" }]
665 }
666 }));
667 assert_eq!(reply["result"]["version"], 1);
668 assert_eq!(reply["result"]["subscriptions"][0]["state"], "unregistered");
669 let encoded = reply.to_string();
670 assert!(!encoded.contains("clientId"));
671 assert!(!encoded.contains("dbPath"));
672 assert!(!encoded.contains("operations"));
673 }
674
675 #[test]
676 fn sync_without_native_transport_fails_loud() {
677 let mut core = SyncularCore::new(&json!({})).unwrap();
678 create(&mut core);
679 let outcome = core.command(&json!({ "method": "sync", "params": {} }));
680 assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
681 assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
682 }
683
684 #[test]
685 fn file_db_persists_across_reopen() {
686 let dir = std::env::temp_dir();
687 let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
688 let path_str = path.to_string_lossy().to_string();
689 let _ = std::fs::remove_file(&path);
690
691 {
692 let mut core = SyncularCore::new(&json!({})).unwrap();
693 let reply = core.command(&json!({
694 "method": "create",
695 "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
696 }));
697 assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
698 core.command(&json!({
699 "method": "mutate",
700 "params": { "mutations": [{
701 "op": "upsert", "table": "todo",
702 "values": { "id": "persisted", "title": "kept", "done": false }
703 }] }
704 }));
705 let revision = core.command(&json!({
706 "method": "localRevision", "params": {}
707 }));
708 assert_eq!(revision["result"]["revision"], "1");
709 }
710 {
713 let mut core = SyncularCore::new(&json!({})).unwrap();
714 let reopened = core.command(&json!({
715 "method": "create",
716 "params": { "schema": simple_schema(), "dbPath": path_str }
717 }));
718 assert_eq!(reopened["result"], json!({}), "reopen: {reopened}");
719 let rows = core.query("SELECT title FROM todo", Value::Null);
720 let list = rows["result"]["rows"].as_array().expect("rows");
721 assert_eq!(list.len(), 1, "reopened db: {rows}");
722 assert_eq!(list[0]["title"], "kept");
723 let revision = core.command(&json!({
724 "method": "localRevision", "params": {}
725 }));
726 assert_eq!(revision["result"]["revision"], "1");
727 let pending = core.command(&json!({
728 "method": "pendingCommitIds", "params": {}
729 }));
730 assert_eq!(pending["result"]["ids"].as_array().map(Vec::len), Some(1));
731 let status = core.command(&json!({
732 "method": "statusSnapshot", "params": {}
733 }));
734 assert_eq!(status["result"]["outbox"], 1);
735 assert_eq!(status["result"]["syncNeeded"], true);
736 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
737 }
738 {
739 let mut core = SyncularCore::new(&json!({})).unwrap();
740 let mismatch = core.command(&json!({
741 "method": "create",
742 "params": { "clientId": "different", "schema": simple_schema(), "dbPath": path_str }
743 }));
744 assert_eq!(mismatch["error"]["code"], "client.identity_mismatch");
745 }
746 let _ = std::fs::remove_file(&path);
747 }
748
749 #[test]
750 fn config_validation_rejects_baseurl_without_native_feature() {
751 let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
752 #[cfg(not(feature = "native-transport"))]
753 assert!(
754 result.is_err(),
755 "baseUrl must be refused without native-transport"
756 );
757 #[cfg(feature = "native-transport")]
758 assert!(result.is_ok(), "baseUrl builds with native-transport");
759 }
760}