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
72pub const CLIENT_DIAGNOSTICS_VERSION: u8 = 1;
73pub const MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS: usize = 256;
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct ExpectedDiagnosticSubscription {
78    pub id: String,
79    pub table: String,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct ClientDiagnosticsRequest {
85    #[serde(default)]
86    pub expected_subscriptions: Vec<ExpectedDiagnosticSubscription>,
87}
88
89#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
90#[serde(rename_all = "camelCase")]
91pub struct ClientDiagnosticsHost {
92    pub kind: String,
93    pub role: String,
94    pub connectivity: String,
95    pub realtime: String,
96}
97
98#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
99#[serde(rename_all = "camelCase")]
100pub struct DiagnosticSubscription {
101    pub id: String,
102    pub table: String,
103    pub state: String,
104    pub complete: bool,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub cursor: Option<i64>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub reason_code: Option<String>,
109}
110
111#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
112#[serde(rename_all = "camelCase")]
113pub struct DiagnosticRoundCounters {
114    pub pushed: u32,
115    pub applied: usize,
116    pub rejected: usize,
117    pub retryable: usize,
118    pub conflicts: u32,
119    pub commits_applied: u32,
120    pub segment_rows_applied: u32,
121    pub bootstrapping: usize,
122    pub resets: usize,
123    pub revoked: usize,
124    pub failed: usize,
125    pub deferred_commits: usize,
126}
127
128#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
129#[serde(rename_all = "camelCase")]
130pub struct DiagnosticLastRound {
131    pub status: String,
132    pub started_at_ms: i64,
133    pub completed_at_ms: i64,
134    pub duration_ms: i64,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub counters: Option<DiagnosticRoundCounters>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub error_code: Option<String>,
139}
140
141#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
142#[serde(rename_all = "camelCase")]
143pub struct DiagnosticLastChange {
144    pub revision: String,
145    pub recorded_at_ms: i64,
146    pub tables: Vec<String>,
147    pub windows: Vec<String>,
148    pub domains_truncated: bool,
149    pub status_changed: bool,
150    pub conflicts_changed: bool,
151    pub rejections_changed: bool,
152    pub outcomes_changed: bool,
153}
154
155#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
156#[serde(rename_all = "camelCase")]
157pub struct ClientDiagnosticsStorage {
158    pub status: String,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub database_bytes_approx: Option<i64>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub pending_outbox_bytes_approx: Option<i64>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub retained_outcome_bytes_approx: Option<i64>,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub retained_outcome_entries: Option<i64>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub blob_cache_bytes_approx: Option<i64>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub pressure_reason_code: Option<String>,
171}
172
173#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
174#[serde(rename_all = "camelCase")]
175pub struct ClientDiagnosticsSnapshot {
176    pub version: u8,
177    pub captured_at_ms: i64,
178    pub host: ClientDiagnosticsHost,
179    pub security_lifecycle: String,
180    pub schema: ClientDiagnosticsSchema,
181    pub replica: ClientDiagnosticsReplica,
182    pub lease: ClientDiagnosticsLease,
183    pub subscriptions: Vec<DiagnosticSubscription>,
184    pub subscriptions_truncated: bool,
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub last_round: Option<DiagnosticLastRound>,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub last_change: Option<DiagnosticLastChange>,
189    pub storage: ClientDiagnosticsStorage,
190}
191
192#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
193#[serde(rename_all = "camelCase")]
194pub struct ClientDiagnosticsSchema {
195    pub current_version: i32,
196    pub upgrading: bool,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub required_version: Option<i32>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub latest_version: Option<i32>,
201}
202
203#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
204#[serde(rename_all = "camelCase")]
205pub struct ClientDiagnosticsReplica {
206    pub local_revision: String,
207    pub sync_needed: bool,
208    pub pending_outbox: usize,
209}
210
211#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
212#[serde(rename_all = "camelCase")]
213pub struct ClientDiagnosticsLease {
214    pub state: String,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub expires_at_ms: Option<i64>,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub error_code: Option<String>,
219}
220
221/// JSON bindings carry the u64 revision as a decimal string (§7.5).
222#[derive(Debug, Clone, Serialize)]
223#[serde(rename_all = "camelCase")]
224pub struct ClientChangeBatch {
225    pub revision: String,
226    pub tables: Vec<TableChange>,
227    pub windows: Vec<WindowChange>,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub status: Option<SyncStatusSnapshot>,
230    pub conflicts_changed: bool,
231    pub rejections_changed: bool,
232    pub outcomes_changed: bool,
233}
234
235#[derive(Debug, Clone, Serialize)]
236#[serde(tag = "kind", rename_all = "camelCase")]
237pub enum SyncIntent {
238    None,
239    Interactive,
240    Background {
241        #[serde(rename = "delayMs")]
242        delay_ms: u64,
243    },
244}
245
246#[derive(Debug, Clone, Serialize)]
247pub struct CommandEffects {
248    pub sync: SyncIntent,
249}
250
251impl CommandEffects {
252    #[must_use]
253    pub fn none() -> Self {
254        Self {
255            sync: SyncIntent::None,
256        }
257    }
258
259    #[must_use]
260    pub fn interactive() -> Self {
261        Self {
262            sync: SyncIntent::Interactive,
263        }
264    }
265}
266
267#[derive(Debug, Clone)]
268pub struct WindowCoverage {
269    pub base: WindowBase,
270    pub units: Vec<String>,
271}
272
273#[derive(Debug, Clone, Serialize)]
274#[serde(rename_all = "camelCase")]
275pub struct WindowUnitRef {
276    pub base_key: String,
277    pub unit: String,
278}
279
280#[derive(Debug, Clone, Serialize)]
281#[serde(rename_all = "camelCase")]
282pub struct CoverageSnapshot {
283    pub complete: bool,
284    pub pending: Vec<WindowUnitRef>,
285    pub missing: Vec<WindowUnitRef>,
286}
287
288#[derive(Debug, Clone, Serialize)]
289#[serde(rename_all = "camelCase")]
290pub struct QuerySnapshot {
291    pub revision: String,
292    pub rows: Vec<QueryRow>,
293    pub coverage: CoverageSnapshot,
294}
295
296impl WindowState {
297    /// The per-unit verdict: registered AND bootstrap-complete.
298    #[must_use]
299    pub fn complete(&self, unit: &str) -> bool {
300        self.units.iter().any(|u| u == unit) && !self.pending.iter().any(|u| u == unit)
301    }
302}
303
304/// One local mutation (§6.1 shapes, schema-agnostic local form per §0).
305#[derive(Debug, Clone)]
306pub enum Mutation {
307    Upsert {
308        table: String,
309        values: Map<String, Value>,
310        base_version: Option<i64>,
311    },
312    Delete {
313        table: String,
314        row_id: String,
315        base_version: Option<i64>,
316    },
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
320#[serde(rename_all = "camelCase")]
321pub struct SchemaFloor {
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub required_schema_version: Option<i32>,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub latest_schema_version: Option<i32>,
326}
327
328/// §7.3.5: the client's opaque auth-lease state — `leaseId`/`expiresAtMs`
329/// from the last `LEASE` frame, and `errorCode` once a round was rejected
330/// with a request-level lease code (stop-and-surface; no data purge).
331#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
332#[serde(rename_all = "camelCase")]
333pub struct LeaseState {
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub lease_id: Option<String>,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub expires_at_ms: Option<i64>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub error_code: Option<String>,
340}
341
342/// §8.6 a peer's ephemeral presence document on a scope key.
343#[derive(Debug, Clone, Serialize)]
344#[serde(rename_all = "camelCase")]
345pub struct PresencePeer {
346    pub actor_id: String,
347    pub client_id: String,
348    pub doc: serde_json::Value,
349}
350
351#[derive(Debug, Clone, Serialize, Default)]
352#[serde(rename_all = "camelCase")]
353pub struct SyncReport {
354    pub pushed: u32,
355    pub applied: Vec<String>,
356    pub rejected: Vec<String>,
357    pub retryable: Vec<String>,
358    pub conflicts: u32,
359    pub commits_applied: u32,
360    pub segment_rows_applied: u32,
361    pub bootstrapping: Vec<String>,
362    pub resets: Vec<String>,
363    pub revoked: Vec<String>,
364    pub failed: Vec<String>,
365    pub deferred_commits: usize,
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub schema_floor: Option<SchemaFloor>,
368}
369
370/// `sync()` never panics or errors out-of-band: transport and protocol
371/// failures come back as `Failed` (the driver's `{ ok: false }`).
372#[derive(Debug, Clone)]
373pub enum SyncOutcome {
374    Ok(SyncReport),
375    Failed { error_code: String, message: String },
376}
377
378impl SyncOutcome {
379    pub fn to_json(&self) -> Value {
380        match self {
381            SyncOutcome::Ok(report) => {
382                let mut map = Map::new();
383                map.insert("ok".to_owned(), Value::Bool(true));
384                map.insert(
385                    "report".to_owned(),
386                    serde_json::to_value(report).expect("report serializes"),
387                );
388                Value::Object(map)
389            }
390            SyncOutcome::Failed {
391                error_code,
392                message,
393            } => {
394                let mut map = Map::new();
395                map.insert("ok".to_owned(), Value::Bool(false));
396                map.insert("errorCode".to_owned(), Value::from(error_code.clone()));
397                map.insert("message".to_owned(), Value::from(message.clone()));
398                Value::Object(map)
399            }
400        }
401    }
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
405#[serde(rename_all = "camelCase")]
406pub struct ConflictRecord {
407    pub client_commit_id: String,
408    pub op_index: i32,
409    pub table: String,
410    pub row_id: String,
411    pub code: String,
412    pub message: String,
413    pub server_version: i64,
414    /// Driver row (bytes as `{"$bytes": hex}`), decoded from the conflict
415    /// record's `serverRow` (§6.3).
416    pub server_row: Map<String, Value>,
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub operation: Option<CommitOperation>,
419}
420
421/// Bounded code-like metadata explicitly declared safe for authorized client
422/// recovery UI. Diagnostic prose remains in `RejectionRecord.message`.
423#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
424#[serde(rename_all = "camelCase", deny_unknown_fields)]
425pub struct RejectionDetails {
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub field_paths: Option<Vec<String>>,
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub reason: Option<String>,
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub required_action: Option<String>,
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub references: Option<BTreeMap<String, String>>,
434}
435
436impl RejectionDetails {
437    pub(crate) fn parse(raw: &str) -> Result<Self, String> {
438        if raw.len() > 4_096 {
439            return Err("rejection details exceed 4096 encoded bytes".to_owned());
440        }
441        let details: Self = serde_json::from_str(raw)
442            .map_err(|error| format!("invalid rejection details: {error}"))?;
443        details.validate()?;
444        Ok(details)
445    }
446
447    fn validate(&self) -> Result<(), String> {
448        let mut members = 0;
449        if let Some(paths) = &self.field_paths {
450            members += 1;
451            if paths.is_empty() || paths.len() > 32 {
452                return Err("fieldPaths must contain 1-32 paths".to_owned());
453            }
454            let mut seen = std::collections::BTreeSet::new();
455            for path in paths {
456                if path.len() > 160 || !path.split('.').all(valid_identifier) || !seen.insert(path)
457                {
458                    return Err("fieldPaths contains an invalid or duplicate path".to_owned());
459                }
460            }
461        }
462        if let Some(reason) = &self.reason {
463            members += 1;
464            if !valid_token(reason, 96) {
465                return Err("reason must be a lowercase stable token".to_owned());
466            }
467        }
468        if let Some(action) = &self.required_action {
469            members += 1;
470            if !valid_token(action, 96) {
471                return Err("requiredAction must be a lowercase stable token".to_owned());
472            }
473        }
474        if let Some(references) = &self.references {
475            members += 1;
476            if references.is_empty() || references.len() > 16 {
477                return Err("references must contain 1-16 entries".to_owned());
478            }
479            for (key, value) in references {
480                if !valid_token(key, 64)
481                    || value.is_empty()
482                    || value.len() > 256
483                    || value.trim() != value
484                    || value.chars().any(char::is_control)
485                {
486                    return Err("references contains an invalid key or value".to_owned());
487                }
488            }
489        }
490        if members == 0 {
491            return Err("rejection details must not be empty".to_owned());
492        }
493        Ok(())
494    }
495}
496
497fn valid_identifier(segment: &str) -> bool {
498    let mut chars = segment.chars();
499    matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic())
500        && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
501}
502
503fn valid_token(token: &str, max: usize) -> bool {
504    if token.is_empty() || token.len() > max || !token.starts_with(|c: char| c.is_ascii_lowercase())
505    {
506        return false;
507    }
508    let mut previous_separator = false;
509    for character in token.chars() {
510        if character.is_ascii_lowercase() || character.is_ascii_digit() {
511            previous_separator = false;
512        } else if matches!(character, '.' | '_' | '-') && !previous_separator {
513            previous_separator = true;
514        } else {
515            return false;
516        }
517    }
518    !previous_separator
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
522#[serde(rename_all = "camelCase")]
523pub struct RejectionRecord {
524    pub client_commit_id: String,
525    pub op_index: i32,
526    pub code: String,
527    pub message: String,
528    pub retryable: bool,
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub details: Option<RejectionDetails>,
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub operation: Option<CommitOperation>,
533}
534
535/// Schema-agnostic local operation retained with a failed final outcome.
536#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
537#[serde(rename_all = "camelCase")]
538pub struct CommitOperation {
539    pub table: String,
540    pub row_id: String,
541    pub op: String,
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub base_version: Option<i64>,
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub values: Option<Map<String, Value>>,
546    /// Normalized columns intentionally supplied to `patch()`; absent for a
547    /// full-row mutate/upsert because intent is unknown.
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub changed_fields: Option<Vec<String>>,
550}
551
552#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
553#[serde(rename_all = "snake_case")]
554pub enum CommitOutcomeStatus {
555    Applied,
556    Cached,
557    Conflict,
558    Rejected,
559}
560
561#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
562#[serde(rename_all = "snake_case")]
563pub enum CommitOutcomeResolution {
564    Active,
565    ResolvedKeepServer,
566    Superseded,
567    Dismissed,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
571#[serde(tag = "status", rename_all = "snake_case")]
572pub enum CommitOperationOutcome {
573    Applied {
574        #[serde(rename = "opIndex")]
575        op_index: i32,
576    },
577    Conflict {
578        conflict: ConflictRecord,
579    },
580    Error {
581        rejection: RejectionRecord,
582    },
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
586#[serde(rename_all = "camelCase")]
587pub struct CommitOutcome {
588    pub sequence: i64,
589    pub client_commit_id: String,
590    pub status: CommitOutcomeStatus,
591    pub recorded_at_ms: i64,
592    pub results: Vec<CommitOperationOutcome>,
593    /// Complete local failed-commit envelope retained after outbox drain.
594    /// Absent for successful and historical outcomes; never sent over wire.
595    #[serde(skip_serializing_if = "Option::is_none")]
596    pub operations: Option<Vec<CommitOperation>>,
597    pub resolution: CommitOutcomeResolution,
598    #[serde(skip_serializing_if = "Option::is_none")]
599    pub resolved_at_ms: Option<i64>,
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub replacement_client_commit_id: Option<String>,
602}
603
604#[derive(Debug, Clone, Deserialize, Default)]
605#[serde(rename_all = "camelCase")]
606pub struct CommitOutcomeQuery {
607    pub limit: Option<usize>,
608    #[serde(default)]
609    pub active_only: bool,
610}
611
612#[derive(Debug, Clone, Deserialize)]
613#[serde(rename_all = "camelCase")]
614pub struct ResolveCommitOutcomeInput {
615    pub client_commit_id: String,
616    pub resolution: CommitOutcomeResolution,
617    pub replacement_client_commit_id: Option<String>,
618}
619
620#[derive(Debug, Clone, Serialize)]
621#[serde(rename_all = "camelCase")]
622pub struct RowState {
623    pub row_id: String,
624    /// Local synced version: `-1` = optimistic, else the server version
625    /// (from a `COMMIT` change or a segment row record, §5.2/§5.6).
626    pub version: i64,
627    pub values: Map<String, Value>,
628}
629
630#[derive(Debug, Clone, Serialize)]
631#[serde(rename_all = "camelCase")]
632pub struct SubscriptionStateView {
633    pub id: String,
634    pub table: String,
635    /// `active` | `revoked` | `failed`.
636    pub status: String,
637    pub cursor: i64,
638    pub has_resume_token: bool,
639    #[serde(skip_serializing_if = "Option::is_none")]
640    pub effective_scopes: Option<Value>,
641    #[serde(skip_serializing_if = "Option::is_none")]
642    pub reason_code: Option<String>,
643}
644
645/// One AND-combined application-authorized local purge target. Targets in
646/// one input are OR-combined. Selector columns must compile to plaintext
647/// strings; values are bounded code-like routing identifiers.
648#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
649#[serde(rename_all = "camelCase", deny_unknown_fields)]
650pub struct LocalDataPurgeTarget {
651    pub table: String,
652    pub selectors: BTreeMap<String, Vec<String>>,
653}
654
655/// Durable local idempotency key plus exact routing targets. The host owns
656/// directive authenticity and subscription gating; the client owns SQLite.
657#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
658#[serde(rename_all = "camelCase", deny_unknown_fields)]
659pub struct LocalDataPurgeInput {
660    pub purge_id: String,
661    pub targets: Vec<LocalDataPurgeTarget>,
662}
663
664/// Privacy-safe local purge acknowledgement; row ids never cross the bridge.
665#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
666#[serde(rename_all = "camelCase")]
667pub struct LocalDataPurgeResult {
668    pub already_applied: bool,
669    pub purged_rows: usize,
670    pub dropped_commits: usize,
671}
672
673/// Client limits (§4.2 request knobs).
674#[derive(Debug, Clone, Default)]
675pub struct ClientLimits {
676    pub limit_commits: Option<i32>,
677    pub limit_snapshot_rows: Option<i32>,
678    pub max_snapshot_pages: Option<i32>,
679    /// §4.2 accept bitmask; this client defaults to `0b0111` (rows
680    /// baseline + sqlite images, §5.3 — rusqlite can always import).
681    pub accept: Option<u8>,
682    /// §5.9.7 B1 blob-cache size cap (bytes). When set and the sum of cached
683    /// body sizes exceeds it, zero-ref, non-pinned bodies are evicted LRU-first
684    /// after each cache write. `None` ⇒ retain until storage pressure (default).
685    pub blob_cache_max_bytes: Option<i64>,
686    /// Maximum durable final outcomes. Active conflicts/rejections are never
687    /// pruned to satisfy the cap. Defaults to 1,000.
688    pub outcome_retention_max_entries: Option<usize>,
689}