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