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 std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11/// §4.8 window base: one table, the scope variable whose values are the
12/// window units, any fixed scopes shared by every unit, and host-opaque
13/// `params` carried onto each unit's subscription.
14#[derive(Debug, Clone)]
15pub struct WindowBase {
16    pub table: String,
17    pub variable: String,
18    /// Scopes shared by every unit (other variables), if any.
19    pub fixed_scopes: Vec<(String, Vec<String>)>,
20    pub params: Option<String>,
21}
22
23/// §4.8 completeness oracle (I3): the windowed-in units for a base, plus
24/// the subset whose bootstrap has not yet completed. Registration alone is
25/// not completeness — a `pending` unit's local replica may be empty or
26/// partial (its subscription still has `cursor: -1` or holds a resume
27/// token), and MUST NOT be rendered as complete. A unit with zero server
28/// rows still completes once its bootstrap round finishes.
29#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
30#[serde(rename_all = "camelCase")]
31pub struct WindowState {
32    /// Windowed-in units for this base, ordered by value.
33    pub units: Vec<String>,
34    /// Registered units whose bootstrap has not yet completed.
35    pub pending: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39#[serde(rename_all = "camelCase")]
40pub struct TableChange {
41    pub table: String,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub scope_keys: Option<Vec<String>>,
44}
45
46#[derive(Debug, Clone, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct WindowChange {
49    pub base_key: String,
50    pub table: String,
51    pub units: Vec<String>,
52}
53
54#[derive(Debug, Clone, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct SyncStatusSnapshot {
57    pub outbox: usize,
58    pub upgrading: bool,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub lease_state: Option<LeaseState>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub schema_floor: Option<SchemaFloor>,
63    pub sync_needed: bool,
64}
65
66/// JSON bindings carry the u64 revision as a decimal string (§7.5).
67#[derive(Debug, Clone, Serialize)]
68#[serde(rename_all = "camelCase")]
69pub struct ClientChangeBatch {
70    pub revision: String,
71    pub tables: Vec<TableChange>,
72    pub windows: Vec<WindowChange>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub status: Option<SyncStatusSnapshot>,
75    pub conflicts_changed: bool,
76    pub rejections_changed: bool,
77    pub outcomes_changed: bool,
78}
79
80#[derive(Debug, Clone, Serialize)]
81#[serde(tag = "kind", rename_all = "camelCase")]
82pub enum SyncIntent {
83    None,
84    Interactive,
85    Background {
86        #[serde(rename = "delayMs")]
87        delay_ms: u64,
88    },
89}
90
91#[derive(Debug, Clone, Serialize)]
92pub struct CommandEffects {
93    pub sync: SyncIntent,
94}
95
96impl CommandEffects {
97    #[must_use]
98    pub fn none() -> Self {
99        Self {
100            sync: SyncIntent::None,
101        }
102    }
103
104    #[must_use]
105    pub fn interactive() -> Self {
106        Self {
107            sync: SyncIntent::Interactive,
108        }
109    }
110}
111
112#[derive(Debug, Clone)]
113pub struct WindowCoverage {
114    pub base: WindowBase,
115    pub units: Vec<String>,
116}
117
118#[derive(Debug, Clone, Serialize)]
119#[serde(rename_all = "camelCase")]
120pub struct WindowUnitRef {
121    pub base_key: String,
122    pub unit: String,
123}
124
125#[derive(Debug, Clone, Serialize)]
126#[serde(rename_all = "camelCase")]
127pub struct CoverageSnapshot {
128    pub complete: bool,
129    pub pending: Vec<WindowUnitRef>,
130    pub missing: Vec<WindowUnitRef>,
131}
132
133#[derive(Debug, Clone, Serialize)]
134#[serde(rename_all = "camelCase")]
135pub struct QuerySnapshot {
136    pub revision: String,
137    pub rows: Vec<Map<String, Value>>,
138    pub coverage: CoverageSnapshot,
139}
140
141impl WindowState {
142    /// The per-unit verdict: registered AND bootstrap-complete.
143    #[must_use]
144    pub fn complete(&self, unit: &str) -> bool {
145        self.units.iter().any(|u| u == unit) && !self.pending.iter().any(|u| u == unit)
146    }
147}
148
149/// One local mutation (§6.1 shapes, schema-agnostic local form per §0).
150#[derive(Debug, Clone)]
151pub enum Mutation {
152    Upsert {
153        table: String,
154        values: Map<String, Value>,
155        base_version: Option<i64>,
156    },
157    Delete {
158        table: String,
159        row_id: String,
160        base_version: Option<i64>,
161    },
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
165#[serde(rename_all = "camelCase")]
166pub struct SchemaFloor {
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub required_schema_version: Option<i32>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub latest_schema_version: Option<i32>,
171}
172
173/// §7.3.5: the client's opaque auth-lease state — `leaseId`/`expiresAtMs`
174/// from the last `LEASE` frame, and `errorCode` once a round was rejected
175/// with a request-level lease code (stop-and-surface; no data purge).
176#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
177#[serde(rename_all = "camelCase")]
178pub struct LeaseState {
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub lease_id: Option<String>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub expires_at_ms: Option<i64>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub error_code: Option<String>,
185}
186
187/// §8.6 a peer's ephemeral presence document on a scope key.
188#[derive(Debug, Clone, Serialize)]
189#[serde(rename_all = "camelCase")]
190pub struct PresencePeer {
191    pub actor_id: String,
192    pub client_id: String,
193    pub doc: serde_json::Value,
194}
195
196#[derive(Debug, Clone, Serialize, Default)]
197#[serde(rename_all = "camelCase")]
198pub struct SyncReport {
199    pub pushed: u32,
200    pub applied: Vec<String>,
201    pub rejected: Vec<String>,
202    pub retryable: Vec<String>,
203    pub conflicts: u32,
204    pub commits_applied: u32,
205    pub segment_rows_applied: u32,
206    pub bootstrapping: Vec<String>,
207    pub resets: Vec<String>,
208    pub revoked: Vec<String>,
209    pub failed: Vec<String>,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub schema_floor: Option<SchemaFloor>,
212}
213
214/// `sync()` never panics or errors out-of-band: transport and protocol
215/// failures come back as `Failed` (the driver's `{ ok: false }`).
216#[derive(Debug, Clone)]
217pub enum SyncOutcome {
218    Ok(SyncReport),
219    Failed { error_code: String, message: String },
220}
221
222impl SyncOutcome {
223    pub fn to_json(&self) -> Value {
224        match self {
225            SyncOutcome::Ok(report) => {
226                let mut map = Map::new();
227                map.insert("ok".to_owned(), Value::Bool(true));
228                map.insert(
229                    "report".to_owned(),
230                    serde_json::to_value(report).expect("report serializes"),
231                );
232                Value::Object(map)
233            }
234            SyncOutcome::Failed {
235                error_code,
236                message,
237            } => {
238                let mut map = Map::new();
239                map.insert("ok".to_owned(), Value::Bool(false));
240                map.insert("errorCode".to_owned(), Value::from(error_code.clone()));
241                map.insert("message".to_owned(), Value::from(message.clone()));
242                Value::Object(map)
243            }
244        }
245    }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
249#[serde(rename_all = "camelCase")]
250pub struct ConflictRecord {
251    pub client_commit_id: String,
252    pub op_index: i32,
253    pub table: String,
254    pub row_id: String,
255    pub code: String,
256    pub message: String,
257    pub server_version: i64,
258    /// Driver row (bytes as `{"$bytes": hex}`), decoded from the conflict
259    /// record's `serverRow` (§6.3).
260    pub server_row: Map<String, Value>,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub operation: Option<CommitOperation>,
263}
264
265/// Bounded code-like metadata explicitly declared safe for authorized client
266/// recovery UI. Diagnostic prose remains in `RejectionRecord.message`.
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
268#[serde(rename_all = "camelCase", deny_unknown_fields)]
269pub struct RejectionDetails {
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub field_paths: Option<Vec<String>>,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub reason: Option<String>,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub required_action: Option<String>,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub references: Option<BTreeMap<String, String>>,
278}
279
280impl RejectionDetails {
281    pub(crate) fn parse(raw: &str) -> Result<Self, String> {
282        if raw.len() > 4_096 {
283            return Err("rejection details exceed 4096 encoded bytes".to_owned());
284        }
285        let details: Self = serde_json::from_str(raw)
286            .map_err(|error| format!("invalid rejection details: {error}"))?;
287        details.validate()?;
288        Ok(details)
289    }
290
291    fn validate(&self) -> Result<(), String> {
292        let mut members = 0;
293        if let Some(paths) = &self.field_paths {
294            members += 1;
295            if paths.is_empty() || paths.len() > 32 {
296                return Err("fieldPaths must contain 1-32 paths".to_owned());
297            }
298            let mut seen = std::collections::BTreeSet::new();
299            for path in paths {
300                if path.len() > 160 || !path.split('.').all(valid_identifier) || !seen.insert(path)
301                {
302                    return Err("fieldPaths contains an invalid or duplicate path".to_owned());
303                }
304            }
305        }
306        if let Some(reason) = &self.reason {
307            members += 1;
308            if !valid_token(reason, 96) {
309                return Err("reason must be a lowercase stable token".to_owned());
310            }
311        }
312        if let Some(action) = &self.required_action {
313            members += 1;
314            if !valid_token(action, 96) {
315                return Err("requiredAction must be a lowercase stable token".to_owned());
316            }
317        }
318        if let Some(references) = &self.references {
319            members += 1;
320            if references.is_empty() || references.len() > 16 {
321                return Err("references must contain 1-16 entries".to_owned());
322            }
323            for (key, value) in references {
324                if !valid_token(key, 64)
325                    || value.is_empty()
326                    || value.len() > 256
327                    || value.trim() != value
328                    || value.chars().any(char::is_control)
329                {
330                    return Err("references contains an invalid key or value".to_owned());
331                }
332            }
333        }
334        if members == 0 {
335            return Err("rejection details must not be empty".to_owned());
336        }
337        Ok(())
338    }
339}
340
341fn valid_identifier(segment: &str) -> bool {
342    let mut chars = segment.chars();
343    matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic())
344        && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
345}
346
347fn valid_token(token: &str, max: usize) -> bool {
348    if token.is_empty() || token.len() > max || !token.starts_with(|c: char| c.is_ascii_lowercase())
349    {
350        return false;
351    }
352    let mut previous_separator = false;
353    for character in token.chars() {
354        if character.is_ascii_lowercase() || character.is_ascii_digit() {
355            previous_separator = false;
356        } else if matches!(character, '.' | '_' | '-') && !previous_separator {
357            previous_separator = true;
358        } else {
359            return false;
360        }
361    }
362    !previous_separator
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
366#[serde(rename_all = "camelCase")]
367pub struct RejectionRecord {
368    pub client_commit_id: String,
369    pub op_index: i32,
370    pub code: String,
371    pub message: String,
372    pub retryable: bool,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub details: Option<RejectionDetails>,
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub operation: Option<CommitOperation>,
377}
378
379/// Schema-agnostic local operation retained with a failed final outcome.
380#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
381#[serde(rename_all = "camelCase")]
382pub struct CommitOperation {
383    pub table: String,
384    pub row_id: String,
385    pub op: String,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub base_version: Option<i64>,
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub values: Option<Map<String, Value>>,
390    /// Normalized columns intentionally supplied to `patch()`; absent for a
391    /// full-row mutate/upsert because intent is unknown.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub changed_fields: Option<Vec<String>>,
394}
395
396#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
397#[serde(rename_all = "snake_case")]
398pub enum CommitOutcomeStatus {
399    Applied,
400    Cached,
401    Conflict,
402    Rejected,
403}
404
405#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
406#[serde(rename_all = "snake_case")]
407pub enum CommitOutcomeResolution {
408    Active,
409    ResolvedKeepServer,
410    Superseded,
411    Dismissed,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
415#[serde(tag = "status", rename_all = "snake_case")]
416pub enum CommitOperationOutcome {
417    Applied {
418        #[serde(rename = "opIndex")]
419        op_index: i32,
420    },
421    Conflict {
422        conflict: ConflictRecord,
423    },
424    Error {
425        rejection: RejectionRecord,
426    },
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
430#[serde(rename_all = "camelCase")]
431pub struct CommitOutcome {
432    pub sequence: i64,
433    pub client_commit_id: String,
434    pub status: CommitOutcomeStatus,
435    pub recorded_at_ms: i64,
436    pub results: Vec<CommitOperationOutcome>,
437    /// Complete local failed-commit envelope retained after outbox drain.
438    /// Absent for successful and historical outcomes; never sent over wire.
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub operations: Option<Vec<CommitOperation>>,
441    pub resolution: CommitOutcomeResolution,
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub resolved_at_ms: Option<i64>,
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub replacement_client_commit_id: Option<String>,
446}
447
448#[derive(Debug, Clone, Deserialize, Default)]
449#[serde(rename_all = "camelCase")]
450pub struct CommitOutcomeQuery {
451    pub limit: Option<usize>,
452    #[serde(default)]
453    pub active_only: bool,
454}
455
456#[derive(Debug, Clone, Deserialize)]
457#[serde(rename_all = "camelCase")]
458pub struct ResolveCommitOutcomeInput {
459    pub client_commit_id: String,
460    pub resolution: CommitOutcomeResolution,
461    pub replacement_client_commit_id: Option<String>,
462}
463
464#[derive(Debug, Clone, Serialize)]
465#[serde(rename_all = "camelCase")]
466pub struct RowState {
467    pub row_id: String,
468    /// Local synced version: `-1` = optimistic, else the server version
469    /// (from a `COMMIT` change or a segment row record, §5.2/§5.6).
470    pub version: i64,
471    pub values: Map<String, Value>,
472}
473
474#[derive(Debug, Clone, Serialize)]
475#[serde(rename_all = "camelCase")]
476pub struct SubscriptionStateView {
477    pub id: String,
478    pub table: String,
479    /// `active` | `revoked` | `failed`.
480    pub status: String,
481    pub cursor: i64,
482    pub has_resume_token: bool,
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub effective_scopes: Option<Value>,
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub reason_code: Option<String>,
487}
488
489/// Client limits (§4.2 request knobs).
490#[derive(Debug, Clone, Default)]
491pub struct ClientLimits {
492    pub limit_commits: Option<i32>,
493    pub limit_snapshot_rows: Option<i32>,
494    pub max_snapshot_pages: Option<i32>,
495    /// §4.2 accept bitmask; this client defaults to `0b0111` (rows
496    /// baseline + sqlite images, §5.3 — rusqlite can always import).
497    pub accept: Option<u8>,
498    /// §5.9.7 B1 blob-cache size cap (bytes). When set and the sum of cached
499    /// body sizes exceeds it, zero-ref, non-pinned bodies are evicted LRU-first
500    /// after each cache write. `None` ⇒ retain until storage pressure (default).
501    pub blob_cache_max_bytes: Option<i64>,
502    /// Maximum durable final outcomes. Active conflicts/rejections are never
503    /// pruned to satisfy the cap. Defaults to 1,000.
504    pub outcome_retention_max_entries: Option<usize>,
505}