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