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