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/// An owned blob body returned by the Rust client without driver encoding.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct FetchedBlob {
19    pub blob_id: String,
20    pub byte_length: i64,
21    pub bytes: Vec<u8>,
22    pub media_type: Option<String>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum TimeBucketUnit {
27    Month,
28}
29
30const MAX_TIME_BUCKET_MS: i64 = 253_402_300_799_999;
31
32fn utc_year_month(timestamp_ms: i64) -> Result<(i64, i64), String> {
33    if !(0..=MAX_TIME_BUCKET_MS).contains(&timestamp_ms) {
34        return Err(
35            "sync.invalid_request: time bucket timestamps must fall from 1970 through 9999"
36                .to_owned(),
37        );
38    }
39    let z = timestamp_ms / 86_400_000 + 719_468;
40    let era = z.div_euclid(146_097);
41    let day_of_era = z - era * 146_097;
42    let year_of_era =
43        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
44    let mut year = year_of_era + era * 400;
45    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
46    let month_prime = (5 * day_of_year + 2) / 153;
47    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
48    if month <= 2 {
49        year += 1;
50    }
51    Ok((year, month))
52}
53
54/// Derive the immutable UTC scope value stored when a row is created.
55pub fn creation_time_bucket(created_at_ms: i64, unit: TimeBucketUnit) -> Result<String, String> {
56    match unit {
57        TimeBucketUnit::Month => {
58            let (year, month) = utc_year_month(created_at_ms)?;
59            Ok(format!("{year:04}-{month:02}"))
60        }
61    }
62}
63
64/// Return a rolling UTC month window ordered from oldest to newest.
65pub fn last(count: usize, unit: TimeBucketUnit, now_ms: i64) -> Result<Vec<String>, String> {
66    if !(1..=1_200).contains(&count) {
67        return Err(
68            "sync.invalid_request: time bucket count must be from 1 through 1200".to_owned(),
69        );
70    }
71    match unit {
72        TimeBucketUnit::Month => {
73            let (year, month) = utc_year_month(now_ms)?;
74            let current = year * 12 + month - 1;
75            if current - (count as i64 - 1) < 1970 * 12 {
76                return Err(
77                    "sync.invalid_request: every returned UTC month must fall from 1970 through 9999"
78                        .to_owned(),
79                );
80            }
81            Ok((0..count)
82                .rev()
83                .map(|offset| {
84                    let value = current - offset as i64;
85                    format!(
86                        "{:04}-{:02}",
87                        value.div_euclid(12),
88                        value.rem_euclid(12) + 1
89                    )
90                })
91                .collect())
92        }
93    }
94}
95
96/// §4.8 window base: one table, the scope variable whose values are the
97/// window units, any fixed scopes shared by every unit, and host-opaque
98/// `params` carried onto each unit's subscription.
99#[derive(Debug, Clone)]
100pub struct WindowBase {
101    pub table: String,
102    pub variable: String,
103    /// Scopes shared by every unit (other variables), if any.
104    pub fixed_scopes: Vec<(String, Vec<String>)>,
105    pub params: Option<String>,
106}
107
108/// §4.8 completeness oracle (I3): the windowed-in units for a base, plus
109/// the subset whose bootstrap has not yet completed. Registration alone is
110/// not completeness — a `pending` unit's local replica may be empty or
111/// partial (its subscription still has `cursor: -1` or holds a resume
112/// token), and MUST NOT be rendered as complete. A unit with zero server
113/// rows still completes once its bootstrap round finishes.
114#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
115#[serde(rename_all = "camelCase")]
116pub struct WindowState {
117    /// Windowed-in units for this base, ordered by value.
118    pub units: Vec<String>,
119    /// Registered units whose bootstrap has not yet completed.
120    pub pending: Vec<String>,
121}
122
123#[cfg(test)]
124mod time_bucket_tests {
125    use super::{creation_time_bucket, last, TimeBucketUnit};
126
127    #[test]
128    fn utc_month_buckets_cross_year_boundaries() {
129        let now = 1_770_508_800_000; // 2026-02-08T00:00:00Z
130        assert_eq!(
131            creation_time_bucket(now, TimeBucketUnit::Month).unwrap(),
132            "2026-02"
133        );
134        assert_eq!(
135            last(3, TimeBucketUnit::Month, now).unwrap(),
136            vec!["2025-12", "2026-01", "2026-02"]
137        );
138    }
139
140    #[test]
141    fn utc_month_buckets_reject_unbounded_input() {
142        assert!(last(0, TimeBucketUnit::Month, 0).is_err());
143        assert!(last(1_201, TimeBucketUnit::Month, 0).is_err());
144        assert!(last(2, TimeBucketUnit::Month, 0).is_err());
145        assert!(creation_time_bucket(-1, TimeBucketUnit::Month).is_err());
146    }
147}
148
149#[derive(Debug, Clone, Serialize)]
150#[serde(rename_all = "camelCase")]
151pub struct TableChange {
152    pub table: String,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub scope_keys: Option<Vec<String>>,
155}
156
157#[derive(Debug, Clone, Serialize)]
158#[serde(rename_all = "camelCase")]
159pub struct WindowChange {
160    pub base_key: String,
161    pub table: String,
162    pub units: Vec<String>,
163}
164
165#[derive(Debug, Clone, Serialize)]
166#[serde(rename_all = "camelCase")]
167pub struct SyncStatusSnapshot {
168    pub current_schema_version: i32,
169    pub outbox: usize,
170    pub upgrading: bool,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub lease_state: Option<LeaseState>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub schema_floor: Option<SchemaFloor>,
175    pub sync_needed: bool,
176}
177
178pub const CLIENT_DIAGNOSTICS_VERSION: u8 = 1;
179pub const MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS: usize = 256;
180
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
182#[serde(rename_all = "camelCase", deny_unknown_fields)]
183pub struct ExpectedDiagnosticSubscription {
184    pub id: String,
185    pub table: String,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
189#[serde(rename_all = "camelCase", deny_unknown_fields)]
190pub struct ClientDiagnosticsRequest {
191    #[serde(default)]
192    pub expected_subscriptions: Vec<ExpectedDiagnosticSubscription>,
193}
194
195#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
196#[serde(rename_all = "camelCase")]
197pub struct ClientDiagnosticsHost {
198    pub kind: String,
199    pub role: String,
200    pub connectivity: String,
201    pub realtime: String,
202}
203
204#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
205#[serde(rename_all = "camelCase")]
206pub struct DiagnosticSubscription {
207    pub id: String,
208    pub table: String,
209    pub state: String,
210    pub complete: bool,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub cursor: Option<i64>,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub reason_code: Option<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
218#[serde(rename_all = "camelCase")]
219pub struct DiagnosticRoundCounters {
220    pub pushed: u32,
221    pub applied: usize,
222    pub rejected: usize,
223    pub retryable: usize,
224    pub conflicts: u32,
225    pub commits_applied: u32,
226    pub segment_rows_applied: u32,
227    pub bootstrapping: usize,
228    pub resets: usize,
229    pub revoked: usize,
230    pub failed: usize,
231    pub deferred_commits: usize,
232}
233
234#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
235#[serde(rename_all = "camelCase")]
236pub struct DiagnosticLastRound {
237    pub status: String,
238    pub started_at_ms: i64,
239    pub completed_at_ms: i64,
240    pub duration_ms: i64,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub counters: Option<DiagnosticRoundCounters>,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub error_code: Option<String>,
245}
246
247#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
248#[serde(rename_all = "camelCase")]
249pub struct DiagnosticLastChange {
250    pub revision: String,
251    pub recorded_at_ms: i64,
252    pub tables: Vec<String>,
253    pub windows: Vec<String>,
254    pub domains_truncated: bool,
255    pub status_changed: bool,
256    pub conflicts_changed: bool,
257    pub rejections_changed: bool,
258    pub outcomes_changed: bool,
259}
260
261#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
262#[serde(rename_all = "camelCase")]
263pub struct ClientDiagnosticsStorage {
264    pub status: String,
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub database_bytes_approx: Option<i64>,
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub pending_outbox_bytes_approx: Option<i64>,
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub retained_outcome_bytes_approx: Option<i64>,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub retained_outcome_entries: Option<i64>,
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub blob_cache_bytes_approx: Option<i64>,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub pressure_reason_code: Option<String>,
277}
278
279#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
280#[serde(rename_all = "camelCase")]
281pub struct ClientDiagnosticsSnapshot {
282    pub version: u8,
283    pub captured_at_ms: i64,
284    pub host: ClientDiagnosticsHost,
285    pub security_lifecycle: String,
286    pub schema: ClientDiagnosticsSchema,
287    pub replica: ClientDiagnosticsReplica,
288    pub lease: ClientDiagnosticsLease,
289    pub subscriptions: Vec<DiagnosticSubscription>,
290    pub subscriptions_truncated: bool,
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub last_round: Option<DiagnosticLastRound>,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub last_change: Option<DiagnosticLastChange>,
295    pub storage: ClientDiagnosticsStorage,
296}
297
298#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
299#[serde(rename_all = "camelCase")]
300pub struct ClientDiagnosticsSchema {
301    pub current_version: i32,
302    pub upgrading: bool,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub required_version: Option<i32>,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub latest_version: Option<i32>,
307}
308
309#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
310#[serde(rename_all = "camelCase")]
311pub struct ClientDiagnosticsReplica {
312    pub local_revision: String,
313    pub sync_needed: bool,
314    pub pending_outbox: usize,
315}
316
317#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
318#[serde(rename_all = "camelCase")]
319pub struct ClientDiagnosticsLease {
320    pub state: String,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub expires_at_ms: Option<i64>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub error_code: Option<String>,
325}
326
327/// JSON bindings carry the u64 revision as a decimal string (§7.5).
328#[derive(Debug, Clone, Serialize)]
329#[serde(rename_all = "camelCase")]
330pub struct ClientChangeBatch {
331    pub revision: String,
332    pub tables: Vec<TableChange>,
333    pub windows: Vec<WindowChange>,
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub status: Option<SyncStatusSnapshot>,
336    pub conflicts_changed: bool,
337    pub rejections_changed: bool,
338    pub outcomes_changed: bool,
339}
340
341#[derive(Debug, Clone, Serialize)]
342#[serde(tag = "kind", rename_all = "camelCase")]
343pub enum SyncIntent {
344    None,
345    Interactive,
346    Background {
347        #[serde(rename = "delayMs")]
348        delay_ms: u64,
349    },
350}
351
352#[derive(Debug, Clone, Serialize)]
353pub struct CommandEffects {
354    pub sync: SyncIntent,
355}
356
357impl CommandEffects {
358    #[must_use]
359    pub fn none() -> Self {
360        Self {
361            sync: SyncIntent::None,
362        }
363    }
364
365    #[must_use]
366    pub fn interactive() -> Self {
367        Self {
368            sync: SyncIntent::Interactive,
369        }
370    }
371}
372
373#[derive(Debug, Clone)]
374pub struct WindowCoverage {
375    pub base: WindowBase,
376    pub units: Vec<String>,
377}
378
379#[derive(Debug, Clone, Serialize)]
380#[serde(rename_all = "camelCase")]
381pub struct WindowUnitRef {
382    pub base_key: String,
383    pub unit: String,
384}
385
386#[derive(Debug, Clone, Serialize)]
387#[serde(rename_all = "camelCase")]
388pub struct CoverageSnapshot {
389    pub complete: bool,
390    pub pending: Vec<WindowUnitRef>,
391    pub missing: Vec<WindowUnitRef>,
392}
393
394#[derive(Debug, Clone, Serialize)]
395#[serde(rename_all = "camelCase")]
396pub struct QuerySnapshot {
397    pub revision: String,
398    pub rows: Vec<QueryRow>,
399    pub coverage: CoverageSnapshot,
400}
401
402impl WindowState {
403    /// The per-unit verdict: registered AND bootstrap-complete.
404    #[must_use]
405    pub fn complete(&self, unit: &str) -> bool {
406        self.units.iter().any(|u| u == unit) && !self.pending.iter().any(|u| u == unit)
407    }
408}
409
410/// One local mutation (§6.1 shapes, schema-agnostic local form per §0).
411#[derive(Debug, Clone)]
412pub enum Mutation {
413    Upsert {
414        table: String,
415        values: Map<String, Value>,
416        base_version: Option<i64>,
417    },
418    Delete {
419        table: String,
420        row_id: String,
421        base_version: Option<i64>,
422    },
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
426#[serde(rename_all = "camelCase")]
427pub struct SchemaFloor {
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub required_schema_version: Option<i32>,
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub latest_schema_version: Option<i32>,
432}
433
434/// §7.3.5: the client's opaque auth-lease state — `leaseId`/`expiresAtMs`
435/// from the last `LEASE` frame, and `errorCode` once a round was rejected
436/// with a request-level lease code (stop-and-surface; no data purge).
437#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
438#[serde(rename_all = "camelCase")]
439pub struct LeaseState {
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub lease_id: Option<String>,
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub expires_at_ms: Option<i64>,
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub error_code: Option<String>,
446}
447
448/// §8.6 a peer's ephemeral presence document on a scope key.
449#[derive(Debug, Clone, Serialize)]
450#[serde(rename_all = "camelCase")]
451pub struct PresencePeer {
452    pub actor_id: String,
453    pub client_id: String,
454    pub doc: serde_json::Value,
455}
456
457#[derive(Debug, Clone, Serialize, Default)]
458#[serde(rename_all = "camelCase")]
459pub struct SyncReport {
460    pub pushed: u32,
461    pub applied: Vec<String>,
462    pub rejected: Vec<String>,
463    pub retryable: Vec<String>,
464    pub conflicts: u32,
465    pub commits_applied: u32,
466    pub segment_rows_applied: u32,
467    pub bootstrapping: Vec<String>,
468    pub resets: Vec<String>,
469    pub revoked: Vec<String>,
470    pub failed: Vec<String>,
471    pub deferred_commits: usize,
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub schema_floor: Option<SchemaFloor>,
474}
475
476/// `sync()` never panics or errors out-of-band: transport and protocol
477/// failures come back as `Failed` (the driver's `{ ok: false }`).
478#[derive(Debug, Clone)]
479pub enum SyncOutcome {
480    Ok(SyncReport),
481    Failed { error_code: String, message: String },
482}
483
484impl SyncOutcome {
485    pub fn to_json(&self) -> Value {
486        match self {
487            SyncOutcome::Ok(report) => {
488                let mut map = Map::new();
489                map.insert("ok".to_owned(), Value::Bool(true));
490                map.insert(
491                    "report".to_owned(),
492                    serde_json::to_value(report).expect("report serializes"),
493                );
494                Value::Object(map)
495            }
496            SyncOutcome::Failed {
497                error_code,
498                message,
499            } => {
500                let mut map = Map::new();
501                map.insert("ok".to_owned(), Value::Bool(false));
502                map.insert("errorCode".to_owned(), Value::from(error_code.clone()));
503                map.insert("message".to_owned(), Value::from(message.clone()));
504                Value::Object(map)
505            }
506        }
507    }
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
511#[serde(rename_all = "camelCase")]
512pub struct ConflictRecord {
513    pub client_commit_id: String,
514    pub op_index: i32,
515    pub table: String,
516    pub row_id: String,
517    pub code: String,
518    pub message: String,
519    pub server_version: i64,
520    /// Driver row (bytes as `{"$bytes": hex}`), decoded from the conflict
521    /// record's `serverRow` (§6.3).
522    pub server_row: Map<String, Value>,
523    /// §6.3 `conflictColumns`: the present columns whose `column_version`
524    /// exceeded `baseVersion`, decoded from the bitmap into column names in
525    /// declaration order. Absent on a legacy journal entry, which reads as an
526    /// empty list.
527    #[serde(default, skip_serializing_if = "Vec::is_empty")]
528    pub conflict_columns: Vec<String>,
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub operation: Option<CommitOperation>,
531}
532
533/// Bounded code-like metadata explicitly declared safe for authorized client
534/// recovery UI. Diagnostic prose remains in `RejectionRecord.message`.
535#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
536#[serde(rename_all = "camelCase", deny_unknown_fields)]
537pub struct RejectionDetails {
538    #[serde(skip_serializing_if = "Option::is_none")]
539    pub field_paths: Option<Vec<String>>,
540    #[serde(skip_serializing_if = "Option::is_none")]
541    pub reason: Option<String>,
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub required_action: Option<String>,
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub references: Option<BTreeMap<String, String>>,
546}
547
548impl RejectionDetails {
549    pub(crate) fn parse(raw: &str) -> Result<Self, String> {
550        if raw.len() > 4_096 {
551            return Err("rejection details exceed 4096 encoded bytes".to_owned());
552        }
553        let details: Self = serde_json::from_str(raw)
554            .map_err(|error| format!("invalid rejection details: {error}"))?;
555        details.validate()?;
556        Ok(details)
557    }
558
559    fn validate(&self) -> Result<(), String> {
560        let mut members = 0;
561        if let Some(paths) = &self.field_paths {
562            members += 1;
563            if paths.is_empty() || paths.len() > 32 {
564                return Err("fieldPaths must contain 1-32 paths".to_owned());
565            }
566            let mut seen = std::collections::BTreeSet::new();
567            for path in paths {
568                if path.len() > 160 || !path.split('.').all(valid_identifier) || !seen.insert(path)
569                {
570                    return Err("fieldPaths contains an invalid or duplicate path".to_owned());
571                }
572            }
573        }
574        if let Some(reason) = &self.reason {
575            members += 1;
576            if !valid_token(reason, 96) {
577                return Err("reason must be a lowercase stable token".to_owned());
578            }
579        }
580        if let Some(action) = &self.required_action {
581            members += 1;
582            if !valid_token(action, 96) {
583                return Err("requiredAction must be a lowercase stable token".to_owned());
584            }
585        }
586        if let Some(references) = &self.references {
587            members += 1;
588            if references.is_empty() || references.len() > 16 {
589                return Err("references must contain 1-16 entries".to_owned());
590            }
591            for (key, value) in references {
592                if !valid_token(key, 64)
593                    || value.is_empty()
594                    || value.len() > 256
595                    || value.trim() != value
596                    || value.chars().any(char::is_control)
597                {
598                    return Err("references contains an invalid key or value".to_owned());
599                }
600            }
601        }
602        if members == 0 {
603            return Err("rejection details must not be empty".to_owned());
604        }
605        Ok(())
606    }
607}
608
609fn valid_identifier(segment: &str) -> bool {
610    let mut chars = segment.chars();
611    matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic())
612        && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
613}
614
615fn valid_token(token: &str, max: usize) -> bool {
616    if token.is_empty() || token.len() > max || !token.starts_with(|c: char| c.is_ascii_lowercase())
617    {
618        return false;
619    }
620    let mut previous_separator = false;
621    for character in token.chars() {
622        if character.is_ascii_lowercase() || character.is_ascii_digit() {
623            previous_separator = false;
624        } else if matches!(character, '.' | '_' | '-') && !previous_separator {
625            previous_separator = true;
626        } else {
627            return false;
628        }
629    }
630    !previous_separator
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
634#[serde(rename_all = "camelCase")]
635pub struct RejectionRecord {
636    pub client_commit_id: String,
637    pub op_index: i32,
638    pub code: String,
639    pub message: String,
640    pub retryable: bool,
641    #[serde(skip_serializing_if = "Option::is_none")]
642    pub details: Option<RejectionDetails>,
643    #[serde(skip_serializing_if = "Option::is_none")]
644    pub operation: Option<CommitOperation>,
645}
646
647/// Schema-agnostic local operation retained with a failed final outcome.
648#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
649#[serde(rename_all = "camelCase")]
650pub struct CommitOperation {
651    pub table: String,
652    pub row_id: String,
653    pub op: String,
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub base_version: Option<i64>,
656    #[serde(skip_serializing_if = "Option::is_none")]
657    pub values: Option<Map<String, Value>>,
658}
659
660#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
661#[serde(rename_all = "snake_case")]
662pub enum CommitOutcomeStatus {
663    Applied,
664    Cached,
665    Conflict,
666    Rejected,
667}
668
669#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
670#[serde(rename_all = "snake_case")]
671pub enum CommitOutcomeResolution {
672    Active,
673    ResolvedKeepServer,
674    Superseded,
675    Dismissed,
676}
677
678#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
679#[serde(tag = "status", rename_all = "snake_case")]
680pub enum CommitOperationOutcome {
681    Applied {
682        #[serde(rename = "opIndex")]
683        op_index: i32,
684    },
685    Conflict {
686        conflict: ConflictRecord,
687    },
688    Error {
689        rejection: RejectionRecord,
690    },
691}
692
693#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
694#[serde(rename_all = "camelCase")]
695pub struct CommitOutcome {
696    pub sequence: i64,
697    pub client_commit_id: String,
698    pub status: CommitOutcomeStatus,
699    pub recorded_at_ms: i64,
700    pub results: Vec<CommitOperationOutcome>,
701    /// Complete local failed-commit envelope retained after outbox drain.
702    /// Absent for successful and historical outcomes; never sent over wire.
703    #[serde(skip_serializing_if = "Option::is_none")]
704    pub operations: Option<Vec<CommitOperation>>,
705    pub resolution: CommitOutcomeResolution,
706    #[serde(skip_serializing_if = "Option::is_none")]
707    pub resolved_at_ms: Option<i64>,
708    #[serde(skip_serializing_if = "Option::is_none")]
709    pub replacement_client_commit_id: Option<String>,
710}
711
712#[derive(Debug, Clone, Deserialize, Default)]
713#[serde(rename_all = "camelCase")]
714pub struct CommitOutcomeQuery {
715    pub limit: Option<usize>,
716    #[serde(default)]
717    pub active_only: bool,
718}
719
720#[derive(Debug, Clone, Deserialize)]
721#[serde(rename_all = "camelCase")]
722pub struct ResolveCommitOutcomeInput {
723    pub client_commit_id: String,
724    pub resolution: CommitOutcomeResolution,
725    pub replacement_client_commit_id: Option<String>,
726}
727
728#[derive(Debug, Clone, Serialize)]
729#[serde(rename_all = "camelCase")]
730pub struct RowState {
731    pub row_id: String,
732    /// Local synced version: `-1` = optimistic, else the server version
733    /// (from a `COMMIT` change or a segment row record, §5.2/§5.6).
734    pub version: i64,
735    pub values: Map<String, Value>,
736}
737
738#[derive(Debug, Clone, Serialize)]
739#[serde(rename_all = "camelCase")]
740pub struct SubscriptionStateView {
741    pub id: String,
742    pub table: String,
743    /// `active` | `revoked` | `failed`.
744    pub status: String,
745    pub cursor: i64,
746    pub has_resume_token: bool,
747    #[serde(skip_serializing_if = "Option::is_none")]
748    pub effective_scopes: Option<Value>,
749    #[serde(skip_serializing_if = "Option::is_none")]
750    pub reason_code: Option<String>,
751}
752
753/// One AND-combined application-authorized local purge target. Targets in
754/// one input are OR-combined. Selector columns must compile to plaintext
755/// strings; values are bounded code-like routing identifiers.
756#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
757#[serde(rename_all = "camelCase", deny_unknown_fields)]
758pub struct LocalDataPurgeTarget {
759    pub table: String,
760    pub selectors: BTreeMap<String, Vec<String>>,
761}
762
763/// Durable local idempotency key plus exact routing targets. The host owns
764/// directive authenticity and subscription gating; the client owns SQLite.
765#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
766#[serde(rename_all = "camelCase", deny_unknown_fields)]
767pub struct LocalDataPurgeInput {
768    pub purge_id: String,
769    pub targets: Vec<LocalDataPurgeTarget>,
770}
771
772/// Privacy-safe local purge acknowledgement; row ids never cross the bridge.
773#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
774#[serde(rename_all = "camelCase")]
775pub struct LocalDataPurgeResult {
776    pub already_applied: bool,
777    pub purged_rows: usize,
778    pub dropped_commits: usize,
779}
780
781/// Durable application repair id. Reusing the id is an exact no-op.
782#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
783#[serde(rename_all = "camelCase", deny_unknown_fields)]
784pub struct LocalDataRebootstrapInput {
785    pub rebootstrap_id: String,
786}
787
788/// Privacy-safe acknowledgement for a replicated-projection recovery.
789#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
790#[serde(rename_all = "camelCase")]
791pub struct LocalDataRebootstrapResult {
792    pub already_applied: bool,
793    pub retained_commits: usize,
794    pub reset_subscriptions: usize,
795}
796
797/// Client limits (§4.2 request knobs).
798#[derive(Debug, Clone, Default)]
799pub struct ClientLimits {
800    pub limit_commits: Option<i32>,
801    pub limit_snapshot_rows: Option<i32>,
802    pub max_snapshot_pages: Option<i32>,
803    /// §4.2 accept bitmask; this client defaults to `0b0111` (rows
804    /// baseline + sqlite images, §5.3 — rusqlite can always import).
805    pub accept: Option<u8>,
806    /// §5.9.7 B1 blob-cache size cap (bytes). When set and the sum of cached
807    /// body sizes exceeds it, unreferenced bodies without pending work are
808    /// evicted by creation timestamp after each cache write. `None` retains bodies
809    /// until storage pressure (default).
810    pub blob_cache_max_bytes: Option<i64>,
811    /// Maximum durable final outcomes. Active conflicts/rejections are never
812    /// pruned to satisfy the cap. Defaults to 1,000.
813    pub outcome_retention_max_entries: Option<usize>,
814}