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