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, HostTransportPolicy};
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 round_pending: bool,
64 controls: Vec<String>,
65}
66
67impl SyncularCore {
68 pub fn new(config: &Value) -> Result<Self, String> {
72 Self::new_with_notify(config, None)
73 }
74
75 pub fn new_with_notify(
76 config: &Value,
77 notify: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
78 ) -> Result<Self, String> {
79 let transport = HostTransport::from_config_with_notify(config, notify)?;
80 Ok(SyncularCore {
81 progress_listener: None,
82 progress_subscription: None,
83 client: None,
84 transport,
85 effects: CreateEffects::default(),
86 queue: VecDeque::new(),
87 last_diagnostics_snapshot: None,
88 diagnostics_observed: false,
89 interactive_sync: false,
90 background_sync_ms: None,
91 round_pending: false,
92 controls: Vec::new(),
93 })
94 }
95
96 #[cfg(any(test, feature = "test-clock"))]
102 pub fn set_now_ms(&mut self, now_ms: i64) -> Result<(), String> {
103 let Some(client) = self.client.as_mut() else {
104 return Err("client.not_created: create a client before setting the clock".to_owned());
105 };
106 client.set_now_ms(now_ms);
107 Ok(())
108 }
109
110 pub fn command(&mut self, command: &Value) -> Value {
114 let method = command.get("method").and_then(Value::as_str).unwrap_or("");
115 let params = command.get("params").cloned().unwrap_or(Value::Null);
116 if method == "enableDiagnostics" {
117 self.diagnostics_observed = true;
121 self.last_diagnostics_snapshot = None;
122 self.drain_realtime();
123 self.drain_core_outputs();
124 self.emit_diagnostics_if_changed();
125 return json!({ "result": {} });
126 }
127 if method == "diagnosticsSnapshot" {
128 self.diagnostics_observed = true;
131 }
132 let result = dispatch(
133 &mut self.transport,
134 &mut self.client,
135 &mut self.effects,
136 method,
137 ¶ms,
138 );
139 if result.is_ok() && matches!(method, "activateSecurity" | "setHeaders") {
140 if let Some(headers) = params.get("headers") {
141 self.set_headers(
142 syncular_command::parse_headers(headers).expect("router validated headers"),
143 );
144 }
145 }
146 if method == "create" && result.is_ok() {
147 self.progress_subscription = self.client.as_ref().and_then(|client| {
148 self.progress_listener.clone().map(|listener| {
149 client
150 .progress()
151 .subscribe(move |progress| listener(progress))
152 })
153 });
154 }
155 if method == "shutdown" {
156 self.progress_subscription = None;
157 }
158 if method == "create" {
159 self.last_diagnostics_snapshot = None;
160 self.transport.set_signed_urls(self.effects.signed_urls);
161 }
162 if method == "beginSecurityPreflight"
163 || method == "shutdown"
164 || (method == "create"
165 && params
166 .get("securityPreflight")
167 .and_then(Value::as_bool)
168 .unwrap_or(false))
169 {
170 self.interactive_sync = false;
171 self.background_sync_ms = None;
172 }
173 if let Ok(value) = &result {
174 if value.pointer("/effects/sync/kind").and_then(Value::as_str) == Some("interactive") {
175 self.interactive_sync = true;
176 }
177 }
178 self.drain_realtime();
179 self.drain_core_outputs();
180 self.emit_diagnostics_if_changed();
181 match result {
182 Ok(mut value) => {
183 if let Some(object) = value.as_object_mut() {
186 object.remove("effects");
187 }
188 json!({ "result": value })
189 }
190 Err(error) => {
191 let mut envelope = json!({
192 "code": error.code,
193 "message": error.message,
194 "retryable": error.retryable
195 });
196 if let Some(details) = error.details {
197 envelope["details"] = details;
198 }
199 json!({ "error": envelope })
200 }
201 }
202 }
203
204 pub fn query(&mut self, sql: &str, params: Value) -> Value {
209 let bind = match params {
210 Value::Null => Value::Array(Vec::new()),
211 other => other,
212 };
213 self.command(&json!({ "method": "query", "params": { "sql": sql, "params": bind } }))
214 }
215
216 pub fn take_sync_intent(&mut self) -> SyncIntent {
219 if !self.transport_enabled() {
220 return SyncIntent::None;
221 }
222 if std::mem::take(&mut self.interactive_sync) {
223 self.background_sync_ms = None;
224 SyncIntent::Interactive
225 } else if let Some(delay_ms) = self.background_sync_ms.take() {
226 SyncIntent::Background { delay_ms }
227 } else {
228 SyncIntent::None
229 }
230 }
231
232 pub fn transport_enabled(&self) -> bool {
233 self.client
234 .as_ref()
235 .is_some_and(SyncClient::transport_enabled)
236 }
237
238 pub fn sync_until_idle(&mut self) -> Value {
241 if self.client.is_none() {
242 return json!({ "result": null });
243 }
244 self.command(&json!({ "method": "syncUntilIdle", "params": {} }))
245 }
246
247 pub fn prepare_round(
250 &mut self,
251 ) -> Result<
252 (syncular_client::PreparedSyncRound, HostTransport),
253 Box<syncular_client::SyncOutcome>,
254 > {
255 let Some(client) = self.client.as_mut() else {
256 return Err(Box::new(syncular_client::SyncOutcome::Failed {
257 error_code: "client.not_created".into(),
258 message: "create a client before syncing".into(),
259 details: None,
260 }));
261 };
262 use syncular_client::Transport;
263 let result = client.prepare_sync_round(self.transport.supports_url_fetch());
264 self.round_pending = result.is_ok();
265 self.drain_core_outputs();
266 self.emit_diagnostics_if_changed();
267 result.map(|prepared| (prepared, self.transport.fork_round()))
268 }
269
270 pub fn apply_round(
271 &mut self,
272 completed: syncular_client::CompletedSyncRound,
273 ) -> syncular_client::AppliedSyncRound {
274 self.round_pending = false;
275 let mut applied = match self.client.as_mut() {
276 Some(client) => client.apply_sync_round(completed),
277 None => syncular_client::AppliedSyncRound::Complete {
278 outcome: syncular_client::SyncOutcome::Failed {
279 error_code: "client.round_cancelled".into(),
280 message: "sync round belongs to a released client context".into(),
281 details: None,
282 },
283 controls: Vec::new(),
284 more: false,
285 bootstrap_advanced: false,
286 },
287 };
288 self.round_pending = matches!(applied, syncular_client::AppliedSyncRound::Continue(_));
289 if !self.round_pending && !self.transport_enabled() {
290 if let Some(client) = self.client.as_mut() {
291 client.set_transport_enabled(&mut self.transport, false);
292 }
293 }
294 if let syncular_client::AppliedSyncRound::Complete { controls, .. } = &mut applied {
295 self.controls.append(controls);
296 }
297 self.drain_realtime();
300 self.drain_core_outputs();
301 self.emit_diagnostics_if_changed();
302 applied
303 }
304
305 pub fn set_transport_policy(&mut self, policy: HostTransportPolicy) -> Result<(), String> {
313 self.transport.set_policy(policy)
314 }
315
316 pub fn take_controls(&mut self) -> Option<(Vec<String>, HostTransport)> {
317 if !self.transport_enabled() {
318 self.controls.clear();
319 return None;
320 }
321 if self.controls.is_empty() {
322 return None;
323 }
324 Some((
325 std::mem::take(&mut self.controls),
326 self.transport.fork_round(),
327 ))
328 }
329
330 pub fn record_query_read(
332 &mut self,
333 id: &str,
334 tables: &[String],
335 failure: Option<&QueryReadFailure>,
336 ) {
337 let Some(client) = self.client.as_mut() else {
338 return;
339 };
340 let tables: Vec<&str> = tables.iter().map(String::as_str).collect();
341 client.record_query_read(
342 QueryOwner {
343 id,
344 tables: &tables,
345 },
346 failure,
347 );
348 self.emit_diagnostics_if_changed();
349 }
350
351 pub fn poll_transport(&mut self) {
353 self.drain_realtime();
354 self.drain_core_outputs();
355 self.emit_diagnostics_if_changed();
356 }
357
358 pub fn drain_events(&mut self) -> Vec<Event> {
361 self.queue.drain(..).collect()
362 }
363
364 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
368 self.transport.set_headers(headers);
369 }
370
371 pub fn shutdown(&mut self) {
373 if let Some(mut client) = self.client.take() {
374 client.disconnect_realtime(&mut self.transport);
375 client.seal_security_on_teardown();
378 }
379 self.interactive_sync = false;
380 self.background_sync_ms = None;
381 self.diagnostics_observed = false;
384 self.transport.shutdown();
385 }
386
387 fn push(&mut self, json: Value) {
388 self.queue.push_back(Event { json });
389 }
390
391 fn drain_realtime(&mut self) {
394 if self.round_pending || !self.transport_enabled() {
395 return;
396 }
397 let frames = self.transport.take_inbound();
398 for frame in frames {
399 match frame {
400 transport::Inbound::Text(text) => {
401 if is_presence_control(&text) {
402 self.push(json!({ "type": "presence" }));
403 }
404 if let Some(client) = self.client.as_mut() {
405 client.on_realtime_text(&text);
406 }
407 }
408 transport::Inbound::Binary(bytes) => {
409 if let Some(client) = self.client.as_mut() {
410 if !client.security_preflight() {
411 self.controls
412 .extend(client.on_realtime_binary_queued(&bytes));
413 }
414 }
415 }
416 }
417 }
418 }
419
420 fn drain_core_outputs(&mut self) {
422 let Some(client) = self.client.as_mut() else {
423 return;
424 };
425 let batches = client.drain_change_batches();
426 let intents = client.drain_sync_intents();
427 for batch in batches {
428 self.push(json!({ "type": "change", "batch": batch }));
429 }
430 for intent in intents {
431 match intent {
432 SyncIntent::Interactive => self.interactive_sync = true,
433 SyncIntent::Background { delay_ms } => {
434 self.background_sync_ms = Some(
435 self.background_sync_ms
436 .map_or(delay_ms, |current| current.min(delay_ms)),
437 );
438 }
439 SyncIntent::None => {}
440 }
441 }
442 }
443
444 fn emit_diagnostics_if_changed(&mut self) {
453 if !self.diagnostics_observed {
454 return;
455 }
456 let Some(client) = self.client.as_ref() else {
457 return;
458 };
459 if client.security_preflight() {
460 return;
461 }
462 let Ok(snapshot) = client.diagnostics_snapshot(&ClientDiagnosticsRequest::default()) else {
463 return;
464 };
465 if let Some(previous) = &mut self.last_diagnostics_snapshot {
466 previous.captured_at_ms = snapshot.captured_at_ms;
468 if previous == &snapshot {
469 return;
470 }
471 }
472 let event = json!({ "type": "diagnostics", "snapshot": snapshot });
473 self.last_diagnostics_snapshot = Some(snapshot);
474 self.push(event);
475 }
476}
477
478fn is_presence_control(text: &str) -> bool {
481 serde_json::from_str::<Value>(text)
482 .ok()
483 .and_then(|v| {
484 v.get("event")
485 .and_then(Value::as_str)
486 .map(|e| e == "presence")
487 })
488 .unwrap_or(false)
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 fn simple_schema() -> Value {
496 json!({
497 "version": 1,
498 "tables": [{
499 "name": "todo",
500 "primaryKey": "id",
501 "columns": [
502 { "name": "id", "type": "string", "nullable": false },
503 { "name": "title", "type": "string", "nullable": false },
504 { "name": "done", "type": "boolean", "nullable": false }
505 ],
506 "scopes": []
507 }]
508 })
509 }
510
511 fn create(core: &mut SyncularCore) {
512 let reply = core.command(&json!({
513 "method": "create",
514 "params": { "clientId": "c1", "schema": simple_schema() }
515 }));
516 assert_eq!(reply["result"], json!({}), "create ok: {reply}");
517 }
518
519 #[test]
520 fn transport_gate_survives_security_activation_and_owner_rounds() {
521 let mut core = SyncularCore::new(&json!({})).unwrap();
522 let result = core.command(&json!({ "method": "create", "params": {
523 "schema": simple_schema(), "transportEnabled": false, "securityPreflight": true
524 } }));
525 assert!(result.get("error").is_none());
526 assert!(core
527 .command(&json!({ "method": "activateSecurity", "params": {} }))
528 .get("error")
529 .is_none());
530 for title in ["first", "second"] {
531 assert!(core.command(&json!({ "method": "mutate", "params": { "mutations": [{
532 "op": "upsert", "table": "todo", "values": { "id": "one", "title": title, "done": false }
533 }] } })).get("error").is_none());
534 assert!(matches!(core.take_sync_intent(), SyncIntent::None));
535 assert!(
536 matches!(*core.prepare_round().err().expect("closed transport"), syncular_client::SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
537 );
538 }
539 assert_eq!(
540 core.query("SELECT title FROM todo", Value::Null)["result"]["rows"][0]["title"],
541 "second"
542 );
543 assert!(!core.transport_enabled());
544 core.command(&json!({ "method": "setTransportEnabled", "params": { "enabled": true } }));
545 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
546 core.command(&json!({ "method": "setTransportEnabled", "params": { "enabled": true } }));
547 assert!(matches!(core.take_sync_intent(), SyncIntent::None));
548 core.command(&json!({ "method": "setTransportEnabled", "params": { "enabled": false } }));
549 core.command(&json!({ "method": "beginSecurityPreflight" }));
550 assert_eq!(
551 core.query("SELECT title FROM todo", Value::Null)["error"]["code"],
552 "client.security_preflight_required"
553 );
554 core.command(&json!({ "method": "activateSecurity", "params": {} }));
555 assert!(!core.transport_enabled());
556 }
557
558 #[test]
559 fn progress_sink_runs_before_the_sync_command_returns() {
560 use std::sync::{
561 atomic::{AtomicBool, Ordering},
562 Arc, Mutex,
563 };
564 let mut core = SyncularCore::new(&json!({})).unwrap();
565 let running = Arc::new(AtomicBool::new(false));
566 let observed = Arc::new(Mutex::new(Vec::new()));
567 let in_command = running.clone();
568 let events = observed.clone();
569 core.progress_listener = Some(Arc::new(move |progress| {
570 events
571 .lock()
572 .unwrap()
573 .push((in_command.load(Ordering::SeqCst), progress.state));
574 }));
575 create(&mut core);
576 running.store(true, Ordering::SeqCst);
577 core.command(&json!({ "method": "sync" }));
578 running.store(false, Ordering::SeqCst);
579 assert_eq!(
580 *observed.lock().unwrap(),
581 vec![
582 (true, syncular_client::ProgressState::Running),
583 (true, syncular_client::ProgressState::Failed)
584 ]
585 );
586 }
587
588 #[test]
589 fn command_round_trip_create_mutate_query() {
590 let mut core = SyncularCore::new(&json!({})).unwrap();
591 create(&mut core);
592
593 let sub = core.command(&json!({
594 "method": "subscribe",
595 "params": { "id": "s1", "table": "todo", "scopes": {} }
596 }));
597 assert_eq!(sub["result"], json!({}));
598
599 let mutate = core.command(&json!({
600 "method": "mutate",
601 "params": { "mutations": [{
602 "op": "upsert", "table": "todo",
603 "values": { "id": "t1", "title": "hello", "done": false }
604 }] }
605 }));
606 assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
607
608 let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
610 let list = rows["result"]["rows"].as_array().expect("rows");
611 assert_eq!(list.len(), 1);
612 assert_eq!(list[0]["title"], "hello");
613 assert_eq!(list[0]["id"], "t1");
614 }
615
616 #[test]
617 fn query_binds_params() {
618 let mut core = SyncularCore::new(&json!({})).unwrap();
619 create(&mut core);
620 core.command(&json!({
621 "method": "mutate",
622 "params": { "mutations": [
623 { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
624 { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
625 ] }
626 }));
627 let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
628 let list = rows["result"]["rows"].as_array().expect("rows");
629 assert_eq!(list.len(), 1);
630 assert_eq!(list[0]["id"], "b");
631 }
632
633 #[test]
634 fn events_derived_after_mutate() {
635 let mut core = SyncularCore::new(&json!({})).unwrap();
636 create(&mut core);
638 let enabled = core.command(&json!({ "method": "enableDiagnostics", "params": {} }));
640 assert_eq!(enabled["result"], json!({}));
641 let _ = core.drain_events();
642 core.command(&json!({
643 "method": "mutate",
644 "params": { "mutations": [{
645 "op": "upsert", "table": "todo",
646 "values": { "id": "t1", "title": "x", "done": false }
647 }] }
648 }));
649 let events = core.drain_events();
650 let kinds: Vec<&str> = events
653 .iter()
654 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
655 .collect();
656 assert!(kinds.contains(&"change"), "kinds: {kinds:?}");
657 assert!(kinds.contains(&"diagnostics"), "kinds: {kinds:?}");
658 let change = events
659 .iter()
660 .find(|event| event.json["type"] == "change")
661 .expect("change event");
662 assert_eq!(change.json["batch"]["revision"], "1");
663 assert_eq!(change.json["batch"]["tables"][0]["table"], "todo");
664 assert_eq!(change.json["batch"]["status"]["outbox"], 1);
665 assert_eq!(change.json["batch"]["status"]["syncNeeded"], false);
668 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
669 assert!(core.drain_events().is_empty());
671 }
672
673 #[test]
674 fn diagnostics_keep_fresh_capture_times_and_revision_independent_changes() {
675 let mut core = SyncularCore::new(&json!({})).unwrap();
676 let created = core.command(&json!({"method": "create", "params": {
677 "clientId": "diagnostics-comparison", "schema": simple_schema(), "nowMs": 1000
678 }}));
679 assert!(created.get("error").is_none(), "{created}");
680 assert!(core.drain_events().is_empty());
681 core.command(&json!({"method": "enableDiagnostics", "params": {}}));
682 let initial = core.drain_events();
683 assert_eq!(initial.len(), 1);
684 assert_eq!(initial[0].json["snapshot"]["capturedAtMs"], 1000);
685
686 core.set_now_ms(2000).unwrap();
687 let read = json!({"method": "query", "params": {"sql": "SELECT 1 AS id"}});
688 assert_eq!(core.command(&read)["result"]["rows"], json!([{"id": 1}]));
689 assert!(core.drain_events().is_empty());
690 let changed = core.command(&json!({"method": "mutate", "params": {
691 "mutations": [{"op": "upsert", "table": "todo", "values": {
692 "id": "first", "title": "private value", "done": false
693 }}]
694 }}));
695 assert!(changed.get("error").is_none(), "{changed}");
696 let expected = serde_json::to_value(
697 core.client
698 .as_ref()
699 .unwrap()
700 .diagnostics_snapshot(&Default::default())
701 .unwrap(),
702 )
703 .unwrap();
704 let diagnostics: Vec<Value> = core
705 .drain_events()
706 .into_iter()
707 .filter(|event| event.json["type"] == "diagnostics")
708 .map(|event| event.json)
709 .collect();
710 assert_eq!(
711 diagnostics,
712 vec![json!({"type": "diagnostics", "snapshot": expected})]
713 );
714 assert_eq!(diagnostics[0]["snapshot"]["capturedAtMs"], 2000);
715
716 core.set_now_ms(3000).unwrap();
717 assert!(core.command(&read).get("error").is_none());
718 assert!(core.drain_events().is_empty());
719 let revision = core.client.as_ref().unwrap().local_revision();
720 core.command(&json!({"method": "sync", "params": {}}));
721 assert_eq!(core.client.as_ref().unwrap().local_revision(), revision);
722 let expected = serde_json::to_value(
723 core.client
724 .as_ref()
725 .unwrap()
726 .diagnostics_snapshot(&Default::default())
727 .unwrap(),
728 )
729 .unwrap();
730 let diagnostics: Vec<Value> = core
731 .drain_events()
732 .into_iter()
733 .filter(|event| event.json["type"] == "diagnostics")
734 .map(|event| event.json)
735 .collect();
736 assert_eq!(
737 diagnostics,
738 vec![json!({"type": "diagnostics", "snapshot": expected})]
739 );
740 assert_eq!(diagnostics[0]["snapshot"]["capturedAtMs"], 3000);
741 assert_eq!(diagnostics[0]["snapshot"]["lastRound"]["status"], "failed");
742
743 let preflight = core.command(&json!({"method": "beginSecurityPreflight", "params": {}}));
744 assert!(preflight.get("error").is_none(), "{preflight}");
745 assert_eq!(
746 core.command(&read)["error"]["code"],
747 "client.security_preflight_required"
748 );
749 assert!(core
750 .drain_events()
751 .iter()
752 .all(|event| event.json["type"] != "diagnostics"));
753 }
754
755 #[test]
756 fn set_now_ms_sets_observable_capture_times_and_requires_a_client() {
757 let mut core = SyncularCore::new(&json!({})).unwrap();
758 assert_eq!(
761 core.set_now_ms(500).unwrap_err(),
762 "client.not_created: create a client before setting the clock"
763 );
764 let created = core.command(&json!({"method": "create", "params": {
765 "clientId": "clock-seam", "schema": simple_schema(), "nowMs": 1000
766 }}));
767 assert!(created.get("error").is_none(), "{created}");
768 core.command(&json!({"method": "enableDiagnostics", "params": {}}));
769 let initial = core.drain_events();
770 assert_eq!(
771 initial[0].json["snapshot"]["capturedAtMs"], 1000,
772 "create.nowMs pins the client clock"
773 );
774
775 core.set_now_ms(2500).unwrap();
776 let read = json!({"method": "query", "params": {"sql": "SELECT 1 AS id"}});
777 assert!(core.command(&read).get("error").is_none());
778 core.command(&json!({"method": "mutate", "params": {
779 "mutations": [{"op": "upsert", "table": "todo", "values": {
780 "id": "clocked", "title": "x", "done": false
781 }}]
782 }}));
783 let advanced = core
784 .drain_events()
785 .into_iter()
786 .find(|event| event.json["type"] == "diagnostics")
787 .expect("advanced diagnostics snapshot");
788 assert_eq!(
789 advanced.json["snapshot"]["capturedAtMs"], 2500,
790 "the seam sets the observable clock"
791 );
792 }
793
794 #[test]
795 fn diagnostics_events_wait_for_a_registered_consumer() {
796 let mut core = SyncularCore::new(&json!({})).unwrap();
797 create(&mut core);
798 let _ = core.drain_events();
799 core.command(&json!({
800 "method": "mutate",
801 "params": { "mutations": [{
802 "op": "upsert", "table": "todo",
803 "values": { "id": "t1", "title": "x", "done": false }
804 }] }
805 }));
806 let kinds: Vec<String> = core
807 .drain_events()
808 .iter()
809 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
810 .map(str::to_owned)
811 .collect();
812 assert!(kinds.contains(&"change".to_owned()), "kinds: {kinds:?}");
813 assert!(
815 !kinds.contains(&"diagnostics".to_owned()),
816 "kinds: {kinds:?}"
817 );
818
819 let enabled = core.command(&json!({ "method": "enableDiagnostics", "params": {} }));
821 assert_eq!(enabled["result"], json!({}));
822 let events = core.drain_events();
823 assert!(
824 events.iter().any(|e| e.json["type"] == "diagnostics"),
825 "events: {events:?}"
826 );
827
828 core.command(&json!({
830 "method": "mutate",
831 "params": { "mutations": [{
832 "op": "upsert", "table": "todo",
833 "values": { "id": "t2", "title": "y", "done": false }
834 }] }
835 }));
836 let events = core.drain_events();
837 assert!(
838 events.iter().any(|e| e.json["type"] == "diagnostics"),
839 "events: {events:?}"
840 );
841 }
842
843 #[test]
844 fn a_snapshot_pull_registers_the_diagnostics_consumer() {
845 let mut core = SyncularCore::new(&json!({})).unwrap();
846 create(&mut core);
847 let _ = core.drain_events();
848 let reply = core.command(&json!({ "method": "diagnosticsSnapshot", "params": {} }));
849 assert_eq!(reply["result"]["version"], 1);
850 let _ = core.drain_events();
851 core.command(&json!({
852 "method": "mutate",
853 "params": { "mutations": [{
854 "op": "upsert", "table": "todo",
855 "values": { "id": "t1", "title": "x", "done": false }
856 }] }
857 }));
858 let events = core.drain_events();
859 assert!(
860 events.iter().any(|e| e.json["type"] == "diagnostics"),
861 "events: {events:?}"
862 );
863 }
864
865 #[test]
866 fn diagnostics_are_versioned_bounded_and_payload_free() {
867 let mut core = SyncularCore::new(&json!({})).unwrap();
868 create(&mut core);
869 let reply = core.command(&json!({
870 "method": "diagnosticsSnapshot",
871 "params": {
872 "expectedSubscriptions": [{ "id": "membership", "table": "todo" }]
873 }
874 }));
875 assert_eq!(reply["result"]["version"], 1);
876 assert_eq!(reply["result"]["subscriptions"][0]["state"], "unregistered");
877 let encoded = reply.to_string();
878 assert!(!encoded.contains("clientId"));
879 assert!(!encoded.contains("dbPath"));
880 assert!(!encoded.contains("operations"));
881 }
882
883 #[test]
884 fn sync_without_native_transport_fails_loud() {
885 let mut core = SyncularCore::new(&json!({})).unwrap();
886 create(&mut core);
887 let outcome = core.command(&json!({ "method": "sync", "params": {} }));
888 assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
889 assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
890 }
891
892 #[test]
893 fn file_db_persists_across_reopen() {
894 let dir = std::env::temp_dir();
895 let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
896 let path_str = path.to_string_lossy().to_string();
897 let _ = std::fs::remove_file(&path);
898
899 {
900 let mut core = SyncularCore::new(&json!({})).unwrap();
901 let reply = core.command(&json!({
902 "method": "create",
903 "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
904 }));
905 assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
906 core.command(&json!({
907 "method": "mutate",
908 "params": { "mutations": [{
909 "op": "upsert", "table": "todo",
910 "values": { "id": "persisted", "title": "kept", "done": false }
911 }] }
912 }));
913 let revision = core.command(&json!({
914 "method": "localRevision", "params": {}
915 }));
916 assert_eq!(revision["result"]["revision"], "1");
917 }
918 {
921 let mut core = SyncularCore::new(&json!({})).unwrap();
922 let reopened = core.command(&json!({
923 "method": "create",
924 "params": { "schema": simple_schema(), "dbPath": path_str }
925 }));
926 assert_eq!(reopened["result"], json!({}), "reopen: {reopened}");
927 let rows = core.query("SELECT title FROM todo", Value::Null);
928 let list = rows["result"]["rows"].as_array().expect("rows");
929 assert_eq!(list.len(), 1, "reopened db: {rows}");
930 assert_eq!(list[0]["title"], "kept");
931 let revision = core.command(&json!({
932 "method": "localRevision", "params": {}
933 }));
934 assert_eq!(revision["result"]["revision"], "1");
935 let pending = core.command(&json!({
936 "method": "pendingCommitIds", "params": {}
937 }));
938 assert_eq!(pending["result"]["ids"].as_array().map(Vec::len), Some(1));
939 let status = core.command(&json!({
940 "method": "statusSnapshot", "params": {}
941 }));
942 assert_eq!(status["result"]["outbox"], 1);
943 assert_eq!(status["result"]["syncNeeded"], true);
944 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
945 }
946 {
947 let mut core = SyncularCore::new(&json!({})).unwrap();
948 let mismatch = core.command(&json!({
949 "method": "create",
950 "params": { "clientId": "different", "schema": simple_schema(), "dbPath": path_str }
951 }));
952 assert_eq!(mismatch["error"]["code"], "client.identity_mismatch");
953 }
954 let _ = std::fs::remove_file(&path);
955 }
956
957 #[test]
958 fn config_validation_rejects_baseurl_without_native_feature() {
959 let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
960 #[cfg(not(feature = "native-transport"))]
961 assert!(
962 result.is_err(),
963 "baseUrl must be refused without native-transport"
964 );
965 #[cfg(feature = "native-transport")]
966 assert!(result.is_ok(), "baseUrl builds with native-transport");
967 }
968}