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 recommended_action: None,
261 }));
262 };
263 use syncular_client::Transport;
264 let result = client.prepare_sync_round(self.transport.supports_url_fetch());
265 self.round_pending = result.is_ok();
266 self.drain_core_outputs();
267 self.emit_diagnostics_if_changed();
268 result.map(|prepared| (prepared, self.transport.fork_round()))
269 }
270
271 pub fn apply_round(
272 &mut self,
273 completed: syncular_client::CompletedSyncRound,
274 ) -> syncular_client::AppliedSyncRound {
275 self.round_pending = false;
276 let mut applied = match self.client.as_mut() {
277 Some(client) => client.apply_sync_round(completed),
278 None => syncular_client::AppliedSyncRound::Complete {
279 outcome: syncular_client::SyncOutcome::Failed {
280 error_code: "client.round_cancelled".into(),
281 message: "sync round belongs to a released client context".into(),
282 details: None,
283 recommended_action: None,
284 },
285 controls: Vec::new(),
286 more: false,
287 bootstrap_advanced: false,
288 },
289 };
290 self.round_pending = matches!(applied, syncular_client::AppliedSyncRound::Continue(_));
291 if !self.round_pending && !self.transport_enabled() {
292 if let Some(client) = self.client.as_mut() {
293 client.set_transport_enabled(&mut self.transport, false);
294 }
295 }
296 if let syncular_client::AppliedSyncRound::Complete { controls, .. } = &mut applied {
297 self.controls.append(controls);
298 }
299 self.drain_realtime();
302 self.drain_core_outputs();
303 self.emit_diagnostics_if_changed();
304 applied
305 }
306
307 pub fn set_transport_policy(&mut self, policy: HostTransportPolicy) -> Result<(), String> {
315 self.transport.set_policy(policy)
316 }
317
318 pub fn take_controls(&mut self) -> Option<(Vec<String>, HostTransport)> {
319 if !self.transport_enabled() {
320 self.controls.clear();
321 return None;
322 }
323 if self.controls.is_empty() {
324 return None;
325 }
326 Some((
327 std::mem::take(&mut self.controls),
328 self.transport.fork_round(),
329 ))
330 }
331
332 pub fn record_query_read(
334 &mut self,
335 id: &str,
336 tables: &[String],
337 failure: Option<&QueryReadFailure>,
338 ) {
339 let Some(client) = self.client.as_mut() else {
340 return;
341 };
342 let tables: Vec<&str> = tables.iter().map(String::as_str).collect();
343 client.record_query_read(
344 QueryOwner {
345 id,
346 tables: &tables,
347 },
348 failure,
349 );
350 self.emit_diagnostics_if_changed();
351 }
352
353 pub fn poll_transport(&mut self) {
355 self.drain_realtime();
356 self.drain_core_outputs();
357 self.emit_diagnostics_if_changed();
358 }
359
360 pub fn drain_events(&mut self) -> Vec<Event> {
363 self.queue.drain(..).collect()
364 }
365
366 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
370 self.transport.set_headers(headers);
371 }
372
373 pub fn shutdown(&mut self) {
375 if let Some(mut client) = self.client.take() {
376 client.disconnect_realtime(&mut self.transport);
377 client.seal_security_on_teardown();
380 }
381 self.interactive_sync = false;
382 self.background_sync_ms = None;
383 self.diagnostics_observed = false;
386 self.transport.shutdown();
387 }
388
389 fn push(&mut self, json: Value) {
390 self.queue.push_back(Event { json });
391 }
392
393 fn drain_realtime(&mut self) {
396 if self.round_pending || !self.transport_enabled() {
397 return;
398 }
399 let frames = self.transport.take_inbound();
400 for frame in frames {
401 match frame {
402 transport::Inbound::Text(text) => {
403 if let Some(scope_key) = presence_scope_key(&text) {
404 self.push(json!({ "type": "presence", "scopeKey": scope_key }));
405 }
406 if let Some(client) = self.client.as_mut() {
407 client.on_realtime_text(&text);
408 }
409 }
410 transport::Inbound::Binary(bytes) => {
411 if let Some(client) = self.client.as_mut() {
412 if !client.security_preflight() {
413 self.controls
414 .extend(client.on_realtime_binary_queued(&bytes));
415 }
416 }
417 }
418 transport::Inbound::Disconnected(error) => {
419 if let Some(client) = self.client.as_mut() {
420 client.realtime_lost(&mut self.transport, &error);
421 }
422 }
423 }
424 }
425 }
426
427 fn drain_core_outputs(&mut self) {
429 let Some(client) = self.client.as_mut() else {
430 return;
431 };
432 let batches = client.drain_change_batches();
433 let intents = client.drain_sync_intents();
434 for batch in batches {
435 self.push(json!({ "type": "change", "batch": batch }));
436 }
437 for intent in intents {
438 match intent {
439 SyncIntent::Interactive => self.interactive_sync = true,
440 SyncIntent::Background { delay_ms } => {
441 self.background_sync_ms = Some(
442 self.background_sync_ms
443 .map_or(delay_ms, |current| current.min(delay_ms)),
444 );
445 }
446 SyncIntent::None => {}
447 }
448 }
449 }
450
451 fn emit_diagnostics_if_changed(&mut self) {
460 if !self.diagnostics_observed {
461 return;
462 }
463 let Some(client) = self.client.as_ref() else {
464 return;
465 };
466 if client.security_preflight() {
467 return;
468 }
469 let Ok(snapshot) = client.diagnostics_snapshot(&ClientDiagnosticsRequest::default()) else {
470 return;
471 };
472 if let Some(previous) = &mut self.last_diagnostics_snapshot {
473 previous.captured_at_ms = snapshot.captured_at_ms;
475 if previous == &snapshot {
476 return;
477 }
478 }
479 let event = json!({ "type": "diagnostics", "snapshot": snapshot });
480 self.last_diagnostics_snapshot = Some(snapshot);
481 self.push(event);
482 }
483}
484
485fn presence_scope_key(text: &str) -> Option<String> {
488 serde_json::from_str::<Value>(text).ok().and_then(|v| {
489 if v.get("event").and_then(Value::as_str) != Some("presence") {
490 return None;
491 }
492 v.get("data")?
493 .get("scopeKey")
494 .and_then(Value::as_str)
495 .map(str::to_owned)
496 })
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn presence_scope_key_reads_the_fanout_data() {
505 assert_eq!(
506 presence_scope_key(
507 r#"{"event":"presence","data":{"scopeKey":"room:42","kind":"leave","actorId":"a","clientId":"c","doc":null,"timestamp":1}}"#
508 )
509 .as_deref(),
510 Some("room:42")
511 );
512 assert_eq!(
513 presence_scope_key(r#"{"event":"presence","data":{}}"#),
514 None
515 );
516 assert_eq!(presence_scope_key(r#"{"event":"sync","data":{}}"#), None);
517 }
518
519 fn simple_schema() -> Value {
520 json!({
521 "version": 1,
522 "tables": [{
523 "name": "todo",
524 "primaryKey": "id",
525 "columns": [
526 { "name": "id", "type": "string", "nullable": false },
527 { "name": "title", "type": "string", "nullable": false },
528 { "name": "done", "type": "boolean", "nullable": false }
529 ],
530 "scopes": []
531 }]
532 })
533 }
534
535 fn create(core: &mut SyncularCore) {
536 let reply = core.command(&json!({
537 "method": "create",
538 "params": { "clientId": "c1", "schema": simple_schema() }
539 }));
540 assert_eq!(reply["result"], json!({}), "create ok: {reply}");
541 }
542
543 #[test]
544 fn transport_gate_survives_security_activation_and_owner_rounds() {
545 let mut core = SyncularCore::new(&json!({})).unwrap();
546 let result = core.command(&json!({ "method": "create", "params": {
547 "schema": simple_schema(), "transportEnabled": false, "securityPreflight": true
548 } }));
549 assert!(result.get("error").is_none());
550 assert!(core
551 .command(&json!({ "method": "activateSecurity", "params": {} }))
552 .get("error")
553 .is_none());
554 for title in ["first", "second"] {
555 assert!(core.command(&json!({ "method": "mutate", "params": { "mutations": [{
556 "op": "upsert", "table": "todo", "values": { "id": "one", "title": title, "done": false }
557 }] } })).get("error").is_none());
558 assert!(matches!(core.take_sync_intent(), SyncIntent::None));
559 assert!(
560 matches!(*core.prepare_round().err().expect("closed transport"), syncular_client::SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
561 );
562 }
563 assert_eq!(
564 core.query("SELECT title FROM todo", Value::Null)["result"]["rows"][0]["title"],
565 "second"
566 );
567 assert!(!core.transport_enabled());
568 core.command(&json!({ "method": "setTransportEnabled", "params": { "enabled": true } }));
569 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
570 core.command(&json!({ "method": "setTransportEnabled", "params": { "enabled": true } }));
571 assert!(matches!(core.take_sync_intent(), SyncIntent::None));
572 core.command(&json!({ "method": "setTransportEnabled", "params": { "enabled": false } }));
573 core.command(&json!({ "method": "beginSecurityPreflight" }));
574 assert_eq!(
575 core.query("SELECT title FROM todo", Value::Null)["error"]["code"],
576 "client.security_preflight_required"
577 );
578 core.command(&json!({ "method": "activateSecurity", "params": {} }));
579 assert!(!core.transport_enabled());
580 }
581
582 #[test]
583 fn progress_sink_runs_before_the_sync_command_returns() {
584 use std::sync::{
585 atomic::{AtomicBool, Ordering},
586 Arc, Mutex,
587 };
588 let mut core = SyncularCore::new(&json!({})).unwrap();
589 let running = Arc::new(AtomicBool::new(false));
590 let observed = Arc::new(Mutex::new(Vec::new()));
591 let in_command = running.clone();
592 let events = observed.clone();
593 core.progress_listener = Some(Arc::new(move |progress| {
594 events
595 .lock()
596 .unwrap()
597 .push((in_command.load(Ordering::SeqCst), progress.state));
598 }));
599 create(&mut core);
600 running.store(true, Ordering::SeqCst);
601 core.command(&json!({ "method": "sync" }));
602 running.store(false, Ordering::SeqCst);
603 assert_eq!(
604 *observed.lock().unwrap(),
605 vec![
606 (true, syncular_client::ProgressState::Running),
607 (true, syncular_client::ProgressState::Failed)
608 ]
609 );
610 }
611
612 #[test]
613 fn command_round_trip_create_mutate_query() {
614 let mut core = SyncularCore::new(&json!({})).unwrap();
615 create(&mut core);
616
617 let sub = core.command(&json!({
618 "method": "subscribe",
619 "params": { "id": "s1", "table": "todo", "scopes": {} }
620 }));
621 assert_eq!(sub["result"], json!({}));
622
623 let mutate = core.command(&json!({
624 "method": "mutate",
625 "params": { "mutations": [{
626 "op": "upsert", "table": "todo",
627 "values": { "id": "t1", "title": "hello", "done": false }
628 }] }
629 }));
630 assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
631
632 let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
634 let list = rows["result"]["rows"].as_array().expect("rows");
635 assert_eq!(list.len(), 1);
636 assert_eq!(list[0]["title"], "hello");
637 assert_eq!(list[0]["id"], "t1");
638 }
639
640 #[test]
641 fn query_binds_params() {
642 let mut core = SyncularCore::new(&json!({})).unwrap();
643 create(&mut core);
644 core.command(&json!({
645 "method": "mutate",
646 "params": { "mutations": [
647 { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
648 { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
649 ] }
650 }));
651 let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
652 let list = rows["result"]["rows"].as_array().expect("rows");
653 assert_eq!(list.len(), 1);
654 assert_eq!(list[0]["id"], "b");
655 }
656
657 #[test]
658 fn events_derived_after_mutate() {
659 let mut core = SyncularCore::new(&json!({})).unwrap();
660 create(&mut core);
662 let enabled = core.command(&json!({ "method": "enableDiagnostics", "params": {} }));
664 assert_eq!(enabled["result"], json!({}));
665 let _ = core.drain_events();
666 core.command(&json!({
667 "method": "mutate",
668 "params": { "mutations": [{
669 "op": "upsert", "table": "todo",
670 "values": { "id": "t1", "title": "x", "done": false }
671 }] }
672 }));
673 let events = core.drain_events();
674 let kinds: Vec<&str> = events
677 .iter()
678 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
679 .collect();
680 assert!(kinds.contains(&"change"), "kinds: {kinds:?}");
681 assert!(kinds.contains(&"diagnostics"), "kinds: {kinds:?}");
682 let change = events
683 .iter()
684 .find(|event| event.json["type"] == "change")
685 .expect("change event");
686 assert_eq!(change.json["batch"]["revision"], "1");
687 assert_eq!(change.json["batch"]["tables"][0]["table"], "todo");
688 assert_eq!(change.json["batch"]["status"]["outbox"], 1);
689 assert_eq!(change.json["batch"]["status"]["syncNeeded"], false);
692 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
693 assert!(core.drain_events().is_empty());
695 }
696
697 #[test]
698 fn diagnostics_keep_fresh_capture_times_and_revision_independent_changes() {
699 let mut core = SyncularCore::new(&json!({})).unwrap();
700 let created = core.command(&json!({"method": "create", "params": {
701 "clientId": "diagnostics-comparison", "schema": simple_schema(), "nowMs": 1000
702 }}));
703 assert!(created.get("error").is_none(), "{created}");
704 assert!(core.drain_events().is_empty());
705 core.command(&json!({"method": "enableDiagnostics", "params": {}}));
706 let initial = core.drain_events();
707 assert_eq!(initial.len(), 1);
708 assert_eq!(initial[0].json["snapshot"]["capturedAtMs"], 1000);
709
710 core.set_now_ms(2000).unwrap();
711 let read = json!({"method": "query", "params": {"sql": "SELECT 1 AS id"}});
712 assert_eq!(core.command(&read)["result"]["rows"], json!([{"id": 1}]));
713 assert!(core.drain_events().is_empty());
714 let changed = core.command(&json!({"method": "mutate", "params": {
715 "mutations": [{"op": "upsert", "table": "todo", "values": {
716 "id": "first", "title": "private value", "done": false
717 }}]
718 }}));
719 assert!(changed.get("error").is_none(), "{changed}");
720 let expected = serde_json::to_value(
721 core.client
722 .as_ref()
723 .unwrap()
724 .diagnostics_snapshot(&Default::default())
725 .unwrap(),
726 )
727 .unwrap();
728 let diagnostics: Vec<Value> = core
729 .drain_events()
730 .into_iter()
731 .filter(|event| event.json["type"] == "diagnostics")
732 .map(|event| event.json)
733 .collect();
734 assert_eq!(
735 diagnostics,
736 vec![json!({"type": "diagnostics", "snapshot": expected})]
737 );
738 assert_eq!(diagnostics[0]["snapshot"]["capturedAtMs"], 2000);
739
740 core.set_now_ms(3000).unwrap();
741 assert!(core.command(&read).get("error").is_none());
742 assert!(core.drain_events().is_empty());
743 let revision = core.client.as_ref().unwrap().local_revision();
744 core.command(&json!({"method": "sync", "params": {}}));
745 assert_eq!(core.client.as_ref().unwrap().local_revision(), revision);
746 let expected = serde_json::to_value(
747 core.client
748 .as_ref()
749 .unwrap()
750 .diagnostics_snapshot(&Default::default())
751 .unwrap(),
752 )
753 .unwrap();
754 let diagnostics: Vec<Value> = core
755 .drain_events()
756 .into_iter()
757 .filter(|event| event.json["type"] == "diagnostics")
758 .map(|event| event.json)
759 .collect();
760 assert_eq!(
761 diagnostics,
762 vec![json!({"type": "diagnostics", "snapshot": expected})]
763 );
764 assert_eq!(diagnostics[0]["snapshot"]["capturedAtMs"], 3000);
765 assert_eq!(diagnostics[0]["snapshot"]["lastRound"]["status"], "failed");
766
767 let preflight = core.command(&json!({"method": "beginSecurityPreflight", "params": {}}));
768 assert!(preflight.get("error").is_none(), "{preflight}");
769 assert_eq!(
770 core.command(&read)["error"]["code"],
771 "client.security_preflight_required"
772 );
773 assert!(core
774 .drain_events()
775 .iter()
776 .all(|event| event.json["type"] != "diagnostics"));
777 }
778
779 #[test]
780 fn set_now_ms_sets_observable_capture_times_and_requires_a_client() {
781 let mut core = SyncularCore::new(&json!({})).unwrap();
782 assert_eq!(
785 core.set_now_ms(500).unwrap_err(),
786 "client.not_created: create a client before setting the clock"
787 );
788 let created = core.command(&json!({"method": "create", "params": {
789 "clientId": "clock-seam", "schema": simple_schema(), "nowMs": 1000
790 }}));
791 assert!(created.get("error").is_none(), "{created}");
792 core.command(&json!({"method": "enableDiagnostics", "params": {}}));
793 let initial = core.drain_events();
794 assert_eq!(
795 initial[0].json["snapshot"]["capturedAtMs"], 1000,
796 "create.nowMs pins the client clock"
797 );
798
799 core.set_now_ms(2500).unwrap();
800 let read = json!({"method": "query", "params": {"sql": "SELECT 1 AS id"}});
801 assert!(core.command(&read).get("error").is_none());
802 core.command(&json!({"method": "mutate", "params": {
803 "mutations": [{"op": "upsert", "table": "todo", "values": {
804 "id": "clocked", "title": "x", "done": false
805 }}]
806 }}));
807 let advanced = core
808 .drain_events()
809 .into_iter()
810 .find(|event| event.json["type"] == "diagnostics")
811 .expect("advanced diagnostics snapshot");
812 assert_eq!(
813 advanced.json["snapshot"]["capturedAtMs"], 2500,
814 "the seam sets the observable clock"
815 );
816 }
817
818 #[test]
819 fn diagnostics_events_wait_for_a_registered_consumer() {
820 let mut core = SyncularCore::new(&json!({})).unwrap();
821 create(&mut core);
822 let _ = core.drain_events();
823 core.command(&json!({
824 "method": "mutate",
825 "params": { "mutations": [{
826 "op": "upsert", "table": "todo",
827 "values": { "id": "t1", "title": "x", "done": false }
828 }] }
829 }));
830 let kinds: Vec<String> = core
831 .drain_events()
832 .iter()
833 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
834 .map(str::to_owned)
835 .collect();
836 assert!(kinds.contains(&"change".to_owned()), "kinds: {kinds:?}");
837 assert!(
839 !kinds.contains(&"diagnostics".to_owned()),
840 "kinds: {kinds:?}"
841 );
842
843 let enabled = core.command(&json!({ "method": "enableDiagnostics", "params": {} }));
845 assert_eq!(enabled["result"], json!({}));
846 let events = core.drain_events();
847 assert!(
848 events.iter().any(|e| e.json["type"] == "diagnostics"),
849 "events: {events:?}"
850 );
851
852 core.command(&json!({
854 "method": "mutate",
855 "params": { "mutations": [{
856 "op": "upsert", "table": "todo",
857 "values": { "id": "t2", "title": "y", "done": false }
858 }] }
859 }));
860 let events = core.drain_events();
861 assert!(
862 events.iter().any(|e| e.json["type"] == "diagnostics"),
863 "events: {events:?}"
864 );
865 }
866
867 #[test]
868 fn a_snapshot_pull_registers_the_diagnostics_consumer() {
869 let mut core = SyncularCore::new(&json!({})).unwrap();
870 create(&mut core);
871 let _ = core.drain_events();
872 let reply = core.command(&json!({ "method": "diagnosticsSnapshot", "params": {} }));
873 assert_eq!(reply["result"]["version"], 1);
874 let _ = core.drain_events();
875 core.command(&json!({
876 "method": "mutate",
877 "params": { "mutations": [{
878 "op": "upsert", "table": "todo",
879 "values": { "id": "t1", "title": "x", "done": false }
880 }] }
881 }));
882 let events = core.drain_events();
883 assert!(
884 events.iter().any(|e| e.json["type"] == "diagnostics"),
885 "events: {events:?}"
886 );
887 }
888
889 #[test]
890 fn diagnostics_are_versioned_bounded_and_payload_free() {
891 let mut core = SyncularCore::new(&json!({})).unwrap();
892 create(&mut core);
893 let reply = core.command(&json!({
894 "method": "diagnosticsSnapshot",
895 "params": {
896 "expectedSubscriptions": [{ "id": "membership", "table": "todo" }]
897 }
898 }));
899 assert_eq!(reply["result"]["version"], 1);
900 assert_eq!(reply["result"]["subscriptions"][0]["state"], "unregistered");
901 let encoded = reply.to_string();
902 assert!(!encoded.contains("clientId"));
903 assert!(!encoded.contains("dbPath"));
904 assert!(!encoded.contains("operations"));
905 }
906
907 #[test]
908 fn sync_without_native_transport_fails_loud() {
909 let mut core = SyncularCore::new(&json!({})).unwrap();
910 create(&mut core);
911 let outcome = core.command(&json!({ "method": "sync", "params": {} }));
912 assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
913 assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
914 }
915
916 #[test]
917 fn file_db_persists_across_reopen() {
918 let dir = std::env::temp_dir();
919 let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
920 let path_str = path.to_string_lossy().to_string();
921 let _ = std::fs::remove_file(&path);
922
923 {
924 let mut core = SyncularCore::new(&json!({})).unwrap();
925 let reply = core.command(&json!({
926 "method": "create",
927 "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
928 }));
929 assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
930 core.command(&json!({
931 "method": "mutate",
932 "params": { "mutations": [{
933 "op": "upsert", "table": "todo",
934 "values": { "id": "persisted", "title": "kept", "done": false }
935 }] }
936 }));
937 let revision = core.command(&json!({
938 "method": "localRevision", "params": {}
939 }));
940 assert_eq!(revision["result"]["revision"], "1");
941 }
942 {
945 let mut core = SyncularCore::new(&json!({})).unwrap();
946 let reopened = core.command(&json!({
947 "method": "create",
948 "params": { "schema": simple_schema(), "dbPath": path_str }
949 }));
950 assert_eq!(reopened["result"], json!({}), "reopen: {reopened}");
951 let rows = core.query("SELECT title FROM todo", Value::Null);
952 let list = rows["result"]["rows"].as_array().expect("rows");
953 assert_eq!(list.len(), 1, "reopened db: {rows}");
954 assert_eq!(list[0]["title"], "kept");
955 let revision = core.command(&json!({
956 "method": "localRevision", "params": {}
957 }));
958 assert_eq!(revision["result"]["revision"], "1");
959 let pending = core.command(&json!({
960 "method": "pendingCommitIds", "params": {}
961 }));
962 assert_eq!(pending["result"]["ids"].as_array().map(Vec::len), Some(1));
963 let status = core.command(&json!({
964 "method": "statusSnapshot", "params": {}
965 }));
966 assert_eq!(status["result"]["outbox"], 1);
967 assert_eq!(status["result"]["syncNeeded"], true);
968 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
969 }
970 {
971 let mut core = SyncularCore::new(&json!({})).unwrap();
972 let mismatch = core.command(&json!({
973 "method": "create",
974 "params": { "clientId": "different", "schema": simple_schema(), "dbPath": path_str }
975 }));
976 assert_eq!(mismatch["error"]["code"], "client.identity_mismatch");
977 }
978 let _ = std::fs::remove_file(&path);
979 }
980
981 #[test]
982 fn config_validation_rejects_baseurl_without_native_feature() {
983 let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
984 #[cfg(not(feature = "native-transport"))]
985 assert!(
986 result.is_err(),
987 "baseUrl must be refused without native-transport"
988 );
989 #[cfg(feature = "native-transport")]
990 assert!(result.is_ok(), "baseUrl builds with native-transport");
991 }
992}