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