syncular/lib.rs
1//! # syncular-ffi — the Syncular v2 Rust client core as a C-ABI native library
2//!
3//! The POC client crate, packaged for shipping. Five C functions expose the
4//! whole client over a JSON command surface — the v1-proven bindings shape,
5//! and the SAME `syncular-command` router the conformance shim locks:
6//!
7//! ```c
8//! void* syncular_client_new(const char* config_json);
9//! char* syncular_client_command(void* handle, const char* command_json);
10//! char* syncular_client_poll_event(void* handle, int64_t timeout_ms);
11//! void syncular_client_close(void* handle);
12//! void syncular_free_string(char* ptr);
13//! ```
14//!
15//! `command_json` is `{"method": "...", "params": {...}}` — every method the
16//! shim speaks (create/subscribe/mutate/sync/syncUntilIdle/readRows/…). The
17//! reply is `{"result": ...}` or `{"error": {"code", "message"}}`. Bytes ride
18//! as `{"$bytes": "<hex>"}`, unchanged from the driver protocol, so a
19//! JSI/TurboModule bridge marshals plain JSON.
20//!
21//! ## Transport ownership (native vs. inverted)
22//!
23//! The conformance shim inverts transport to the harness (the host holds the
24//! sync/segment/realtime endpoints). A native app cannot do that — there is
25//! no host loop to call back into. So under the `native-transport` feature
26//! this crate OWNS a real HTTP + WS transport (see [`transport`]); the config
27//! carries a `baseUrl` and the core drives the network itself. Without the
28//! feature (the dependency-lean default), network commands fail loudly with
29//! `transport.unavailable` and only client-local commands run — which is all
30//! the C smoke test and pure-logic tests need.
31//!
32//! ## Events (lean queue over a callback-free core)
33//!
34//! The client core exposes no callbacks; it surfaces observable state
35//! (`sync_needed`, presence peers, conflicts, schema floor, lease errors).
36//! After each command — and after draining inbound realtime traffic — the FFI
37//! diffs that state and enqueues client-observable events
38//! (`sync-needed` / `conflict` / `presence` / `schema-floor`, the
39//! invalidation-equivalent set). `poll_event` drains them. This keeps the core
40//! callback-free (portable, testable) while giving native hosts a push-shaped
41//! signal.
42
43use std::collections::VecDeque;
44use std::ffi::{c_char, CStr, CString};
45use std::os::raw::c_longlong;
46use std::sync::{Arc, Condvar, Mutex};
47
48use serde_json::{json, Value};
49use syncular_client::SyncClient;
50use syncular_command::{dispatch, CreateEffects};
51
52pub mod transport;
53
54use transport::HostTransport;
55
56/// One client-observable event (§8 realtime signals + §6 conflicts + §1.6
57/// schema floor + §7.3 lease). JSON-able; delivered by `poll_event`.
58#[derive(Debug, Clone)]
59struct Event {
60 json: Value,
61}
62
63/// The opaque handle behind the `void*`. One `SyncClient` instance, its owned
64/// transport, and the derived event queue, guarded for cross-thread use (the
65/// native WS reader thread pushes into `queue`).
66pub struct Handle {
67 client: Option<SyncClient>,
68 transport: HostTransport,
69 effects: CreateEffects,
70 /// Snapshot of observable state after the last drain, for diffing.
71 last: ObservedState,
72 queue: Arc<EventQueue>,
73}
74
75#[derive(Debug, Default, Clone)]
76struct ObservedState {
77 sync_needed: bool,
78 conflicts: usize,
79 rejections: usize,
80 schema_floor: Option<Value>,
81 lease_error: Option<String>,
82}
83
84/// A bounded, blocking event queue: `poll_event` waits up to `timeout_ms` for
85/// the next event. The native WS reader thread and the command path both push.
86pub(crate) struct EventQueue {
87 inner: Mutex<VecDeque<Event>>,
88 ready: Condvar,
89}
90
91impl EventQueue {
92 fn new() -> Self {
93 EventQueue {
94 inner: Mutex::new(VecDeque::new()),
95 ready: Condvar::new(),
96 }
97 }
98
99 fn push(&self, event: Event) {
100 let mut guard = self.inner.lock().expect("event queue lock");
101 guard.push_back(event);
102 self.ready.notify_one();
103 }
104
105 /// Pop the next event, waiting up to `timeout_ms` (< 0 = block until one
106 /// arrives, 0 = non-blocking). Returns `None` on timeout.
107 fn pop(&self, timeout_ms: i64) -> Option<Event> {
108 let mut guard = self.inner.lock().expect("event queue lock");
109 if let Some(event) = guard.pop_front() {
110 return Some(event);
111 }
112 if timeout_ms == 0 {
113 return None;
114 }
115 if timeout_ms < 0 {
116 loop {
117 guard = self.ready.wait(guard).expect("event queue wait");
118 if let Some(event) = guard.pop_front() {
119 return Some(event);
120 }
121 }
122 }
123 let dur = std::time::Duration::from_millis(timeout_ms as u64);
124 let (mut guard2, _timeout) = self
125 .ready
126 .wait_timeout(guard, dur)
127 .expect("event queue wait_timeout");
128 guard2.pop_front()
129 }
130}
131
132impl Handle {
133 fn new(config: &Value) -> Result<Self, String> {
134 let queue = Arc::new(EventQueue::new());
135 let transport = HostTransport::from_config(config, Arc::clone(&queue))?;
136 Ok(Handle {
137 client: None,
138 transport,
139 effects: CreateEffects::default(),
140 last: ObservedState::default(),
141 queue,
142 })
143 }
144
145 /// Run one JSON command through the shared router, then drain inbound
146 /// realtime traffic and derive events.
147 fn command(&mut self, command: &Value) -> Value {
148 let method = command.get("method").and_then(Value::as_str).unwrap_or("");
149 let params = command.get("params").cloned().unwrap_or(Value::Null);
150 let result = dispatch(
151 &mut self.transport,
152 &mut self.client,
153 &mut self.effects,
154 method,
155 ¶ms,
156 );
157 if method == "create" {
158 self.transport.set_signed_urls(self.effects.signed_urls);
159 }
160 // Deliver any realtime traffic the owned transport buffered, then
161 // diff observable state into events.
162 self.drain_realtime();
163 self.derive_events();
164 match result {
165 Ok(value) => json!({ "result": value }),
166 Err((code, message)) => json!({ "error": { "code": code, "message": message } }),
167 }
168 }
169
170 /// Feed buffered inbound WS frames to the client (which may send acks back
171 /// through the same transport). A no-op without a native socket.
172 fn drain_realtime(&mut self) {
173 let Some(client) = self.client.as_mut() else {
174 return;
175 };
176 for frame in self.transport.take_inbound() {
177 match frame {
178 transport::Inbound::Text(text) => {
179 // A presence fanout is the client-observable event a native
180 // host wants pushed; detect it on the wire (the core has no
181 // presence callback) before handing the control frame off.
182 if is_presence_control(&text) {
183 self.queue.push(Event {
184 json: json!({ "type": "presence" }),
185 });
186 }
187 client.on_realtime_text(&text);
188 }
189 transport::Inbound::Binary(bytes) => {
190 client.on_realtime_binary(&mut self.transport, &bytes)
191 }
192 }
193 }
194 }
195
196 /// Diff the client's observable state against the last snapshot and
197 /// enqueue an event per change (the invalidation-equivalent set).
198 fn derive_events(&mut self) {
199 let Some(client) = self.client.as_ref() else {
200 return;
201 };
202 let now = ObservedState {
203 sync_needed: client.sync_needed(),
204 conflicts: client.conflicts().len(),
205 rejections: client.rejections().len(),
206 schema_floor: client
207 .schema_floor()
208 .map(|f| serde_json::to_value(f).unwrap_or(Value::Null)),
209 lease_error: client.lease_state().and_then(|l| l.error_code.clone()),
210 };
211 if now.sync_needed && !self.last.sync_needed {
212 self.queue.push(Event {
213 json: json!({ "type": "sync-needed" }),
214 });
215 }
216 if now.conflicts > self.last.conflicts {
217 self.queue.push(Event {
218 json: json!({ "type": "conflict", "count": now.conflicts }),
219 });
220 }
221 if now.rejections > self.last.rejections {
222 self.queue.push(Event {
223 json: json!({ "type": "rejection", "count": now.rejections }),
224 });
225 }
226 if now.schema_floor != self.last.schema_floor {
227 if let Some(floor) = &now.schema_floor {
228 self.queue.push(Event {
229 json: json!({ "type": "schema-floor", "floor": floor }),
230 });
231 }
232 }
233 if now.lease_error != self.last.lease_error {
234 if let Some(code) = &now.lease_error {
235 self.queue.push(Event {
236 json: json!({ "type": "lease", "errorCode": code }),
237 });
238 }
239 }
240 self.last = now;
241 }
242}
243
244/// A presence fanout control frame (§8.6.2) — `{"event":"presence",...}`, the
245/// one inbound realtime event a native host surfaces as an event.
246fn is_presence_control(text: &str) -> bool {
247 serde_json::from_str::<Value>(text)
248 .ok()
249 .and_then(|v| {
250 v.get("event")
251 .and_then(Value::as_str)
252 .map(|e| e == "presence")
253 })
254 .unwrap_or(false)
255}
256
257// -- C-ABI surface (the 5 functions kept exactly in sync with rust/ffi.h) ----
258
259/// Turn a Rust string into a heap `char*` the caller frees with
260/// `syncular_free_string`. Never returns null for a valid string.
261fn into_c_string(value: String) -> *mut c_char {
262 match CString::new(value) {
263 Ok(s) => s.into_raw(),
264 // A NUL byte cannot occur in our JSON output, but never panic across
265 // the FFI boundary: fall back to an empty string.
266 Err(_) => CString::new("").expect("empty CString").into_raw(),
267 }
268}
269
270fn c_str_to_value(ptr: *const c_char) -> Result<Value, String> {
271 if ptr.is_null() {
272 return Err("null pointer".to_owned());
273 }
274 // Safety: the caller contract is a valid NUL-terminated C string.
275 let bytes = unsafe { CStr::from_ptr(ptr) };
276 let text = bytes
277 .to_str()
278 .map_err(|_| "config is not UTF-8".to_owned())?;
279 serde_json::from_str(text).map_err(|e| format!("config is not JSON: {e}"))
280}
281
282/// Create a client core. `config_json` is a JSON object: `{}` for the
283/// dependency-lean default (client-local commands only); with the
284/// `native-transport` feature it carries `{"baseUrl": "...", ...}` for the
285/// owned HTTP+WS transport. Returns an opaque handle, or null on a malformed
286/// config.
287///
288/// # Safety
289/// `config_json` must be a valid NUL-terminated UTF-8 C string (or null).
290#[no_mangle]
291pub extern "C" fn syncular_client_new(config_json: *const c_char) -> *mut Handle {
292 let config = match c_str_to_value(config_json) {
293 Ok(value) => value,
294 Err(_) => return std::ptr::null_mut(),
295 };
296 match Handle::new(&config) {
297 Ok(handle) => Box::into_raw(Box::new(handle)),
298 Err(_) => std::ptr::null_mut(),
299 }
300}
301
302/// Run one JSON command (`{"method","params"}`) against the client. Returns a
303/// freshly-allocated `{"result"|"error"}` JSON string the caller frees with
304/// `syncular_free_string`. Returns null only on a null handle.
305///
306/// # Safety
307/// `handle` must be a live handle from `syncular_client_new`; `command_json`
308/// a valid NUL-terminated UTF-8 C string.
309#[allow(clippy::not_unsafe_ptr_arg_deref)]
310#[no_mangle]
311pub extern "C" fn syncular_client_command(
312 handle: *mut Handle,
313 command_json: *const c_char,
314) -> *mut c_char {
315 if handle.is_null() {
316 return std::ptr::null_mut();
317 }
318 // Safety: non-null per the contract; we do not free it here.
319 let handle = unsafe { &mut *handle };
320 let command = match c_str_to_value(command_json) {
321 Ok(value) => value,
322 Err(message) => {
323 return into_c_string(
324 json!({ "error": { "code": "client.failed", "message": message } }).to_string(),
325 )
326 }
327 };
328 let reply = handle.command(&command);
329 into_c_string(reply.to_string())
330}
331
332/// Poll the next client-observable event, waiting up to `timeout_ms`
333/// (negative = block until one arrives, 0 = non-blocking). Returns a
334/// freshly-allocated event JSON string, or null if none arrived in time.
335///
336/// # Safety
337/// `handle` must be a live handle from `syncular_client_new`.
338#[allow(clippy::not_unsafe_ptr_arg_deref)]
339#[no_mangle]
340pub extern "C" fn syncular_client_poll_event(
341 handle: *mut Handle,
342 timeout_ms: c_longlong,
343) -> *mut c_char {
344 if handle.is_null() {
345 return std::ptr::null_mut();
346 }
347 // Safety: non-null per the contract.
348 let handle = unsafe { &*handle };
349 match handle.queue.pop(timeout_ms) {
350 Some(event) => into_c_string(event.json.to_string()),
351 None => std::ptr::null_mut(),
352 }
353}
354
355/// Close a client core, releasing its database, transport, and socket thread.
356/// The handle is invalid after this call.
357///
358/// # Safety
359/// `handle` must be a live handle from `syncular_client_new`, closed once.
360#[allow(clippy::not_unsafe_ptr_arg_deref)]
361#[no_mangle]
362pub extern "C" fn syncular_client_close(handle: *mut Handle) {
363 if handle.is_null() {
364 return;
365 }
366 // Safety: reclaim the Box; dropping shuts down the transport/socket.
367 let mut handle = unsafe { Box::from_raw(handle) };
368 handle.transport.shutdown();
369 drop(handle);
370}
371
372/// Free a string returned by `syncular_client_command` /
373/// `syncular_client_poll_event`.
374///
375/// # Safety
376/// `ptr` must be a string from one of those functions, freed once.
377#[allow(clippy::not_unsafe_ptr_arg_deref)]
378#[no_mangle]
379pub extern "C" fn syncular_free_string(ptr: *mut c_char) {
380 if ptr.is_null() {
381 return;
382 }
383 // Safety: reclaim the CString allocation to drop it.
384 unsafe {
385 let _ = CString::from_raw(ptr);
386 }
387}
388
389#[cfg(test)]
390mod tests;
391
392#[cfg(test)]
393mod round_tests;