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 interactive_sync: bool,
52 background_sync_ms: Option<u64>,
53}
54
55impl SyncularCore {
56 pub fn new(config: &Value) -> Result<Self, String> {
60 Self::new_with_notify(config, None)
61 }
62
63 pub fn new_with_notify(
64 config: &Value,
65 notify: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
66 ) -> Result<Self, String> {
67 let transport = HostTransport::from_config_with_notify(config, notify)?;
68 Ok(SyncularCore {
69 client: None,
70 transport,
71 effects: CreateEffects::default(),
72 queue: VecDeque::new(),
73 last_diagnostics_fingerprint: None,
74 interactive_sync: false,
75 background_sync_ms: None,
76 })
77 }
78
79 pub fn command(&mut self, command: &Value) -> Value {
83 let method = command.get("method").and_then(Value::as_str).unwrap_or("");
84 let params = command.get("params").cloned().unwrap_or(Value::Null);
85 let result = dispatch(
86 &mut self.transport,
87 &mut self.client,
88 &mut self.effects,
89 method,
90 ¶ms,
91 );
92 if method == "create" {
93 self.last_diagnostics_fingerprint = None;
94 self.transport.set_signed_urls(self.effects.signed_urls);
95 }
96 if method == "beginSecurityPreflight"
97 || method == "shutdown"
98 || (method == "create"
99 && params
100 .get("securityPreflight")
101 .and_then(Value::as_bool)
102 .unwrap_or(false))
103 {
104 self.interactive_sync = false;
105 self.background_sync_ms = None;
106 }
107 if let Ok(value) = &result {
108 if value.pointer("/effects/sync/kind").and_then(Value::as_str) == Some("interactive") {
109 self.interactive_sync = true;
110 }
111 }
112 self.drain_realtime();
113 self.drain_core_outputs();
114 self.emit_diagnostics_if_changed();
115 match result {
116 Ok(mut value) => {
117 if let Some(object) = value.as_object_mut() {
120 object.remove("effects");
121 }
122 json!({ "result": value })
123 }
124 Err((code, message)) => json!({ "error": { "code": code, "message": message } }),
125 }
126 }
127
128 pub fn query(&mut self, sql: &str, params: Value) -> Value {
133 let bind = match params {
134 Value::Null => Value::Array(Vec::new()),
135 other => other,
136 };
137 self.command(&json!({ "method": "query", "params": { "sql": sql, "params": bind } }))
138 }
139
140 pub fn take_sync_intent(&mut self) -> SyncIntent {
143 if std::mem::take(&mut self.interactive_sync) {
144 self.background_sync_ms = None;
145 SyncIntent::Interactive
146 } else if let Some(delay_ms) = self.background_sync_ms.take() {
147 SyncIntent::Background { delay_ms }
148 } else {
149 SyncIntent::None
150 }
151 }
152
153 pub fn sync_until_idle(&mut self) -> Value {
156 if self.client.is_none() {
157 return json!({ "result": null });
158 }
159 self.command(&json!({ "method": "syncUntilIdle", "params": {} }))
160 }
161
162 pub fn poll_transport(&mut self) {
164 self.drain_realtime();
165 self.drain_core_outputs();
166 self.emit_diagnostics_if_changed();
167 }
168
169 pub fn drain_events(&mut self) -> Vec<Event> {
172 self.queue.drain(..).collect()
173 }
174
175 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
179 self.transport.set_headers(headers);
180 }
181
182 pub fn shutdown(&mut self) {
184 if let Some(mut client) = self.client.take() {
185 client.disconnect_realtime(&mut self.transport);
186 client.begin_security_preflight();
187 }
188 self.interactive_sync = false;
189 self.background_sync_ms = None;
190 self.transport.shutdown();
191 }
192
193 fn push(&mut self, json: Value) {
194 self.queue.push_back(Event { json });
195 }
196
197 fn drain_realtime(&mut self) {
200 if self.client.is_none() {
201 return;
202 }
203 let frames = self.transport.take_inbound();
204 for frame in frames {
205 match frame {
206 transport::Inbound::Text(text) => {
207 if is_presence_control(&text) {
208 self.push(json!({ "type": "presence" }));
209 }
210 if let Some(client) = self.client.as_mut() {
211 client.on_realtime_text(&text);
212 }
213 }
214 transport::Inbound::Binary(bytes) => {
215 if let Some(client) = self.client.as_mut() {
216 client.on_realtime_binary(&mut self.transport, &bytes);
217 }
218 }
219 }
220 }
221 }
222
223 fn drain_core_outputs(&mut self) {
225 let Some(client) = self.client.as_mut() else {
226 return;
227 };
228 let batches = client.drain_change_batches();
229 let intents = client.drain_sync_intents();
230 for batch in batches {
231 self.push(json!({ "type": "change", "batch": batch }));
232 }
233 for intent in intents {
234 match intent {
235 SyncIntent::Interactive => self.interactive_sync = true,
236 SyncIntent::Background { delay_ms } => {
237 self.background_sync_ms = Some(
238 self.background_sync_ms
239 .map_or(delay_ms, |current| current.min(delay_ms)),
240 );
241 }
242 SyncIntent::None => {}
243 }
244 }
245 }
246
247 fn emit_diagnostics_if_changed(&mut self) {
252 let Some(client) = self.client.as_ref() else {
253 return;
254 };
255 if client.security_preflight() {
256 return;
257 }
258 let Ok(snapshot) = client.diagnostics_snapshot(&ClientDiagnosticsRequest::default()) else {
259 return;
260 };
261 let Ok(mut fingerprint) = serde_json::to_value(&snapshot) else {
262 return;
263 };
264 if let Some(object) = fingerprint.as_object_mut() {
265 object.remove("capturedAtMs");
266 }
267 if self.last_diagnostics_fingerprint.as_ref() == Some(&fingerprint) {
268 return;
269 }
270 self.last_diagnostics_fingerprint = Some(fingerprint);
271 self.push(json!({ "type": "diagnostics", "snapshot": snapshot }));
272 }
273}
274
275fn is_presence_control(text: &str) -> bool {
278 serde_json::from_str::<Value>(text)
279 .ok()
280 .and_then(|v| {
281 v.get("event")
282 .and_then(Value::as_str)
283 .map(|e| e == "presence")
284 })
285 .unwrap_or(false)
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn simple_schema() -> Value {
293 json!({
294 "version": 1,
295 "tables": [{
296 "name": "todo",
297 "primaryKey": "id",
298 "columns": [
299 { "name": "id", "type": "string", "nullable": false },
300 { "name": "title", "type": "string", "nullable": false },
301 { "name": "done", "type": "boolean", "nullable": false }
302 ],
303 "scopes": []
304 }]
305 })
306 }
307
308 fn create(core: &mut SyncularCore) {
309 let reply = core.command(&json!({
310 "method": "create",
311 "params": { "clientId": "c1", "schema": simple_schema() }
312 }));
313 assert_eq!(reply["result"], json!({}), "create ok: {reply}");
314 }
315
316 #[test]
317 fn command_round_trip_create_mutate_query() {
318 let mut core = SyncularCore::new(&json!({})).unwrap();
319 create(&mut core);
320
321 let sub = core.command(&json!({
322 "method": "subscribe",
323 "params": { "id": "s1", "table": "todo", "scopes": {} }
324 }));
325 assert_eq!(sub["result"], json!({}));
326
327 let mutate = core.command(&json!({
328 "method": "mutate",
329 "params": { "mutations": [{
330 "op": "upsert", "table": "todo",
331 "values": { "id": "t1", "title": "hello", "done": false }
332 }] }
333 }));
334 assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
335
336 let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
338 let list = rows["result"]["rows"].as_array().expect("rows");
339 assert_eq!(list.len(), 1);
340 assert_eq!(list[0]["title"], "hello");
341 assert_eq!(list[0]["id"], "t1");
342 }
343
344 #[test]
345 fn query_binds_params() {
346 let mut core = SyncularCore::new(&json!({})).unwrap();
347 create(&mut core);
348 core.command(&json!({
349 "method": "mutate",
350 "params": { "mutations": [
351 { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
352 { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
353 ] }
354 }));
355 let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
356 let list = rows["result"]["rows"].as_array().expect("rows");
357 assert_eq!(list.len(), 1);
358 assert_eq!(list[0]["id"], "b");
359 }
360
361 #[test]
362 fn events_derived_after_mutate() {
363 let mut core = SyncularCore::new(&json!({})).unwrap();
364 create(&mut core);
366 let _ = core.drain_events();
367 core.command(&json!({
368 "method": "mutate",
369 "params": { "mutations": [{
370 "op": "upsert", "table": "todo",
371 "values": { "id": "t1", "title": "x", "done": false }
372 }] }
373 }));
374 let events = core.drain_events();
375 let kinds: Vec<&str> = events
378 .iter()
379 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
380 .collect();
381 assert!(kinds.contains(&"change"), "kinds: {kinds:?}");
382 assert!(kinds.contains(&"diagnostics"), "kinds: {kinds:?}");
383 let change = events
384 .iter()
385 .find(|event| event.json["type"] == "change")
386 .expect("change event");
387 assert_eq!(change.json["batch"]["revision"], "1");
388 assert_eq!(change.json["batch"]["tables"][0]["table"], "todo");
389 assert_eq!(change.json["batch"]["status"]["outbox"], 1);
390 assert_eq!(change.json["batch"]["status"]["syncNeeded"], false);
393 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
394 assert!(core.drain_events().is_empty());
396 }
397
398 #[test]
399 fn diagnostics_are_versioned_bounded_and_payload_free() {
400 let mut core = SyncularCore::new(&json!({})).unwrap();
401 create(&mut core);
402 let reply = core.command(&json!({
403 "method": "diagnosticsSnapshot",
404 "params": {
405 "expectedSubscriptions": [{ "id": "membership", "table": "todo" }]
406 }
407 }));
408 assert_eq!(reply["result"]["version"], 1);
409 assert_eq!(reply["result"]["subscriptions"][0]["state"], "unregistered");
410 let encoded = reply.to_string();
411 assert!(!encoded.contains("clientId"));
412 assert!(!encoded.contains("dbPath"));
413 assert!(!encoded.contains("operations"));
414 }
415
416 #[test]
417 fn sync_without_native_transport_fails_loud() {
418 let mut core = SyncularCore::new(&json!({})).unwrap();
419 create(&mut core);
420 let outcome = core.command(&json!({ "method": "sync", "params": {} }));
421 assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
422 assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
423 }
424
425 #[test]
426 fn file_db_persists_across_reopen() {
427 let dir = std::env::temp_dir();
428 let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
429 let path_str = path.to_string_lossy().to_string();
430 let _ = std::fs::remove_file(&path);
431
432 {
433 let mut core = SyncularCore::new(&json!({})).unwrap();
434 let reply = core.command(&json!({
435 "method": "create",
436 "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
437 }));
438 assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
439 core.command(&json!({
440 "method": "mutate",
441 "params": { "mutations": [{
442 "op": "upsert", "table": "todo",
443 "values": { "id": "persisted", "title": "kept", "done": false }
444 }] }
445 }));
446 let revision = core.command(&json!({
447 "method": "localRevision", "params": {}
448 }));
449 assert_eq!(revision["result"]["revision"], "1");
450 }
451 {
454 let mut core = SyncularCore::new(&json!({})).unwrap();
455 let reopened = core.command(&json!({
456 "method": "create",
457 "params": { "schema": simple_schema(), "dbPath": path_str }
458 }));
459 assert_eq!(reopened["result"], json!({}), "reopen: {reopened}");
460 let rows = core.query("SELECT title FROM todo", Value::Null);
461 let list = rows["result"]["rows"].as_array().expect("rows");
462 assert_eq!(list.len(), 1, "reopened db: {rows}");
463 assert_eq!(list[0]["title"], "kept");
464 let revision = core.command(&json!({
465 "method": "localRevision", "params": {}
466 }));
467 assert_eq!(revision["result"]["revision"], "1");
468 let pending = core.command(&json!({
469 "method": "pendingCommitIds", "params": {}
470 }));
471 assert_eq!(pending["result"]["ids"].as_array().map(Vec::len), Some(1));
472 let status = core.command(&json!({
473 "method": "statusSnapshot", "params": {}
474 }));
475 assert_eq!(status["result"]["outbox"], 1);
476 assert_eq!(status["result"]["syncNeeded"], true);
477 assert!(matches!(core.take_sync_intent(), SyncIntent::Interactive));
478 }
479 {
480 let mut core = SyncularCore::new(&json!({})).unwrap();
481 let mismatch = core.command(&json!({
482 "method": "create",
483 "params": { "clientId": "different", "schema": simple_schema(), "dbPath": path_str }
484 }));
485 assert_eq!(mismatch["error"]["code"], "client.identity_mismatch");
486 }
487 let _ = std::fs::remove_file(&path);
488 }
489
490 #[test]
491 fn config_validation_rejects_baseurl_without_native_feature() {
492 let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
493 #[cfg(not(feature = "native-transport"))]
494 assert!(
495 result.is_err(),
496 "baseUrl must be refused without native-transport"
497 );
498 #[cfg(feature = "native-transport")]
499 assert!(result.is_ok(), "baseUrl builds with native-transport");
500 }
501}