1use std::collections::VecDeque;
28
29use serde_json::{json, Value};
30use syncular_client::{ClientDiagnosticsRequest, SyncClient, SyncIntent};
31use syncular_command::{dispatch, CreateEffects};
32
33use crate::transport::{self, HostTransport};
34
35#[derive(Debug, Clone)]
39pub struct Event {
40 pub json: Value,
41}
42
43pub struct SyncularCore {
46 client: Option<SyncClient>,
47 transport: HostTransport,
48 effects: CreateEffects,
49 queue: VecDeque<Event>,
50 last_diagnostics_fingerprint: Option<Value>,
51 diagnostics_observed: bool,
56 interactive_sync: bool,
57 background_sync_ms: Option<u64>,
58}
59
60impl SyncularCore {
61 pub fn new(config: &Value) -> Result<Self, String> {
65 Self::new_with_notify(config, None)
66 }
67
68 pub fn new_with_notify(
69 config: &Value,
70 notify: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
71 ) -> Result<Self, String> {
72 let transport = HostTransport::from_config_with_notify(config, notify)?;
73 Ok(SyncularCore {
74 client: None,
75 transport,
76 effects: CreateEffects::default(),
77 queue: VecDeque::new(),
78 last_diagnostics_fingerprint: None,
79 diagnostics_observed: false,
80 interactive_sync: false,
81 background_sync_ms: None,
82 })
83 }
84
85 pub fn command(&mut self, command: &Value) -> Value {
89 let method = command.get("method").and_then(Value::as_str).unwrap_or("");
90 let params = command.get("params").cloned().unwrap_or(Value::Null);
91 if method == "enableDiagnostics" {
92 self.diagnostics_observed = true;
96 self.last_diagnostics_fingerprint = None;
97 self.drain_realtime();
98 self.drain_core_outputs();
99 self.emit_diagnostics_if_changed();
100 return json!({ "result": {} });
101 }
102 if method == "diagnosticsSnapshot" {
103 self.diagnostics_observed = true;
106 }
107 let result = dispatch(
108 &mut self.transport,
109 &mut self.client,
110 &mut self.effects,
111 method,
112 ¶ms,
113 );
114 if method == "create" {
115 self.last_diagnostics_fingerprint = None;
116 self.transport.set_signed_urls(self.effects.signed_urls);
117 }
118 if method == "beginSecurityPreflight"
119 || method == "shutdown"
120 || (method == "create"
121 && params
122 .get("securityPreflight")
123 .and_then(Value::as_bool)
124 .unwrap_or(false))
125 {
126 self.interactive_sync = false;
127 self.background_sync_ms = None;
128 }
129 if let Ok(value) = &result {
130 if value.pointer("/effects/sync/kind").and_then(Value::as_str) == Some("interactive") {
131 self.interactive_sync = true;
132 }
133 }
134 self.drain_realtime();
135 self.drain_core_outputs();
136 self.emit_diagnostics_if_changed();
137 match result {
138 Ok(mut value) => {
139 if let Some(object) = value.as_object_mut() {
142 object.remove("effects");
143 }
144 json!({ "result": value })
145 }
146 Err((code, message)) => json!({ "error": { "code": code, "message": message } }),
147 }
148 }
149
150 pub fn query(&mut self, sql: &str, params: Value) -> Value {
155 let bind = match params {
156 Value::Null => Value::Array(Vec::new()),
157 other => other,
158 };
159 self.command(&json!({ "method": "query", "params": { "sql": sql, "params": bind } }))
160 }
161
162 pub fn take_sync_intent(&mut self) -> SyncIntent {
165 if std::mem::take(&mut self.interactive_sync) {
166 self.background_sync_ms = None;
167 SyncIntent::Interactive
168 } else if let Some(delay_ms) = self.background_sync_ms.take() {
169 SyncIntent::Background { delay_ms }
170 } else {
171 SyncIntent::None
172 }
173 }
174
175 pub fn sync_until_idle(&mut self) -> Value {
178 if self.client.is_none() {
179 return json!({ "result": null });
180 }
181 self.command(&json!({ "method": "syncUntilIdle", "params": {} }))
182 }
183
184 pub fn poll_transport(&mut self) {
186 self.drain_realtime();
187 self.drain_core_outputs();
188 self.emit_diagnostics_if_changed();
189 }
190
191 pub fn drain_events(&mut self) -> Vec<Event> {
194 self.queue.drain(..).collect()
195 }
196
197 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
201 self.transport.set_headers(headers);
202 }
203
204 pub fn shutdown(&mut self) {
206 if let Some(mut client) = self.client.take() {
207 client.disconnect_realtime(&mut self.transport);
208 client.seal_security_on_teardown();
211 }
212 self.interactive_sync = false;
213 self.background_sync_ms = None;
214 self.diagnostics_observed = false;
217 self.transport.shutdown();
218 }
219
220 fn push(&mut self, json: Value) {
221 self.queue.push_back(Event { json });
222 }
223
224 fn drain_realtime(&mut self) {
227 if self.client.is_none() {
228 return;
229 }
230 let frames = self.transport.take_inbound();
231 for frame in frames {
232 match frame {
233 transport::Inbound::Text(text) => {
234 if is_presence_control(&text) {
235 self.push(json!({ "type": "presence" }));
236 }
237 if let Some(client) = self.client.as_mut() {
238 client.on_realtime_text(&text);
239 }
240 }
241 transport::Inbound::Binary(bytes) => {
242 if let Some(client) = self.client.as_mut() {
243 client.on_realtime_binary(&mut self.transport, &bytes);
244 }
245 }
246 }
247 }
248 }
249
250 fn drain_core_outputs(&mut self) {
252 let Some(client) = self.client.as_mut() else {
253 return;
254 };
255 let batches = client.drain_change_batches();
256 let intents = client.drain_sync_intents();
257 for batch in batches {
258 self.push(json!({ "type": "change", "batch": batch }));
259 }
260 for intent in intents {
261 match intent {
262 SyncIntent::Interactive => self.interactive_sync = true,
263 SyncIntent::Background { delay_ms } => {
264 self.background_sync_ms = Some(
265 self.background_sync_ms
266 .map_or(delay_ms, |current| current.min(delay_ms)),
267 );
268 }
269 SyncIntent::None => {}
270 }
271 }
272 }
273
274 fn emit_diagnostics_if_changed(&mut self) {
283 if !self.diagnostics_observed {
284 return;
285 }
286 let Some(client) = self.client.as_ref() else {
287 return;
288 };
289 if client.security_preflight() {
290 return;
291 }
292 let Ok(snapshot) = client.diagnostics_snapshot(&ClientDiagnosticsRequest::default()) else {
293 return;
294 };
295 let Ok(mut fingerprint) = serde_json::to_value(&snapshot) else {
296 return;
297 };
298 if let Some(object) = fingerprint.as_object_mut() {
299 object.remove("capturedAtMs");
300 }
301 if self.last_diagnostics_fingerprint.as_ref() == Some(&fingerprint) {
302 return;
303 }
304 self.last_diagnostics_fingerprint = Some(fingerprint);
305 self.push(json!({ "type": "diagnostics", "snapshot": snapshot }));
306 }
307}
308
309fn is_presence_control(text: &str) -> bool {
312 serde_json::from_str::<Value>(text)
313 .ok()
314 .and_then(|v| {
315 v.get("event")
316 .and_then(Value::as_str)
317 .map(|e| e == "presence")
318 })
319 .unwrap_or(false)
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn simple_schema() -> Value {
327 json!({
328 "version": 1,
329 "tables": [{
330 "name": "todo",
331 "primaryKey": "id",
332 "columns": [
333 { "name": "id", "type": "string", "nullable": false },
334 { "name": "title", "type": "string", "nullable": false },
335 { "name": "done", "type": "boolean", "nullable": false }
336 ],
337 "scopes": []
338 }]
339 })
340 }
341
342 fn create(core: &mut SyncularCore) {
343 let reply = core.command(&json!({
344 "method": "create",
345 "params": { "clientId": "c1", "schema": simple_schema() }
346 }));
347 assert_eq!(reply["result"], json!({}), "create ok: {reply}");
348 }
349
350 #[test]
351 fn command_round_trip_create_mutate_query() {
352 let mut core = SyncularCore::new(&json!({})).unwrap();
353 create(&mut core);
354
355 let sub = core.command(&json!({
356 "method": "subscribe",
357 "params": { "id": "s1", "table": "todo", "scopes": {} }
358 }));
359 assert_eq!(sub["result"], json!({}));
360
361 let mutate = core.command(&json!({
362 "method": "mutate",
363 "params": { "mutations": [{
364 "op": "upsert", "table": "todo",
365 "values": { "id": "t1", "title": "hello", "done": false }
366 }] }
367 }));
368 assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
369
370 let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
372 let list = rows["result"]["rows"].as_array().expect("rows");
373 assert_eq!(list.len(), 1);
374 assert_eq!(list[0]["title"], "hello");
375 assert_eq!(list[0]["id"], "t1");
376 }
377
378 #[test]
379 fn query_binds_params() {
380 let mut core = SyncularCore::new(&json!({})).unwrap();
381 create(&mut core);
382 core.command(&json!({
383 "method": "mutate",
384 "params": { "mutations": [
385 { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
386 { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
387 ] }
388 }));
389 let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
390 let list = rows["result"]["rows"].as_array().expect("rows");
391 assert_eq!(list.len(), 1);
392 assert_eq!(list[0]["id"], "b");
393 }
394
395 #[test]
396 fn events_derived_after_mutate() {
397 let mut core = SyncularCore::new(&json!({})).unwrap();
398 create(&mut core);
400 let enabled = core.command(&json!({ "method": "enableDiagnostics", "params": {} }));
402 assert_eq!(enabled["result"], json!({}));
403 let _ = core.drain_events();
404 core.command(&json!({
405 "method": "mutate",
406 "params": { "mutations": [{
407 "op": "upsert", "table": "todo",
408 "values": { "id": "t1", "title": "x", "done": false }
409 }] }
410 }));
411 let events = core.drain_events();
412 let kinds: Vec<&str> = events
415 .iter()
416 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
417 .collect();
418 assert!(kinds.contains(&"change"), "kinds: {kinds:?}");
419 assert!(kinds.contains(&"diagnostics"), "kinds: {kinds:?}");
420 let change = events
421 .iter()
422 .find(|event| event.json["type"] == "change")
423 .expect("change event");
424 assert_eq!(change.json["batch"]["revision"], "1");
425 assert_eq!(change.json["batch"]["tables"][0]["table"], "todo");
426 assert_eq!(change.json["batch"]["status"]["outbox"], 1);
427 assert_eq!(change.json["batch"]["status"]["syncNeeded"], false);
430 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
431 assert!(core.drain_events().is_empty());
433 }
434
435 #[test]
436 fn diagnostics_events_wait_for_a_registered_consumer() {
437 let mut core = SyncularCore::new(&json!({})).unwrap();
438 create(&mut core);
439 let _ = core.drain_events();
440 core.command(&json!({
441 "method": "mutate",
442 "params": { "mutations": [{
443 "op": "upsert", "table": "todo",
444 "values": { "id": "t1", "title": "x", "done": false }
445 }] }
446 }));
447 let kinds: Vec<String> = core
448 .drain_events()
449 .iter()
450 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
451 .map(str::to_owned)
452 .collect();
453 assert!(kinds.contains(&"change".to_owned()), "kinds: {kinds:?}");
454 assert!(
456 !kinds.contains(&"diagnostics".to_owned()),
457 "kinds: {kinds:?}"
458 );
459
460 let enabled = core.command(&json!({ "method": "enableDiagnostics", "params": {} }));
462 assert_eq!(enabled["result"], json!({}));
463 let events = core.drain_events();
464 assert!(
465 events.iter().any(|e| e.json["type"] == "diagnostics"),
466 "events: {events:?}"
467 );
468
469 core.command(&json!({
471 "method": "mutate",
472 "params": { "mutations": [{
473 "op": "upsert", "table": "todo",
474 "values": { "id": "t2", "title": "y", "done": false }
475 }] }
476 }));
477 let events = core.drain_events();
478 assert!(
479 events.iter().any(|e| e.json["type"] == "diagnostics"),
480 "events: {events:?}"
481 );
482 }
483
484 #[test]
485 fn a_snapshot_pull_registers_the_diagnostics_consumer() {
486 let mut core = SyncularCore::new(&json!({})).unwrap();
487 create(&mut core);
488 let _ = core.drain_events();
489 let reply = core.command(&json!({ "method": "diagnosticsSnapshot", "params": {} }));
490 assert_eq!(reply["result"]["version"], 1);
491 let _ = core.drain_events();
492 core.command(&json!({
493 "method": "mutate",
494 "params": { "mutations": [{
495 "op": "upsert", "table": "todo",
496 "values": { "id": "t1", "title": "x", "done": false }
497 }] }
498 }));
499 let events = core.drain_events();
500 assert!(
501 events.iter().any(|e| e.json["type"] == "diagnostics"),
502 "events: {events:?}"
503 );
504 }
505
506 #[test]
507 fn diagnostics_are_versioned_bounded_and_payload_free() {
508 let mut core = SyncularCore::new(&json!({})).unwrap();
509 create(&mut core);
510 let reply = core.command(&json!({
511 "method": "diagnosticsSnapshot",
512 "params": {
513 "expectedSubscriptions": [{ "id": "membership", "table": "todo" }]
514 }
515 }));
516 assert_eq!(reply["result"]["version"], 1);
517 assert_eq!(reply["result"]["subscriptions"][0]["state"], "unregistered");
518 let encoded = reply.to_string();
519 assert!(!encoded.contains("clientId"));
520 assert!(!encoded.contains("dbPath"));
521 assert!(!encoded.contains("operations"));
522 }
523
524 #[test]
525 fn sync_without_native_transport_fails_loud() {
526 let mut core = SyncularCore::new(&json!({})).unwrap();
527 create(&mut core);
528 let outcome = core.command(&json!({ "method": "sync", "params": {} }));
529 assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
530 assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
531 }
532
533 #[test]
534 fn file_db_persists_across_reopen() {
535 let dir = std::env::temp_dir();
536 let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
537 let path_str = path.to_string_lossy().to_string();
538 let _ = std::fs::remove_file(&path);
539
540 {
541 let mut core = SyncularCore::new(&json!({})).unwrap();
542 let reply = core.command(&json!({
543 "method": "create",
544 "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
545 }));
546 assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
547 core.command(&json!({
548 "method": "mutate",
549 "params": { "mutations": [{
550 "op": "upsert", "table": "todo",
551 "values": { "id": "persisted", "title": "kept", "done": false }
552 }] }
553 }));
554 let revision = core.command(&json!({
555 "method": "localRevision", "params": {}
556 }));
557 assert_eq!(revision["result"]["revision"], "1");
558 }
559 {
562 let mut core = SyncularCore::new(&json!({})).unwrap();
563 let reopened = core.command(&json!({
564 "method": "create",
565 "params": { "schema": simple_schema(), "dbPath": path_str }
566 }));
567 assert_eq!(reopened["result"], json!({}), "reopen: {reopened}");
568 let rows = core.query("SELECT title FROM todo", Value::Null);
569 let list = rows["result"]["rows"].as_array().expect("rows");
570 assert_eq!(list.len(), 1, "reopened db: {rows}");
571 assert_eq!(list[0]["title"], "kept");
572 let revision = core.command(&json!({
573 "method": "localRevision", "params": {}
574 }));
575 assert_eq!(revision["result"]["revision"], "1");
576 let pending = core.command(&json!({
577 "method": "pendingCommitIds", "params": {}
578 }));
579 assert_eq!(pending["result"]["ids"].as_array().map(Vec::len), Some(1));
580 let status = core.command(&json!({
581 "method": "statusSnapshot", "params": {}
582 }));
583 assert_eq!(status["result"]["outbox"], 1);
584 assert_eq!(status["result"]["syncNeeded"], true);
585 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
586 }
587 {
588 let mut core = SyncularCore::new(&json!({})).unwrap();
589 let mismatch = core.command(&json!({
590 "method": "create",
591 "params": { "clientId": "different", "schema": simple_schema(), "dbPath": path_str }
592 }));
593 assert_eq!(mismatch["error"]["code"], "client.identity_mismatch");
594 }
595 let _ = std::fs::remove_file(&path);
596 }
597
598 #[test]
599 fn config_validation_rejects_baseurl_without_native_feature() {
600 let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
601 #[cfg(not(feature = "native-transport"))]
602 assert!(
603 result.is_err(),
604 "baseUrl must be refused without native-transport"
605 );
606 #[cfg(feature = "native-transport")]
607 assert!(result.is_ok(), "baseUrl builds with native-transport");
608 }
609}