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