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    #[serde(skip_serializing_if = "Option::is_none")]
524    pub operation: Option<CommitOperation>,
525}
526
527/// Bounded code-like metadata explicitly declared safe for authorized client
528/// recovery UI. Diagnostic prose remains in `RejectionRecord.message`.
529#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
530#[serde(rename_all = "camelCase", deny_unknown_fields)]
531pub struct RejectionDetails {
532    #[serde(skip_serializing_if = "Option::is_none")]
533    pub field_paths: Option<Vec<String>>,
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub reason: Option<String>,
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub required_action: Option<String>,
538    #[serde(skip_serializing_if = "Option::is_none")]
539    pub references: Option<BTreeMap<String, String>>,
540}
541
542impl RejectionDetails {
543    pub(crate) fn parse(raw: &str) -> Result<Self, String> {
544        if raw.len() > 4_096 {
545            return Err("rejection details exceed 4096 encoded bytes".to_owned());
546        }
547        let details: Self = serde_json::from_str(raw)
548            .map_err(|error| format!("invalid rejection details: {error}"))?;
549        details.validate()?;
550        Ok(details)
551    }
552
553    fn validate(&self) -> Result<(), String> {
554        let mut members = 0;
555        if let Some(paths) = &self.field_paths {
556            members += 1;
557            if paths.is_empty() || paths.len() > 32 {
558                return Err("fieldPaths must contain 1-32 paths".to_owned());
559            }
560            let mut seen = std::collections::BTreeSet::new();
561            for path in paths {
562                if path.len() > 160 || !path.split('.').all(valid_identifier) || !seen.insert(path)
563                {
564                    return Err("fieldPaths contains an invalid or duplicate path".to_owned());
565                }
566            }
567        }
568        if let Some(reason) = &self.reason {
569            members += 1;
570            if !valid_token(reason, 96) {
571                return Err("reason must be a lowercase stable token".to_owned());
572            }
573        }
574        if let Some(action) = &self.required_action {
575            members += 1;
576            if !valid_token(action, 96) {
577                return Err("requiredAction must be a lowercase stable token".to_owned());
578            }
579        }
580        if let Some(references) = &self.references {
581            members += 1;
582            if references.is_empty() || references.len() > 16 {
583                return Err("references must contain 1-16 entries".to_owned());
584            }
585            for (key, value) in references {
586                if !valid_token(key, 64)
587                    || value.is_empty()
588                    || value.len() > 256
589                    || value.trim() != value
590                    || value.chars().any(char::is_control)
591                {
592                    return Err("references contains an invalid key or value".to_owned());
593                }
594            }
595        }
596        if members == 0 {
597            return Err("rejection details must not be empty".to_owned());
598        }
599        Ok(())
600    }
601}
602
603fn valid_identifier(segment: &str) -> bool {
604    let mut chars = segment.chars();
605    matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic())
606        && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
607}
608
609fn valid_token(token: &str, max: usize) -> bool {
610    if token.is_empty() || token.len() > max || !token.starts_with(|c: char| c.is_ascii_lowercase())
611    {
612        return false;
613    }
614    let mut previous_separator = false;
615    for character in token.chars() {
616        if character.is_ascii_lowercase() || character.is_ascii_digit() {
617            previous_separator = false;
618        } else if matches!(character, '.' | '_' | '-') && !previous_separator {
619            previous_separator = true;
620        } else {
621            return false;
622        }
623    }
624    !previous_separator
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
628#[serde(rename_all = "camelCase")]
629pub struct RejectionRecord {
630    pub client_commit_id: String,
631    pub op_index: i32,
632    pub code: String,
633    pub message: String,
634    pub retryable: bool,
635    #[serde(skip_serializing_if = "Option::is_none")]
636    pub details: Option<RejectionDetails>,
637    #[serde(skip_serializing_if = "Option::is_none")]
638    pub operation: Option<CommitOperation>,
639}
640
641/// Schema-agnostic local operation retained with a failed final outcome.
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
643#[serde(rename_all = "camelCase")]
644pub struct CommitOperation {
645    pub table: String,
646    pub row_id: String,
647    pub op: String,
648    #[serde(skip_serializing_if = "Option::is_none")]
649    pub base_version: Option<i64>,
650    #[serde(skip_serializing_if = "Option::is_none")]
651    pub values: Option<Map<String, Value>>,
652    /// Normalized columns intentionally supplied to `patch()`; absent for a
653    /// full-row mutate/upsert because intent is unknown.
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub changed_fields: Option<Vec<String>>,
656}
657
658#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
659#[serde(rename_all = "snake_case")]
660pub enum CommitOutcomeStatus {
661    Applied,
662    Cached,
663    Conflict,
664    Rejected,
665}
666
667#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
668#[serde(rename_all = "snake_case")]
669pub enum CommitOutcomeResolution {
670    Active,
671    ResolvedKeepServer,
672    Superseded,
673    Dismissed,
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
677#[serde(tag = "status", rename_all = "snake_case")]
678pub enum CommitOperationOutcome {
679    Applied {
680        #[serde(rename = "opIndex")]
681        op_index: i32,
682    },
683    Conflict {
684        conflict: ConflictRecord,
685    },
686    Error {
687        rejection: RejectionRecord,
688    },
689}
690
691#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
692#[serde(rename_all = "camelCase")]
693pub struct CommitOutcome {
694    pub sequence: i64,
695    pub client_commit_id: String,
696    pub status: CommitOutcomeStatus,
697    pub recorded_at_ms: i64,
698    pub results: Vec<CommitOperationOutcome>,
699    /// Complete local failed-commit envelope retained after outbox drain.
700    /// Absent for successful and historical outcomes; never sent over wire.
701    #[serde(skip_serializing_if = "Option::is_none")]
702    pub operations: Option<Vec<CommitOperation>>,
703    pub resolution: CommitOutcomeResolution,
704    #[serde(skip_serializing_if = "Option::is_none")]
705    pub resolved_at_ms: Option<i64>,
706    #[serde(skip_serializing_if = "Option::is_none")]
707    pub replacement_client_commit_id: Option<String>,
708}
709
710#[derive(Debug, Clone, Deserialize, Default)]
711#[serde(rename_all = "camelCase")]
712pub struct CommitOutcomeQuery {
713    pub limit: Option<usize>,
714    #[serde(default)]
715    pub active_only: bool,
716}
717
718#[derive(Debug, Clone, Deserialize)]
719#[serde(rename_all = "camelCase")]
720pub struct ResolveCommitOutcomeInput {
721    pub client_commit_id: String,
722    pub resolution: CommitOutcomeResolution,
723    pub replacement_client_commit_id: Option<String>,
724}
725
726#[derive(Debug, Clone, Serialize)]
727#[serde(rename_all = "camelCase")]
728pub struct RowState {
729    pub row_id: String,
730    /// Local synced version: `-1` = optimistic, else the server version
731    /// (from a `COMMIT` change or a segment row record, §5.2/§5.6).
732    pub version: i64,
733    pub values: Map<String, Value>,
734}
735
736#[derive(Debug, Clone, Serialize)]
737#[serde(rename_all = "camelCase")]
738pub struct SubscriptionStateView {
739    pub id: String,
740    pub table: String,
741    /// `active` | `revoked` | `failed`.
742    pub status: String,
743    pub cursor: i64,
744    pub has_resume_token: bool,
745    #[serde(skip_serializing_if = "Option::is_none")]
746    pub effective_scopes: Option<Value>,
747    #[serde(skip_serializing_if = "Option::is_none")]
748    pub reason_code: Option<String>,
749}
750
751/// One AND-combined application-authorized local purge target. Targets in
752/// one input are OR-combined. Selector columns must compile to plaintext
753/// strings; values are bounded code-like routing identifiers.
754#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
755#[serde(rename_all = "camelCase", deny_unknown_fields)]
756pub struct LocalDataPurgeTarget {
757    pub table: String,
758    pub selectors: BTreeMap<String, Vec<String>>,
759}
760
761/// Durable local idempotency key plus exact routing targets. The host owns
762/// directive authenticity and subscription gating; the client owns SQLite.
763#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
764#[serde(rename_all = "camelCase", deny_unknown_fields)]
765pub struct LocalDataPurgeInput {
766    pub purge_id: String,
767    pub targets: Vec<LocalDataPurgeTarget>,
768}
769
770/// Privacy-safe local purge acknowledgement; row ids never cross the bridge.
771#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
772#[serde(rename_all = "camelCase")]
773pub struct LocalDataPurgeResult {
774    pub already_applied: bool,
775    pub purged_rows: usize,
776    pub dropped_commits: usize,
777}
778
779/// Durable application repair id. Reusing the id is an exact no-op.
780#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
781#[serde(rename_all = "camelCase", deny_unknown_fields)]
782pub struct LocalDataRebootstrapInput {
783    pub rebootstrap_id: String,
784}
785
786/// Privacy-safe acknowledgement for a replicated-projection recovery.
787#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
788#[serde(rename_all = "camelCase")]
789pub struct LocalDataRebootstrapResult {
790    pub already_applied: bool,
791    pub retained_commits: usize,
792    pub reset_subscriptions: usize,
793}
794
795/// Client limits (§4.2 request knobs).
796#[derive(Debug, Clone, Default)]
797pub struct ClientLimits {
798    pub limit_commits: Option<i32>,
799    pub limit_snapshot_rows: Option<i32>,
800    pub max_snapshot_pages: Option<i32>,
801    /// §4.2 accept bitmask; this client defaults to `0b0111` (rows
802    /// baseline + sqlite images, §5.3 — rusqlite can always import).
803    pub accept: Option<u8>,
804    /// §5.9.7 B1 blob-cache size cap (bytes). When set and the sum of cached
805    /// body sizes exceeds it, unreferenced bodies without pending work are
806    /// evicted by creation timestamp after each cache write. `None` retains bodies
807    /// until storage pressure (default).
808    pub blob_cache_max_bytes: Option<i64>,
809    /// Maximum durable final outcomes. Active conflicts/rejections are never
810    /// pruned to satisfy the cap. Defaults to 1,000.
811    pub outcome_retention_max_entries: Option<usize>,
812}