1use std::collections::VecDeque;
27
28use serde_json::{json, Value};
29use syncular_client::SyncClient;
30use syncular_command::{dispatch, CreateEffects};
31
32use crate::transport::{self, HostTransport};
33
34#[derive(Debug, Clone)]
38pub struct Event {
39 pub json: Value,
40}
41
42#[derive(Debug, Default, Clone)]
45struct ObservedState {
46 sync_needed: bool,
47 conflicts: usize,
48 rejections: usize,
49 pending_commits: usize,
50 schema_floor: Option<Value>,
51 lease_error: Option<String>,
52}
53
54pub struct SyncularCore {
57 client: Option<SyncClient>,
58 transport: HostTransport,
59 effects: CreateEffects,
60 last: ObservedState,
61 queue: VecDeque<Event>,
62}
63
64impl SyncularCore {
65 pub fn new(config: &Value) -> Result<Self, String> {
69 let transport = HostTransport::from_config(config)?;
70 Ok(SyncularCore {
71 client: None,
72 transport,
73 effects: CreateEffects::default(),
74 last: ObservedState::default(),
75 queue: VecDeque::new(),
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.transport.set_signed_urls(self.effects.signed_urls);
94 }
95 self.drain_realtime();
96 self.derive_events(method);
97 match result {
98 Ok(value) => json!({ "result": value }),
99 Err((code, message)) => json!({ "error": { "code": code, "message": message } }),
100 }
101 }
102
103 pub fn query(&mut self, sql: &str, params: Value) -> Value {
108 let bind = match params {
109 Value::Null => Value::Array(Vec::new()),
110 other => other,
111 };
112 self.command(&json!({ "method": "query", "params": { "sql": sql, "params": bind } }))
113 }
114
115 pub fn sync_needed(&self) -> bool {
118 self.client
119 .as_ref()
120 .map(SyncClient::sync_needed)
121 .unwrap_or(false)
122 }
123
124 pub fn sync_until_idle(&mut self) -> Value {
127 if self.client.is_none() {
128 return json!({ "result": null });
129 }
130 self.command(&json!({ "method": "syncUntilIdle", "params": {} }))
131 }
132
133 pub fn drain_events(&mut self) -> Vec<Event> {
136 self.queue.drain(..).collect()
137 }
138
139 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
143 self.transport.set_headers(headers);
144 }
145
146 pub fn shutdown(&mut self) {
148 self.transport.shutdown();
149 }
150
151 fn push(&mut self, json: Value) {
152 self.queue.push_back(Event { json });
153 }
154
155 fn drain_realtime(&mut self) {
158 if self.client.is_none() {
159 return;
160 }
161 let frames = self.transport.take_inbound();
162 for frame in frames {
163 match frame {
164 transport::Inbound::Text(text) => {
165 if is_presence_control(&text) {
166 self.push(json!({ "type": "presence" }));
167 }
168 if let Some(client) = self.client.as_mut() {
169 client.on_realtime_text(&text);
170 }
171 }
172 transport::Inbound::Binary(bytes) => {
173 if let Some(client) = self.client.as_mut() {
174 client.on_realtime_binary(&mut self.transport, &bytes);
175 }
176 }
177 }
178 }
179 }
180
181 fn derive_events(&mut self, method: &str) {
192 let Some(client) = self.client.as_ref() else {
193 return;
194 };
195 let now = ObservedState {
196 sync_needed: client.sync_needed(),
197 conflicts: client.conflicts().len(),
198 rejections: client.rejections().len(),
199 pending_commits: client.pending_commit_ids().len(),
200 schema_floor: client
201 .schema_floor()
202 .map(|f| serde_json::to_value(f).unwrap_or(Value::Null)),
203 lease_error: client.lease_state().and_then(|l| l.error_code.clone()),
204 };
205 let mut pending: Vec<Value> = Vec::new();
206 if now.sync_needed && !self.last.sync_needed {
207 pending.push(json!({ "type": "sync-needed" }));
208 }
209 if now.conflicts > self.last.conflicts {
210 pending.push(json!({ "type": "conflict", "count": now.conflicts }));
211 }
212 if now.rejections > self.last.rejections {
213 pending.push(json!({ "type": "rejection", "count": now.rejections }));
214 }
215 if now.schema_floor != self.last.schema_floor {
216 if let Some(floor) = &now.schema_floor {
217 pending.push(json!({ "type": "schema-floor", "floor": floor }));
218 }
219 }
220 if now.lease_error != self.last.lease_error {
221 if let Some(code) = &now.lease_error {
222 pending.push(json!({ "type": "lease", "errorCode": code }));
223 }
224 }
225 let data_changed = method == "mutate"
230 || now.pending_commits != self.last.pending_commits
231 || now.conflicts != self.last.conflicts;
232 if data_changed {
233 pending.push(json!({ "type": "invalidate" }));
234 }
235 self.last = now;
236 for event in pending {
237 self.push(event);
238 }
239 }
240}
241
242fn is_presence_control(text: &str) -> bool {
245 serde_json::from_str::<Value>(text)
246 .ok()
247 .and_then(|v| {
248 v.get("event")
249 .and_then(Value::as_str)
250 .map(|e| e == "presence")
251 })
252 .unwrap_or(false)
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 fn simple_schema() -> Value {
260 json!({
261 "version": 1,
262 "tables": [{
263 "name": "todo",
264 "primaryKey": "id",
265 "columns": [
266 { "name": "id", "type": "string", "nullable": false },
267 { "name": "title", "type": "string", "nullable": false },
268 { "name": "done", "type": "boolean", "nullable": false }
269 ],
270 "scopes": []
271 }]
272 })
273 }
274
275 fn create(core: &mut SyncularCore) {
276 let reply = core.command(&json!({
277 "method": "create",
278 "params": { "clientId": "c1", "schema": simple_schema() }
279 }));
280 assert_eq!(reply["result"], json!({}), "create ok: {reply}");
281 }
282
283 #[test]
284 fn command_round_trip_create_mutate_query() {
285 let mut core = SyncularCore::new(&json!({})).unwrap();
286 create(&mut core);
287
288 let sub = core.command(&json!({
289 "method": "subscribe",
290 "params": { "id": "s1", "table": "todo", "scopes": {} }
291 }));
292 assert_eq!(sub["result"], json!({}));
293
294 let mutate = core.command(&json!({
295 "method": "mutate",
296 "params": { "mutations": [{
297 "op": "upsert", "table": "todo",
298 "values": { "id": "t1", "title": "hello", "done": false }
299 }] }
300 }));
301 assert!(mutate["result"]["clientCommitId"].is_string(), "{mutate}");
302
303 let rows = core.query("SELECT id, title FROM todo ORDER BY id", Value::Null);
305 let list = rows["result"]["rows"].as_array().expect("rows");
306 assert_eq!(list.len(), 1);
307 assert_eq!(list[0]["title"], "hello");
308 assert_eq!(list[0]["id"], "t1");
309 }
310
311 #[test]
312 fn query_binds_params() {
313 let mut core = SyncularCore::new(&json!({})).unwrap();
314 create(&mut core);
315 core.command(&json!({
316 "method": "mutate",
317 "params": { "mutations": [
318 { "op": "upsert", "table": "todo", "values": { "id": "a", "title": "A", "done": false } },
319 { "op": "upsert", "table": "todo", "values": { "id": "b", "title": "B", "done": true } }
320 ] }
321 }));
322 let rows = core.query("SELECT id FROM todo WHERE done = ?", json!([true]));
323 let list = rows["result"]["rows"].as_array().expect("rows");
324 assert_eq!(list.len(), 1);
325 assert_eq!(list[0]["id"], "b");
326 }
327
328 #[test]
329 fn events_derived_after_mutate() {
330 let mut core = SyncularCore::new(&json!({})).unwrap();
331 create(&mut core);
333 let _ = core.drain_events();
334 core.command(&json!({
335 "method": "mutate",
336 "params": { "mutations": [{
337 "op": "upsert", "table": "todo",
338 "values": { "id": "t1", "title": "x", "done": false }
339 }] }
340 }));
341 let events = core.drain_events();
342 let kinds: Vec<&str> = events
346 .iter()
347 .filter_map(|e| e.json.get("type").and_then(Value::as_str))
348 .collect();
349 assert!(kinds.contains(&"invalidate"), "kinds: {kinds:?}");
350 assert!(core.drain_events().is_empty());
352 }
353
354 #[test]
355 fn sync_without_native_transport_fails_loud() {
356 let mut core = SyncularCore::new(&json!({})).unwrap();
357 create(&mut core);
358 let outcome = core.command(&json!({ "method": "sync", "params": {} }));
359 assert_eq!(outcome["result"]["ok"], json!(false), "{outcome}");
360 assert_eq!(outcome["result"]["errorCode"], "transport.unavailable");
361 }
362
363 #[test]
364 fn file_db_persists_across_reopen() {
365 let dir = std::env::temp_dir();
366 let path = dir.join(format!("syncular-tauri-test-{}.db", std::process::id()));
367 let path_str = path.to_string_lossy().to_string();
368 let _ = std::fs::remove_file(&path);
369
370 {
371 let mut core = SyncularCore::new(&json!({})).unwrap();
372 let reply = core.command(&json!({
373 "method": "create",
374 "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
375 }));
376 assert_eq!(reply["result"], json!({}), "create with dbPath: {reply}");
377 core.command(&json!({
378 "method": "mutate",
379 "params": { "mutations": [{
380 "op": "upsert", "table": "todo",
381 "values": { "id": "persisted", "title": "kept", "done": false }
382 }] }
383 }));
384 }
385 {
387 let mut core = SyncularCore::new(&json!({})).unwrap();
388 core.command(&json!({
389 "method": "create",
390 "params": { "clientId": "c1", "schema": simple_schema(), "dbPath": path_str }
391 }));
392 let rows = core.query("SELECT title FROM todo", Value::Null);
393 let list = rows["result"]["rows"].as_array().expect("rows");
394 assert_eq!(list.len(), 1, "reopened db: {rows}");
395 assert_eq!(list[0]["title"], "kept");
396 }
397 let _ = std::fs::remove_file(&path);
398 }
399
400 #[test]
401 fn config_validation_rejects_baseurl_without_native_feature() {
402 let result = SyncularCore::new(&json!({ "baseUrl": "http://localhost:9/sync" }));
403 #[cfg(not(feature = "native-transport"))]
404 assert!(
405 result.is_err(),
406 "baseUrl must be refused without native-transport"
407 );
408 #[cfg(feature = "native-transport")]
409 assert!(result.is_ok(), "baseUrl builds with native-transport");
410 }
411}