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 let Some(scope_key) = presence_scope_key(&text) {
402 self.push(json!({ "type": "presence", "scopeKey": scope_key }));
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 presence_scope_key(text: &str) -> Option<String> {
481 serde_json::from_str::<Value>(text).ok().and_then(|v| {
482 if v.get("event").and_then(Value::as_str) != Some("presence") {
483 return None;
484 }
485 v.get("data")?
486 .get("scopeKey")
487 .and_then(Value::as_str)
488 .map(str::to_owned)
489 })
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn presence_scope_key_reads_the_fanout_data() {
498 assert_eq!(
499 presence_scope_key(
500 r#"{"event":"presence","data":{"scopeKey":"room:42","kind":"leave","actorId":"a","clientId":"c","doc":null,"timestamp":1}}"#
501 )
502 .as_deref(),
503 Some("room:42")
504 );
505 assert_eq!(
506 presence_scope_key(r#"{"event":"presence","data":{}}"#),
507 None
508 );
509 assert_eq!(presence_scope_key(r#"{"event":"sync","data":{}}"#), None);
510 }
511
512 fn simple_schema() -> Value {
513 json!({
514 "version": 1,
515 "tables": [{
516 "name": "todo",
517 "primaryKey": "id",
518 "columns": [
519 { "name": "id", "type": "string", "nullable": false },
520 { "name": "title", "type": "string", "nullable": false },
521 { "name": "done", "type": "boolean", "nullable": false }
522 ],
523 "scopes": []
524 }]
525 })
526 }
527
528 fn create(core: &mut SyncularCore) {
529 let reply = core.command(&json!({
530 "method": "create",
531 "params": { "clientId": "c1", "schema": simple_schema() }
532 }));
533 assert_eq!(reply["result"], json!({}), "create ok: {reply}");
534 }
535
536 #[test]
537 fn transport_gate_survives_security_activation_and_owner_rounds() {
538 let mut core = SyncularCore::new(&json!({})).unwrap();
539 let result = core.command(&json!({ "method": "create", "params": {
540 "schema": simple_schema(), "transportEnabled": false, "securityPreflight": true
541 } }));
542 assert!(result.get("error").is_none());
543 assert!(core
544 .command(&json!({ "method": "activateSecurity", "params": {} }))
545 .get("error")
546 .is_none());
547 for title in ["first", "second"] {
548 assert!(core.command(&json!({ "method": "mutate", "params": { "mutations": [{
549 "op": "upsert", "table": "todo", "values": { "id": "one", "title": title, "done": false }
550 }] } })).get("error").is_none());
551 assert!(matches!(core.take_sync_intent(), SyncIntent::None));
552 assert!(
553 matches!(*core.prepare_round().err().expect("closed transport"), syncular_client::SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
554 );
555 }
556 assert_eq!(
557 core.query("SELECT title FROM todo", Value::Null)["result"]["rows"][0]["title"],
558 "second"
559 );
560 assert!(!core.transport_enabled());
561 core.command(&json!({ "method": "setTransportEnabled", "params": { "enabled": true } }));
562 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
563 core.command(&json!({ "method": "setTransportEnabled", "params": { "enabled": true } }));
564 assert!(matches!(core.take_sync_intent(), SyncIntent::None));
565 core.command(&json!({ "method": "setTransportEnabled", "params": { "enabled": false } }));
566 core.command(&json!({ "method": "beginSecurityPreflight" }));
567 assert_eq!(
568 core.query("SELECT title FROM todo", Value::Null)["error"]["code"],
569 "client.security_preflight_required"
570 );
571 core.command(&json!({ "method": "activateSecurity", "params": {} }));
572 assert!(!core.transport_enabled());
573 }
574
575 #[test]
576 fn progress_sink_runs_before_the_sync_command_returns() {
577 use std::sync::{
578 atomic::{AtomicBool, Ordering},
579 Arc, Mutex,
580 };
581 let mut core = SyncularCore::new(&json!({})).unwrap();
582 let running = Arc::new(AtomicBool::new(false));
583 let observed = Arc::new(Mutex::new(Vec::new()));
584 let in_command = running.clone();
585 let events = observed.clone();
586 core.progress_listener = Some(Arc::new(move |progress| {
587 events
588 .lock()
589 .unwrap()
590 .push((in_command.load(Ordering::SeqCst), progress.state));
591 }));
592 create(&mut core);
593 running.store(true, Ordering::SeqCst);
594 core.command(&json!({ "method": "sync" }));
595 running.store(false, Ordering::SeqCst);
596 assert_eq!(
597 *observed.lock().unwrap(),
598 vec![
599 (true, syncular_client::ProgressState::Running),
600 (true, syncular_client::ProgressState::Failed)
601 ]
602 );
603 }
604
605 #[test]
606 fn command_round_trip_create_mutate_query() {
607 let mut core = SyncularCore::new(&json!({})).unwrap();
608 create(&mut core);
609
610 let sub = core.command(&json!({
611 "method": "subscribe",
612 "params": { "id": "s1", "table": "todo", "scopes": {} }
613 }));
614 assert_eq!(sub["result"], json!({}));
615
616 let mutate = core.command(&json!({
617 "method": "mutate",
618 "params": { "mutations": [{
619 "op": "upsert", "table": "todo",
620 "values": { "id": "t1", "title": "hello", "done": false }
621 }] }
622 }));
623 assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
624
625 let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
627 let list = rows["result"]["rows"].as_array().expect("rows");
628 assert_eq!(list.len(), 1);
629 assert_eq!(list[0]["title"], "hello");
630 assert_eq!(list[0]["id"], "t1");
631 }
632
633 #[test]
634 fn query_binds_params() {
635 let mut core = SyncularCore::new(&json!({})).unwrap();
636 create(&mut core);
637 core.command(&json!({
638 "method": "mutate",
639 "params": { "mutations": [
640 { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
641 { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
642 ] }
643 }));
644 let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
645 let list = rows["result"]["rows"].as_array().expect("rows");
646 assert_eq!(list.len(), 1);
647 assert_eq!(list[0]["id"], "b");
648 }
649
650 #[test]
651 fn events_derived_after_mutate() {
652 let mut core = SyncularCore::new(&json!({})).unwrap();
653 create(&mut core);
655 let enabled = core.command(&json!({ "method": "enableDiagnostics", "params": {} }));
657 assert_eq!(enabled["result"], json!({}));
658 let _ = core.drain_events();
659 core.command(&json!({
660 "method": "mutate",
661 "params": { "mutations": [{
662 "op": "upsert", "table": "todo",
663 "values": { "id": "t1", "title": "x", "done": false }
664 }] }
665 }));
666 let events = core.drain_events();
667 let kinds: Vec<&str> = events
670 .iter()
671 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
672 .collect();
673 assert!(kinds.contains(&"change"), "kinds: {kinds:?}");
674 assert!(kinds.contains(&"diagnostics"), "kinds: {kinds:?}");
675 let change = events
676 .iter()
677 .find(|event| event.json["type"] == "change")
678 .expect("change event");
679 assert_eq!(change.json["batch"]["revision"], "1");
680 assert_eq!(change.json["batch"]["tables"][0]["table"], "todo");
681 assert_eq!(change.json["batch"]["status"]["outbox"], 1);
682 assert_eq!(change.json["batch"]["status"]["syncNeeded"], false);
685 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
686 assert!(core.drain_events().is_empty());
688 }
689
690 #[test]
691 fn diagnostics_keep_fresh_capture_times_and_revision_independent_changes() {
692 let mut core = SyncularCore::new(&json!({})).unwrap();
693 let created = core.command(&json!({"method": "create", "params": {
694 "clientId": "diagnostics-comparison", "schema": simple_schema(), "nowMs": 1000
695 }}));
696 assert!(created.get("error").is_none(), "{created}");
697 assert!(core.drain_events().is_empty());
698 core.command(&json!({"method": "enableDiagnostics", "params": {}}));
699 let initial = core.drain_events();
700 assert_eq!(initial.len(), 1);
701 assert_eq!(initial[0].json["snapshot"]["capturedAtMs"], 1000);
702
703 core.set_now_ms(2000).unwrap();
704 let read = json!({"method": "query", "params": {"sql": "SELECT 1 AS id"}});
705 assert_eq!(core.command(&read)["result"]["rows"], json!([{"id": 1}]));
706 assert!(core.drain_events().is_empty());
707 let changed = core.command(&json!({"method": "mutate", "params": {
708 "mutations": [{"op": "upsert", "table": "todo", "values": {
709 "id": "first", "title": "private value", "done": false
710 }}]
711 }}));
712 assert!(changed.get("error").is_none(), "{changed}");
713 let expected = serde_json::to_value(
714 core.client
715 .as_ref()
716 .unwrap()
717 .diagnostics_snapshot(&Default::default())
718 .unwrap(),
719 )
720 .unwrap();
721 let diagnostics: Vec<Value> = core
722 .drain_events()
723 .into_iter()
724 .filter(|event| event.json["type"] == "diagnostics")
725 .map(|event| event.json)
726 .collect();
727 assert_eq!(
728 diagnostics,
729 vec![json!({"type": "diagnostics", "snapshot": expected})]
730 );
731 assert_eq!(diagnostics[0]["snapshot"]["capturedAtMs"], 2000);
732
733 core.set_now_ms(3000).unwrap();
734 assert!(core.command(&read).get("error").is_none());
735 assert!(core.drain_events().is_empty());
736 let revision = core.client.as_ref().unwrap().local_revision();
737 core.command(&json!({"method": "sync", "params": {}}));
738 assert_eq!(core.client.as_ref().unwrap().local_revision(), revision);
739 let expected = serde_json::to_value(
740 core.client
741 .as_ref()
742 .unwrap()
743 .diagnostics_snapshot(&Default::default())
744 .unwrap(),
745 )
746 .unwrap();
747 let diagnostics: Vec<Value> = core
748 .drain_events()
749 .into_iter()
750 .filter(|event| event.json["type"] == "diagnostics")
751 .map(|event| event.json)
752 .collect();
753 assert_eq!(
754 diagnostics,
755 vec![json!({"type": "diagnostics", "snapshot": expected})]
756 );
757 assert_eq!(diagnostics[0]["snapshot"]["capturedAtMs"], 3000);
758 assert_eq!(diagnostics[0]["snapshot"]["lastRound"]["status"], "failed");
759
760 let preflight = core.command(&json!({"method": "beginSecurityPreflight", "params": {}}));
761 assert!(preflight.get("error").is_none(), "{preflight}");
762 assert_eq!(
763 core.command(&read)["error"]["code"],
764 "client.security_preflight_required"
765 );
766 assert!(core
767 .drain_events()
768 .iter()
769 .all(|event| event.json["type"] != "diagnostics"));
770 }
771
772 #[test]
773 fn set_now_ms_sets_observable_capture_times_and_requires_a_client() {
774 let mut core = SyncularCore::new(&json!({})).unwrap();
775 assert_eq!(
778 core.set_now_ms(500).unwrap_err(),
779 "client.not_created: create a client before setting the clock"
780 );
781 let created = core.command(&json!({"method": "create", "params": {
782 "clientId": "clock-seam", "schema": simple_schema(), "nowMs": 1000
783 }}));
784 assert!(created.get("error").is_none(), "{created}");
785 core.command(&json!({"method": "enableDiagnostics", "params": {}}));
786 let initial = core.drain_events();
787 assert_eq!(
788 initial[0].json["snapshot"]["capturedAtMs"], 1000,
789 "create.nowMs pins the client clock"
790 );
791
792 core.set_now_ms(2500).unwrap();
793 let read = json!({"method": "query", "params": {"sql": "SELECT 1 AS id"}});
794 assert!(core.command(&read).get("error").is_none());
795 core.command(&json!({"method": "mutate", "params": {
796 "mutations": [{"op": "upsert", "table": "todo", "values": {
797 "id": "clocked", "title": "x", "done": false
798 }}]
799 }}));
800 let advanced = core
801 .drain_events()
802 .into_iter()
803 .find(|event| event.json["type"] == "diagnostics")
804 .expect("advanced diagnostics snapshot");
805 assert_eq!(
806 advanced.json["snapshot"]["capturedAtMs"], 2500,
807 "the seam sets the observable clock"
808 );
809 }
810
811 #[test]
812 fn diagnostics_events_wait_for_a_registered_consumer() {
813 let mut core = SyncularCore::new(&json!({})).unwrap();
814 create(&mut core);
815 let _ = core.drain_events();
816 core.command(&json!({
817 "method": "mutate",
818 "params": { "mutations": [{
819 "op": "upsert", "table": "todo",
820 "values": { "id": "t1", "title": "x", "done": false }
821 }] }
822 }));
823 let kinds: Vec<String> = core
824 .drain_events()
825 .iter()
826 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
827 .map(str::to_owned)
828 .collect();
829 assert!(kinds.contains(&"change".to_owned()), "kinds: {kinds:?}");
830 assert!(
832 !kinds.contains(&"diagnostics".to_owned()),
833 "kinds: {kinds:?}"
834 );
835
836 let enabled = core.command(&json!({ "method": "enableDiagnostics", "params": {} }));
838 assert_eq!(enabled["result"], json!({}));
839 let events = core.drain_events();
840 assert!(
841 events.iter().any(|e| e.json["type"] == "diagnostics"),
842 "events: {events:?}"
843 );
844
845 core.command(&json!({
847 "method": "mutate",
848 "params": { "mutations": [{
849 "op": "upsert", "table": "todo",
850 "values": { "id": "t2", "title": "y", "done": false }
851 }] }
852 }));
853 let events = core.drain_events();
854 assert!(
855 events.iter().any(|e| e.json["type"] == "diagnostics"),
856 "events: {events:?}"
857 );
858 }
859
860 #[test]
861 fn a_snapshot_pull_registers_the_diagnostics_consumer() {
862 let mut core = SyncularCore::new(&json!({})).unwrap();
863 create(&mut core);
864 let _ = core.drain_events();
865 let reply = core.command(&json!({ "method": "diagnosticsSnapshot", "params": {} }));
866 assert_eq!(reply["result"]["version"], 1);
867 let _ = core.drain_events();
868 core.command(&json!({
869 "method": "mutate",
870 "params": { "mutations": [{
871 "op": "upsert", "table": "todo",
872 "values": { "id": "t1", "title": "x", "done": false }
873 }] }
874 }));
875 let events = core.drain_events();
876 assert!(
877 events.iter().any(|e| e.json["type"] == "diagnostics"),
878 "events: {events:?}"
879 );
880 }
881
882 #[test]
883 fn diagnostics_are_versioned_bounded_and_payload_free() {
884 let mut core = SyncularCore::new(&json!({})).unwrap();
885 create(&mut core);
886 let reply = core.command(&json!({
887 "method": "diagnosticsSnapshot",
888 "params": {
889 "expectedSubscriptions": [{ "id": "membership", "table": "todo" }]
890 }
891 }));
892 assert_eq!(reply["result"]["version"], 1);
893 assert_eq!(reply["result"]["subscriptions"][0]["state"], "unregistered");
894 let encoded = reply.to_string();
895 assert!(!encoded.contains("clientId"));
896 assert!(!encoded.contains("dbPath"));
897 assert!(!encoded.contains("operations"));
898 }
899
900 #[test]
901 fn sync_without_native_transport_fails_loud() {
902 let mut core = SyncularCore::new(&json!({})).unwrap();
903 create(&mut core);
904 let outcome = core.command(&json!({ "method": "sync", "params": {} }));
905 assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
906 assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
907 }
908
909 #[test]
910 fn file_db_persists_across_reopen() {
911 let dir = std::env::temp_dir();
912 let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
913 let path_str = path.to_string_lossy().to_string();
914 let _ = std::fs::remove_file(&path);
915
916 {
917 let mut core = SyncularCore::new(&json!({})).unwrap();
918 let reply = core.command(&json!({
919 "method": "create",
920 "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
921 }));
922 assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
923 core.command(&json!({
924 "method": "mutate",
925 "params": { "mutations": [{
926 "op": "upsert", "table": "todo",
927 "values": { "id": "persisted", "title": "kept", "done": false }
928 }] }
929 }));
930 let revision = core.command(&json!({
931 "method": "localRevision", "params": {}
932 }));
933 assert_eq!(revision["result"]["revision"], "1");
934 }
935 {
938 let mut core = SyncularCore::new(&json!({})).unwrap();
939 let reopened = core.command(&json!({
940 "method": "create",
941 "params": { "schema": simple_schema(), "dbPath": path_str }
942 }));
943 assert_eq!(reopened["result"], json!({}), "reopen: {reopened}");
944 let rows = core.query("SELECT title FROM todo", Value::Null);
945 let list = rows["result"]["rows"].as_array().expect("rows");
946 assert_eq!(list.len(), 1, "reopened db: {rows}");
947 assert_eq!(list[0]["title"], "kept");
948 let revision = core.command(&json!({
949 "method": "localRevision", "params": {}
950 }));
951 assert_eq!(revision["result"]["revision"], "1");
952 let pending = core.command(&json!({
953 "method": "pendingCommitIds", "params": {}
954 }));
955 assert_eq!(pending["result"]["ids"].as_array().map(Vec::len), Some(1));
956 let status = core.command(&json!({
957 "method": "statusSnapshot", "params": {}
958 }));
959 assert_eq!(status["result"]["outbox"], 1);
960 assert_eq!(status["result"]["syncNeeded"], true);
961 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
962 }
963 {
964 let mut core = SyncularCore::new(&json!({})).unwrap();
965 let mismatch = core.command(&json!({
966 "method": "create",
967 "params": { "clientId": "different", "schema": simple_schema(), "dbPath": path_str }
968 }));
969 assert_eq!(mismatch["error"]["code"], "client.identity_mismatch");
970 }
971 let _ = std::fs::remove_file(&path);
972 }
973
974 #[test]
975 fn config_validation_rejects_baseurl_without_native_feature() {
976 let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
977 #[cfg(not(feature = "native-transport"))]
978 assert!(
979 result.is_err(),
980 "baseUrl must be refused without native-transport"
981 );
982 #[cfg(feature = "native-transport")]
983 assert!(result.is_ok(), "baseUrl builds with native-transport");
984 }
985}