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    pub outcomes_changed: bool,
76}
77
78#[derive(Debug, Clone, Serialize)]
79#[serde(tag = "kind", rename_all = "camelCase")]
80pub enum SyncIntent {
81    None,
82    Interactive,
83    Background {
84        #[serde(rename = "delayMs")]
85        delay_ms: u64,
86    },
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct CommandEffects {
91    pub sync: SyncIntent,
92}
93
94impl CommandEffects {
95    #[must_use]
96    pub fn none() -> Self {
97        Self {
98            sync: SyncIntent::None,
99        }
100    }
101
102    #[must_use]
103    pub fn interactive() -> Self {
104        Self {
105            sync: SyncIntent::Interactive,
106        }
107    }
108}
109
110#[derive(Debug, Clone)]
111pub struct WindowCoverage {
112    pub base: WindowBase,
113    pub units: Vec<String>,
114}
115
116#[derive(Debug, Clone, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub struct WindowUnitRef {
119    pub base_key: String,
120    pub unit: String,
121}
122
123#[derive(Debug, Clone, Serialize)]
124#[serde(rename_all = "camelCase")]
125pub struct CoverageSnapshot {
126    pub complete: bool,
127    pub pending: Vec<WindowUnitRef>,
128    pub missing: Vec<WindowUnitRef>,
129}
130
131#[derive(Debug, Clone, Serialize)]
132#[serde(rename_all = "camelCase")]
133pub struct QuerySnapshot {
134    pub revision: String,
135    pub rows: Vec<Map<String, Value>>,
136    pub coverage: CoverageSnapshot,
137}
138
139impl WindowState {
140    /// The per-unit verdict: registered AND bootstrap-complete.
141    #[must_use]
142    pub fn complete(&self, unit: &str) -> bool {
143        self.units.iter().any(|u| u == unit) && !self.pending.iter().any(|u| u == unit)
144    }
145}
146
147/// One local mutation (§6.1 shapes, schema-agnostic local form per §0).
148#[derive(Debug, Clone)]
149pub enum Mutation {
150    Upsert {
151        table: String,
152        values: Map<String, Value>,
153        base_version: Option<i64>,
154    },
155    Delete {
156        table: String,
157        row_id: String,
158        base_version: Option<i64>,
159    },
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
163#[serde(rename_all = "camelCase")]
164pub struct SchemaFloor {
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub required_schema_version: Option<i32>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub latest_schema_version: Option<i32>,
169}
170
171/// §7.3.5: the client's opaque auth-lease state — `leaseId`/`expiresAtMs`
172/// from the last `LEASE` frame, and `errorCode` once a round was rejected
173/// with a request-level lease code (stop-and-surface; no data purge).
174#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
175#[serde(rename_all = "camelCase")]
176pub struct LeaseState {
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub lease_id: Option<String>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub expires_at_ms: Option<i64>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub error_code: Option<String>,
183}
184
185/// §8.6 a peer's ephemeral presence document on a scope key.
186#[derive(Debug, Clone, Serialize)]
187#[serde(rename_all = "camelCase")]
188pub struct PresencePeer {
189    pub actor_id: String,
190    pub client_id: String,
191    pub doc: serde_json::Value,
192}
193
194#[derive(Debug, Clone, Serialize, Default)]
195#[serde(rename_all = "camelCase")]
196pub struct SyncReport {
197    pub pushed: u32,
198    pub applied: Vec<String>,
199    pub rejected: Vec<String>,
200    pub retryable: Vec<String>,
201    pub conflicts: u32,
202    pub commits_applied: u32,
203    pub segment_rows_applied: u32,
204    pub bootstrapping: Vec<String>,
205    pub resets: Vec<String>,
206    pub revoked: Vec<String>,
207    pub failed: Vec<String>,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub schema_floor: Option<SchemaFloor>,
210}
211
212/// `sync()` never panics or errors out-of-band: transport and protocol
213/// failures come back as `Failed` (the driver's `{ ok: false }`).
214#[derive(Debug, Clone)]
215pub enum SyncOutcome {
216    Ok(SyncReport),
217    Failed { error_code: String, message: String },
218}
219
220impl SyncOutcome {
221    pub fn to_json(&self) -> Value {
222        match self {
223            SyncOutcome::Ok(report) => {
224                let mut map = Map::new();
225                map.insert("ok".to_owned(), Value::Bool(true));
226                map.insert(
227                    "report".to_owned(),
228                    serde_json::to_value(report).expect("report serializes"),
229                );
230                Value::Object(map)
231            }
232            SyncOutcome::Failed {
233                error_code,
234                message,
235            } => {
236                let mut map = Map::new();
237                map.insert("ok".to_owned(), Value::Bool(false));
238                map.insert("errorCode".to_owned(), Value::from(error_code.clone()));
239                map.insert("message".to_owned(), Value::from(message.clone()));
240                Value::Object(map)
241            }
242        }
243    }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
247#[serde(rename_all = "camelCase")]
248pub struct ConflictRecord {
249    pub client_commit_id: String,
250    pub op_index: i32,
251    pub table: String,
252    pub row_id: String,
253    pub code: String,
254    pub message: String,
255    pub server_version: i64,
256    /// Driver row (bytes as `{"$bytes": hex}`), decoded from the conflict
257    /// record's `serverRow` (§6.3).
258    pub server_row: Map<String, Value>,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub operation: Option<CommitOperation>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
264#[serde(rename_all = "camelCase")]
265pub struct RejectionRecord {
266    pub client_commit_id: String,
267    pub op_index: i32,
268    pub code: String,
269    pub message: String,
270    pub retryable: bool,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub operation: Option<CommitOperation>,
273}
274
275/// Schema-agnostic local operation retained with a failed final outcome.
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
277#[serde(rename_all = "camelCase")]
278pub struct CommitOperation {
279    pub table: String,
280    pub row_id: String,
281    pub op: String,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub base_version: Option<i64>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub values: Option<Map<String, Value>>,
286}
287
288#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
289#[serde(rename_all = "snake_case")]
290pub enum CommitOutcomeStatus {
291    Applied,
292    Cached,
293    Conflict,
294    Rejected,
295}
296
297#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
298#[serde(rename_all = "snake_case")]
299pub enum CommitOutcomeResolution {
300    Active,
301    ResolvedKeepServer,
302    Superseded,
303    Dismissed,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
307#[serde(tag = "status", rename_all = "snake_case")]
308pub enum CommitOperationOutcome {
309    Applied {
310        #[serde(rename = "opIndex")]
311        op_index: i32,
312    },
313    Conflict {
314        conflict: ConflictRecord,
315    },
316    Error {
317        rejection: RejectionRecord,
318    },
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
322#[serde(rename_all = "camelCase")]
323pub struct CommitOutcome {
324    pub sequence: i64,
325    pub client_commit_id: String,
326    pub status: CommitOutcomeStatus,
327    pub recorded_at_ms: i64,
328    pub results: Vec<CommitOperationOutcome>,
329    pub resolution: CommitOutcomeResolution,
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub resolved_at_ms: Option<i64>,
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub replacement_client_commit_id: Option<String>,
334}
335
336#[derive(Debug, Clone, Deserialize, Default)]
337#[serde(rename_all = "camelCase")]
338pub struct CommitOutcomeQuery {
339    pub limit: Option<usize>,
340    #[serde(default)]
341    pub active_only: bool,
342}
343
344#[derive(Debug, Clone, Deserialize)]
345#[serde(rename_all = "camelCase")]
346pub struct ResolveCommitOutcomeInput {
347    pub client_commit_id: String,
348    pub resolution: CommitOutcomeResolution,
349    pub replacement_client_commit_id: Option<String>,
350}
351
352#[derive(Debug, Clone, Serialize)]
353#[serde(rename_all = "camelCase")]
354pub struct RowState {
355    pub row_id: String,
356    /// Local synced version: `-1` = optimistic, else the server version
357    /// (from a `COMMIT` change or a segment row record, §5.2/§5.6).
358    pub version: i64,
359    pub values: Map<String, Value>,
360}
361
362#[derive(Debug, Clone, Serialize)]
363#[serde(rename_all = "camelCase")]
364pub struct SubscriptionStateView {
365    pub id: String,
366    pub table: String,
367    /// `active` | `revoked` | `failed`.
368    pub status: String,
369    pub cursor: i64,
370    pub has_resume_token: bool,
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub effective_scopes: Option<Value>,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub reason_code: Option<String>,
375}
376
377/// Client limits (§4.2 request knobs).
378#[derive(Debug, Clone, Default)]
379pub struct ClientLimits {
380    pub limit_commits: Option<i32>,
381    pub limit_snapshot_rows: Option<i32>,
382    pub max_snapshot_pages: Option<i32>,
383    /// §4.2 accept bitmask; this client defaults to `0b0111` (rows
384    /// baseline + sqlite images, §5.3 — rusqlite can always import).
385    pub accept: Option<u8>,
386    /// §5.9.7 B1 blob-cache size cap (bytes). When set and the sum of cached
387    /// body sizes exceeds it, zero-ref, non-pinned bodies are evicted LRU-first
388    /// after each cache write. `None` ⇒ retain until storage pressure (default).
389    pub blob_cache_max_bytes: Option<i64>,
390    /// Maximum durable final outcomes. Active conflicts/rejections are never
391    /// pruned to satisfy the cap. Defaults to 1,000.
392    pub outcome_retention_max_entries: Option<usize>,
393}