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