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