Skip to main content

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 exact core output)
33//!
34//! The client core exposes no callbacks. Each observer transaction instead
35//! commits an exact revisioned change batch, and commands which create network
36//! work emit an explicit sync intent. After every command — and after draining
37//! inbound realtime traffic — the FFI forwards those outputs verbatim as
38//! `change` and `sync-intent` events. `poll_event` drains them. Native hosts do
39//! not infer changes by diffing counters or method names.
40
41use std::collections::VecDeque;
42use std::ffi::{c_char, CStr, CString};
43use std::os::raw::c_longlong;
44use std::sync::{Arc, Condvar, Mutex};
45
46use serde_json::{json, Value};
47use syncular_client::SyncClient;
48use syncular_command::{dispatch, CreateEffects};
49
50pub mod transport;
51
52use transport::HostTransport;
53
54/// One client-observable event (§8 realtime signals + §6 conflicts + §1.6
55/// schema floor + §7.3 lease). JSON-able; delivered by `poll_event`.
56#[derive(Debug, Clone)]
57struct Event {
58    json: Value,
59}
60
61/// The opaque handle behind the `void*`. One `SyncClient` instance, its owned
62/// transport, and the exact core-output queue, guarded for cross-thread polling.
63pub struct Handle {
64    client: Option<SyncClient>,
65    transport: HostTransport,
66    effects: CreateEffects,
67    queue: Arc<EventQueue>,
68}
69
70/// A bounded, blocking event queue: `poll_event` waits up to `timeout_ms` for
71/// the next event. The native WS reader thread and the command path both push.
72pub(crate) struct EventQueue {
73    inner: Mutex<VecDeque<Event>>,
74    ready: Condvar,
75}
76
77impl EventQueue {
78    fn new() -> Self {
79        EventQueue {
80            inner: Mutex::new(VecDeque::new()),
81            ready: Condvar::new(),
82        }
83    }
84
85    fn push(&self, event: Event) {
86        let mut guard = self.inner.lock().expect("event queue lock");
87        guard.push_back(event);
88        self.ready.notify_one();
89    }
90
91    /// Pop the next event, waiting up to `timeout_ms` (< 0 = block until one
92    /// arrives, 0 = non-blocking). Returns `None` on timeout.
93    fn pop(&self, timeout_ms: i64) -> Option<Event> {
94        let mut guard = self.inner.lock().expect("event queue lock");
95        if let Some(event) = guard.pop_front() {
96            return Some(event);
97        }
98        if timeout_ms == 0 {
99            return None;
100        }
101        if timeout_ms < 0 {
102            loop {
103                guard = self.ready.wait(guard).expect("event queue wait");
104                if let Some(event) = guard.pop_front() {
105                    return Some(event);
106                }
107            }
108        }
109        let dur = std::time::Duration::from_millis(timeout_ms as u64);
110        let (mut guard2, _timeout) = self
111            .ready
112            .wait_timeout(guard, dur)
113            .expect("event queue wait_timeout");
114        guard2.pop_front()
115    }
116}
117
118impl Handle {
119    fn new(config: &Value) -> Result<Self, String> {
120        let queue = Arc::new(EventQueue::new());
121        let transport = HostTransport::from_config(config)?;
122        Ok(Handle {
123            client: None,
124            transport,
125            effects: CreateEffects::default(),
126            queue,
127        })
128    }
129
130    /// Run one JSON command through the shared router, then drain inbound
131    /// realtime traffic and forward exact core outputs.
132    fn command(&mut self, command: &Value) -> Value {
133        let method = command.get("method").and_then(Value::as_str).unwrap_or("");
134        let params = command.get("params").cloned().unwrap_or(Value::Null);
135        let result = dispatch(
136            &mut self.transport,
137            &mut self.client,
138            &mut self.effects,
139            method,
140            &params,
141        );
142        if method == "create" {
143            self.transport.set_signed_urls(self.effects.signed_urls);
144        }
145        // Command-local work is an explicit router effect (mutation and
146        // window changes); realtime/retry work is drained from the core queue
147        // below. Forward both sources without inferring from the method name.
148        if let Ok(value) = &result {
149            if let Some(intent) = value.pointer("/effects/sync") {
150                if intent.get("kind").and_then(Value::as_str) != Some("none") {
151                    self.queue.push(Event {
152                        json: json!({ "type": "sync-intent", "intent": intent }),
153                    });
154                }
155            }
156        }
157        // Deliver any realtime traffic the owned transport buffered, then
158        // forward the core's committed observation output.
159        self.drain_realtime();
160        self.drain_core_outputs();
161        match result {
162            Ok(value) => json!({ "result": value }),
163            Err((code, message)) => json!({ "error": { "code": code, "message": message } }),
164        }
165    }
166
167    /// Feed buffered inbound WS frames to the client (which may send acks back
168    /// through the same transport). A no-op without a native socket.
169    fn drain_realtime(&mut self) {
170        let Some(client) = self.client.as_mut() else {
171            return;
172        };
173        for frame in self.transport.take_inbound() {
174            match frame {
175                transport::Inbound::Text(text) => {
176                    // A presence fanout is the client-observable event a native
177                    // host wants pushed; detect it on the wire (the core has no
178                    // presence callback) before handing the control frame off.
179                    if is_presence_control(&text) {
180                        self.queue.push(Event {
181                            json: json!({ "type": "presence" }),
182                        });
183                    }
184                    client.on_realtime_text(&text);
185                }
186                transport::Inbound::Binary(bytes) => {
187                    client.on_realtime_binary(&mut self.transport, &bytes)
188                }
189            }
190        }
191    }
192
193    /// Forward exact observer batches and sync intents produced by the Rust
194    /// core. The FFI is a manual-sync host; consumers may schedule an intent
195    /// on their own event loop without polling core state.
196    fn drain_core_outputs(&mut self) {
197        let Some(client) = self.client.as_mut() else {
198            return;
199        };
200        for batch in client.drain_change_batches() {
201            self.queue.push(Event {
202                json: json!({ "type": "change", "batch": batch }),
203            });
204        }
205        for intent in client.drain_sync_intents() {
206            self.queue.push(Event {
207                json: json!({ "type": "sync-intent", "intent": intent }),
208            });
209        }
210    }
211}
212
213/// A presence fanout control frame (§8.6.2) — `{"event":"presence",...}`, the
214/// one inbound realtime event a native host surfaces as an event.
215fn is_presence_control(text: &str) -> bool {
216    serde_json::from_str::<Value>(text)
217        .ok()
218        .and_then(|v| {
219            v.get("event")
220                .and_then(Value::as_str)
221                .map(|e| e == "presence")
222        })
223        .unwrap_or(false)
224}
225
226// -- C-ABI surface (the 5 functions kept exactly in sync with rust/ffi.h) ----
227
228/// Turn a Rust string into a heap `char*` the caller frees with
229/// `syncular_free_string`. Never returns null for a valid string.
230fn into_c_string(value: String) -> *mut c_char {
231    match CString::new(value) {
232        Ok(s) => s.into_raw(),
233        // A NUL byte cannot occur in our JSON output, but never panic across
234        // the FFI boundary: fall back to an empty string.
235        Err(_) => CString::new("").expect("empty CString").into_raw(),
236    }
237}
238
239fn c_str_to_value(ptr: *const c_char) -> Result<Value, String> {
240    if ptr.is_null() {
241        return Err("null pointer".to_owned());
242    }
243    // Safety: the caller contract is a valid NUL-terminated C string.
244    let bytes = unsafe { CStr::from_ptr(ptr) };
245    let text = bytes
246        .to_str()
247        .map_err(|_| "config is not UTF-8".to_owned())?;
248    serde_json::from_str(text).map_err(|e| format!("config is not JSON: {e}"))
249}
250
251/// Create a client core. `config_json` is a JSON object: `{}` for the
252/// dependency-lean default (client-local commands only); with the
253/// `native-transport` feature it carries `{"baseUrl": "...", ...}` for the
254/// owned HTTP+WS transport. Returns an opaque handle, or null on a malformed
255/// config.
256///
257/// # Safety
258/// `config_json` must be a valid NUL-terminated UTF-8 C string (or null).
259#[no_mangle]
260pub extern "C" fn syncular_client_new(config_json: *const c_char) -> *mut Handle {
261    let config = match c_str_to_value(config_json) {
262        Ok(value) => value,
263        Err(_) => return std::ptr::null_mut(),
264    };
265    match Handle::new(&config) {
266        Ok(handle) => Box::into_raw(Box::new(handle)),
267        Err(_) => std::ptr::null_mut(),
268    }
269}
270
271/// Run one JSON command (`{"method","params"}`) against the client. Returns a
272/// freshly-allocated `{"result"|"error"}` JSON string the caller frees with
273/// `syncular_free_string`. Returns null only on a null handle.
274///
275/// # Safety
276/// `handle` must be a live handle from `syncular_client_new`; `command_json`
277/// a valid NUL-terminated UTF-8 C string.
278#[allow(clippy::not_unsafe_ptr_arg_deref)]
279#[no_mangle]
280pub extern "C" fn syncular_client_command(
281    handle: *mut Handle,
282    command_json: *const c_char,
283) -> *mut c_char {
284    if handle.is_null() {
285        return std::ptr::null_mut();
286    }
287    // Safety: non-null per the contract; we do not free it here.
288    let handle = unsafe { &mut *handle };
289    let command = match c_str_to_value(command_json) {
290        Ok(value) => value,
291        Err(message) => {
292            return into_c_string(
293                json!({ "error": { "code": "client.failed", "message": message } }).to_string(),
294            )
295        }
296    };
297    let reply = handle.command(&command);
298    into_c_string(reply.to_string())
299}
300
301/// Poll the next client-observable event, waiting up to `timeout_ms`
302/// (negative = block until one arrives, 0 = non-blocking). Returns a
303/// freshly-allocated event JSON string, or null if none arrived in time.
304///
305/// # Safety
306/// `handle` must be a live handle from `syncular_client_new`.
307#[allow(clippy::not_unsafe_ptr_arg_deref)]
308#[no_mangle]
309pub extern "C" fn syncular_client_poll_event(
310    handle: *mut Handle,
311    timeout_ms: c_longlong,
312) -> *mut c_char {
313    if handle.is_null() {
314        return std::ptr::null_mut();
315    }
316    // Safety: non-null per the contract.
317    let handle = unsafe { &*handle };
318    match handle.queue.pop(timeout_ms) {
319        Some(event) => into_c_string(event.json.to_string()),
320        None => std::ptr::null_mut(),
321    }
322}
323
324/// Close a client core, releasing its database, transport, and socket thread.
325/// The handle is invalid after this call.
326///
327/// # Safety
328/// `handle` must be a live handle from `syncular_client_new`, closed once.
329#[allow(clippy::not_unsafe_ptr_arg_deref)]
330#[no_mangle]
331pub extern "C" fn syncular_client_close(handle: *mut Handle) {
332    if handle.is_null() {
333        return;
334    }
335    // Safety: reclaim the Box; dropping shuts down the transport/socket.
336    let mut handle = unsafe { Box::from_raw(handle) };
337    handle.transport.shutdown();
338    drop(handle);
339}
340
341/// Free a string returned by `syncular_client_command` /
342/// `syncular_client_poll_event`.
343///
344/// # Safety
345/// `ptr` must be a string from one of those functions, freed once.
346#[allow(clippy::not_unsafe_ptr_arg_deref)]
347#[no_mangle]
348pub extern "C" fn syncular_free_string(ptr: *mut c_char) {
349    if ptr.is_null() {
350        return;
351    }
352    // Safety: reclaim the CString allocation to drop it.
353    unsafe {
354        let _ = CString::from_raw(ptr);
355    }
356}
357
358#[cfg(test)]
359mod tests;
360
361#[cfg(test)]
362mod round_tests;