Skip to main content

syncular_client/
api.rs

1//! Driver-facing API shapes (JSON-able), mirroring the conformance
2//! `ClientInstance` contract: sync reports, conflicts, rejections, row
3//! states, subscription states. Serialized as camelCase to cross the shim
4//! boundary unchanged.
5
6use serde::{Deserialize, Serialize};
7use serde_json::{Map, Value};
8
9/// §4.8 window base: one table, the scope variable whose values are the
10/// window units, any fixed scopes shared by every unit, and host-opaque
11/// `params` carried onto each unit's subscription.
12#[derive(Debug, Clone)]
13pub struct WindowBase {
14    pub table: String,
15    pub variable: String,
16    /// Scopes shared by every unit (other variables), if any.
17    pub fixed_scopes: Vec<(String, Vec<String>)>,
18    pub params: Option<String>,
19}
20
21/// §4.8 completeness oracle (I3): the windowed-in units for a base, plus
22/// the subset whose bootstrap has not yet completed. Registration alone is
23/// not completeness — a `pending` unit's local replica may be empty or
24/// partial (its subscription still has `cursor: -1` or holds a resume
25/// token), and MUST NOT be rendered as complete. A unit with zero server
26/// rows still completes once its bootstrap round finishes.
27#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
28#[serde(rename_all = "camelCase")]
29pub struct WindowState {
30    /// Windowed-in units for this base, ordered by value.
31    pub units: Vec<String>,
32    /// Registered units whose bootstrap has not yet completed.
33    pub pending: Vec<String>,
34}
35
36#[derive(Debug, Clone, Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct TableChange {
39    pub table: String,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub scope_keys: Option<Vec<String>>,
42}
43
44#[derive(Debug, Clone, Serialize)]
45#[serde(rename_all = "camelCase")]
46pub struct WindowChange {
47    pub base_key: String,
48    pub table: String,
49    pub units: Vec<String>,
50}
51
52#[derive(Debug, Clone, Serialize)]
53#[serde(rename_all = "camelCase")]
54pub struct SyncStatusSnapshot {
55    pub outbox: usize,
56    pub upgrading: bool,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub lease_state: Option<LeaseState>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub schema_floor: Option<SchemaFloor>,
61    pub sync_needed: bool,
62}
63
64/// JSON bindings carry the u64 revision as a decimal string (§7.5).
65#[derive(Debug, Clone, Serialize)]
66#[serde(rename_all = "camelCase")]
67pub struct ClientChangeBatch {
68    pub revision: String,
69    pub tables: Vec<TableChange>,
70    pub windows: Vec<WindowChange>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub status: Option<SyncStatusSnapshot>,
73    pub conflicts_changed: bool,
74    pub rejections_changed: bool,
75}
76
77#[derive(Debug, Clone, Serialize)]
78#[serde(tag = "kind", rename_all = "camelCase")]
79pub enum SyncIntent {
80    None,
81    Interactive,
82    Background {
83        #[serde(rename = "delayMs")]
84        delay_ms: u64,
85    },
86}
87
88#[derive(Debug, Clone, Serialize)]
89pub struct CommandEffects {
90    pub sync: SyncIntent,
91}
92
93impl CommandEffects {
94    #[must_use]
95    pub fn none() -> Self {
96        Self {
97            sync: SyncIntent::None,
98        }
99    }
100
101    #[must_use]
102    pub fn interactive() -> Self {
103        Self {
104            sync: SyncIntent::Interactive,
105        }
106    }
107}
108
109#[derive(Debug, Clone)]
110pub struct WindowCoverage {
111    pub base: WindowBase,
112    pub units: Vec<String>,
113}
114
115#[derive(Debug, Clone, Serialize)]
116#[serde(rename_all = "camelCase")]
117pub struct WindowUnitRef {
118    pub base_key: String,
119    pub unit: String,
120}
121
122#[derive(Debug, Clone, Serialize)]
123#[serde(rename_all = "camelCase")]
124pub struct CoverageSnapshot {
125    pub complete: bool,
126    pub pending: Vec<WindowUnitRef>,
127    pub missing: Vec<WindowUnitRef>,
128}
129
130#[derive(Debug, Clone, Serialize)]
131#[serde(rename_all = "camelCase")]
132pub struct QuerySnapshot {
133    pub revision: String,
134    pub rows: Vec<Map<String, Value>>,
135    pub coverage: CoverageSnapshot,
136}
137
138impl WindowState {
139    /// The per-unit verdict: registered AND bootstrap-complete.
140    #[must_use]
141    pub fn complete(&self, unit: &str) -> bool {
142        self.units.iter().any(|u| u == unit) && !self.pending.iter().any(|u| u == unit)
143    }
144}
145
146/// One local mutation (§6.1 shapes, schema-agnostic local form per §0).
147#[derive(Debug, Clone)]
148pub enum Mutation {
149    Upsert {
150        table: String,
151        values: Map<String, Value>,
152        base_version: Option<i64>,
153    },
154    Delete {
155        table: String,
156        row_id: String,
157        base_version: Option<i64>,
158    },
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
162#[serde(rename_all = "camelCase")]
163pub struct SchemaFloor {
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub required_schema_version: Option<i32>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub latest_schema_version: Option<i32>,
168}
169
170/// §7.3.5: the client's opaque auth-lease state — `leaseId`/`expiresAtMs`
171/// from the last `LEASE` frame, and `errorCode` once a round was rejected
172/// with a request-level lease code (stop-and-surface; no data purge).
173#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
174#[serde(rename_all = "camelCase")]
175pub struct LeaseState {
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub lease_id: Option<String>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub expires_at_ms: Option<i64>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub error_code: Option<String>,
182}
183
184/// §8.6 a peer's ephemeral presence document on a scope key.
185#[derive(Debug, Clone, Serialize)]
186#[serde(rename_all = "camelCase")]
187pub struct PresencePeer {
188    pub actor_id: String,
189    pub client_id: String,
190    pub doc: serde_json::Value,
191}
192
193#[derive(Debug, Clone, Serialize, Default)]
194#[serde(rename_all = "camelCase")]
195pub struct SyncReport {
196    pub pushed: u32,
197    pub applied: Vec<String>,
198    pub rejected: Vec<String>,
199    pub retryable: Vec<String>,
200    pub conflicts: u32,
201    pub commits_applied: u32,
202    pub segment_rows_applied: u32,
203    pub bootstrapping: Vec<String>,
204    pub resets: Vec<String>,
205    pub revoked: Vec<String>,
206    pub failed: Vec<String>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub schema_floor: Option<SchemaFloor>,
209}
210
211/// `sync()` never panics or errors out-of-band: transport and protocol
212/// failures come back as `Failed` (the driver's `{ ok: false }`).
213#[derive(Debug, Clone)]
214pub enum SyncOutcome {
215    Ok(SyncReport),
216    Failed { error_code: String, message: String },
217}
218
219impl SyncOutcome {
220    pub fn to_json(&self) -> Value {
221        match self {
222            SyncOutcome::Ok(report) => {
223                let mut map = Map::new();
224                map.insert("ok".to_owned(), Value::Bool(true));
225                map.insert(
226                    "report".to_owned(),
227                    serde_json::to_value(report).expect("report serializes"),
228                );
229                Value::Object(map)
230            }
231            SyncOutcome::Failed {
232                error_code,
233                message,
234            } => {
235                let mut map = Map::new();
236                map.insert("ok".to_owned(), Value::Bool(false));
237                map.insert("errorCode".to_owned(), Value::from(error_code.clone()));
238                map.insert("message".to_owned(), Value::from(message.clone()));
239                Value::Object(map)
240            }
241        }
242    }
243}
244
245#[derive(Debug, Clone, Serialize)]
246#[serde(rename_all = "camelCase")]
247pub struct ConflictRecord {
248    pub client_commit_id: String,
249    pub op_index: i32,
250    pub table: String,
251    pub row_id: String,
252    pub code: String,
253    pub server_version: i64,
254    /// Driver row (bytes as `{"$bytes": hex}`), decoded from the conflict
255    /// record's `serverRow` (§6.3).
256    pub server_row: Map<String, Value>,
257}
258
259#[derive(Debug, Clone, Serialize)]
260#[serde(rename_all = "camelCase")]
261pub struct RejectionRecord {
262    pub client_commit_id: String,
263    pub op_index: i32,
264    pub code: String,
265    pub retryable: bool,
266}
267
268#[derive(Debug, Clone, Serialize)]
269#[serde(rename_all = "camelCase")]
270pub struct RowState {
271    pub row_id: String,
272    /// Local synced version: `-1` = optimistic, else the server version
273    /// (from a `COMMIT` change or a segment row record, §5.2/§5.6).
274    pub version: i64,
275    pub values: Map<String, Value>,
276}
277
278#[derive(Debug, Clone, Serialize)]
279#[serde(rename_all = "camelCase")]
280pub struct SubscriptionStateView {
281    pub id: String,
282    pub table: String,
283    /// `active` | `revoked` | `failed`.
284    pub status: String,
285    pub cursor: i64,
286    pub has_resume_token: bool,
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub effective_scopes: Option<Value>,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub reason_code: Option<String>,
291}
292
293/// Client limits (§4.2 request knobs).
294#[derive(Debug, Clone, Default)]
295pub struct ClientLimits {
296    pub limit_commits: Option<i32>,
297    pub limit_snapshot_rows: Option<i32>,
298    pub max_snapshot_pages: Option<i32>,
299    /// §4.2 accept bitmask; this client defaults to `0b0111` (rows
300    /// baseline + sqlite images, §5.3 — rusqlite can always import).
301    pub accept: Option<u8>,
302    /// §5.9.7 B1 blob-cache size cap (bytes). When set and the sum of cached
303    /// body sizes exceeds it, zero-ref, non-pinned bodies are evicted LRU-first
304    /// after each cache write. `None` ⇒ retain until storage pressure (default).
305    pub blob_cache_max_bytes: Option<i64>,
306}