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;
193pub const MAX_DIAGNOSTIC_QUERY_FAILURES: usize = 256;
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
196#[serde(rename_all = "camelCase", deny_unknown_fields)]
197pub struct ExpectedDiagnosticSubscription {
198    pub id: String,
199    pub table: String,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
203#[serde(rename_all = "camelCase", deny_unknown_fields)]
204pub struct ClientDiagnosticsRequest {
205    #[serde(default)]
206    pub expected_subscriptions: Vec<ExpectedDiagnosticSubscription>,
207}
208
209#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
210#[serde(rename_all = "camelCase")]
211pub struct ClientDiagnosticsHost {
212    pub kind: String,
213    pub role: String,
214    pub connectivity: String,
215    pub realtime: String,
216}
217
218#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
219#[serde(rename_all = "camelCase")]
220pub struct DiagnosticSubscription {
221    pub id: String,
222    pub table: String,
223    pub state: String,
224    pub complete: bool,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub cursor: Option<i64>,
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub reason_code: Option<String>,
229}
230
231#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
232#[serde(rename_all = "camelCase")]
233pub struct DiagnosticRoundCounters {
234    pub pushed: u32,
235    pub applied: usize,
236    pub rejected: usize,
237    pub retryable: usize,
238    pub conflicts: u32,
239    pub commits_applied: u32,
240    pub segment_rows_applied: u32,
241    pub bootstrapping: usize,
242    pub resets: usize,
243    pub revoked: usize,
244    pub failed: usize,
245    pub deferred_commits: usize,
246}
247
248#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
249#[serde(rename_all = "camelCase")]
250pub struct DiagnosticLastRound {
251    pub status: String,
252    pub started_at_ms: i64,
253    pub completed_at_ms: i64,
254    pub duration_ms: i64,
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub counters: Option<DiagnosticRoundCounters>,
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub error_code: Option<String>,
259}
260
261#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
262#[serde(rename_all = "camelCase")]
263pub struct DiagnosticLastChange {
264    pub revision: String,
265    pub recorded_at_ms: i64,
266    pub tables: Vec<String>,
267    pub windows: Vec<String>,
268    pub domains_truncated: bool,
269    pub status_changed: bool,
270    pub conflicts_changed: bool,
271    pub rejections_changed: bool,
272    pub outcomes_changed: bool,
273}
274
275#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
276#[serde(rename_all = "camelCase")]
277pub struct ClientDiagnosticsStorage {
278    pub status: String,
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub database_bytes_approx: Option<i64>,
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub pending_outbox_bytes_approx: Option<i64>,
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub retained_outcome_bytes_approx: Option<i64>,
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub retained_outcome_entries: Option<i64>,
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub blob_cache_bytes_approx: Option<i64>,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub pressure_reason_code: Option<String>,
291}
292
293/// §7.6: the latest owned snapshot read of one query id failed.
294#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
295#[serde(rename_all = "camelCase")]
296pub struct DiagnosticQueryFailure {
297    pub id: String,
298    /// Distinct generated table names, ascending.
299    pub tables: Vec<String>,
300    /// `client.storage_corrupt`, `client.storage_io`, or `client.query_failed`.
301    pub code: String,
302    /// SQLite extended result code, when the driver exposed one.
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub sqlite_code: Option<i32>,
305    /// First failure since this id's last successful read.
306    pub at_ms: i64,
307}
308
309#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
310#[serde(rename_all = "camelCase")]
311pub struct ClientDiagnosticsSnapshot {
312    pub version: u8,
313    pub captured_at_ms: i64,
314    pub host: ClientDiagnosticsHost,
315    pub security_lifecycle: String,
316    pub schema: ClientDiagnosticsSchema,
317    pub replica: ClientDiagnosticsReplica,
318    pub lease: ClientDiagnosticsLease,
319    pub subscriptions: Vec<DiagnosticSubscription>,
320    pub subscriptions_truncated: bool,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub last_round: Option<DiagnosticLastRound>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub last_change: Option<DiagnosticLastChange>,
325    pub storage: ClientDiagnosticsStorage,
326    pub query_failures: Vec<DiagnosticQueryFailure>,
327}
328
329#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
330#[serde(rename_all = "camelCase")]
331pub struct ClientDiagnosticsSchema {
332    pub current_version: i32,
333    pub upgrading: bool,
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub required_version: Option<i32>,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub latest_version: Option<i32>,
338}
339
340#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
341#[serde(rename_all = "camelCase")]
342pub struct ClientDiagnosticsReplica {
343    pub local_revision: String,
344    pub sync_needed: bool,
345    pub pending_outbox: usize,
346}
347
348#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
349#[serde(rename_all = "camelCase")]
350pub struct ClientDiagnosticsLease {
351    pub state: String,
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub expires_at_ms: Option<i64>,
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub error_code: Option<String>,
356}
357
358/// JSON bindings carry the u64 revision as a decimal string (§7.5).
359#[derive(Debug, Clone, Serialize)]
360#[serde(rename_all = "camelCase")]
361pub struct ClientChangeBatch {
362    pub revision: String,
363    pub tables: Vec<TableChange>,
364    pub windows: Vec<WindowChange>,
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub status: Option<SyncStatusSnapshot>,
367    pub conflicts_changed: bool,
368    pub rejections_changed: bool,
369    pub outcomes_changed: bool,
370}
371
372#[derive(Debug, Clone, Serialize)]
373#[serde(tag = "kind", rename_all = "camelCase")]
374pub enum SyncIntent {
375    None,
376    Interactive,
377    Background {
378        #[serde(rename = "delayMs")]
379        delay_ms: u64,
380    },
381}
382
383#[derive(Debug, Clone, Serialize)]
384pub struct CommandEffects {
385    pub sync: SyncIntent,
386}
387
388impl CommandEffects {
389    #[must_use]
390    pub fn none() -> Self {
391        Self {
392            sync: SyncIntent::None,
393        }
394    }
395
396    #[must_use]
397    pub fn interactive() -> Self {
398        Self {
399            sync: SyncIntent::Interactive,
400        }
401    }
402}
403
404#[derive(Debug, Clone)]
405pub struct WindowCoverage {
406    pub base: WindowBase,
407    pub units: Vec<String>,
408}
409
410#[derive(Debug, Clone, Serialize)]
411#[serde(rename_all = "camelCase")]
412pub struct WindowUnitRef {
413    pub base_key: String,
414    pub unit: String,
415}
416
417#[derive(Debug, Clone, Serialize)]
418#[serde(rename_all = "camelCase")]
419pub struct CoverageSnapshot {
420    pub complete: bool,
421    pub pending: Vec<WindowUnitRef>,
422    pub missing: Vec<WindowUnitRef>,
423}
424
425/// §7.5: the query that owns a snapshot read. The id is application-owned,
426/// stable, and PHI-free; generated named queries pass their `ID` and `TABLES`.
427#[derive(Debug, Clone, Copy)]
428pub struct QueryOwner<'a> {
429    pub id: &'a str,
430    pub tables: &'a [&'a str],
431}
432
433/// §7.5: a failed snapshot read. `code` is `client.storage_corrupt` or
434/// `client.storage_io` when SQLite reported corruption or I/O failure, and
435/// `None` for every other failure, which keeps its existing message.
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct QueryReadFailure {
438    pub code: Option<&'static str>,
439    pub sqlite_code: Option<i32>,
440    pub message: String,
441}
442
443impl std::fmt::Display for QueryReadFailure {
444    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
445        match self.code {
446            Some(code) => write!(f, "{code}: {}", self.message),
447            None => f.write_str(&self.message),
448        }
449    }
450}
451
452impl From<String> for QueryReadFailure {
453    fn from(message: String) -> Self {
454        Self {
455            code: None,
456            sqlite_code: None,
457            message,
458        }
459    }
460}
461
462impl From<rusqlite::Error> for QueryReadFailure {
463    /// Classify by SQLite's primary result code, never by message text.
464    fn from(error: rusqlite::Error) -> Self {
465        let sqlite_code = match &error {
466            rusqlite::Error::SqliteFailure(failure, _) => Some(failure.extended_code),
467            _ => None,
468        };
469        match sqlite_code.map(|code| code & 0xff) {
470            Some(11 | 26) => Self {
471                code: Some("client.storage_corrupt"),
472                sqlite_code,
473                message: "local SQLite storage is corrupt".to_owned(),
474            },
475            Some(10) => Self {
476                code: Some("client.storage_io"),
477                sqlite_code,
478                message: "local SQLite storage I/O failed".to_owned(),
479            },
480            _ => Self {
481                code: None,
482                sqlite_code,
483                message: error.to_string(),
484            },
485        }
486    }
487}
488
489#[derive(Debug, Clone, Serialize)]
490#[serde(rename_all = "camelCase")]
491pub struct QuerySnapshot {
492    pub revision: String,
493    pub rows: Vec<QueryRow>,
494    pub coverage: CoverageSnapshot,
495}
496
497impl WindowState {
498    /// The per-unit verdict: registered AND bootstrap-complete.
499    #[must_use]
500    pub fn complete(&self, unit: &str) -> bool {
501        self.units.iter().any(|u| u == unit) && !self.pending.iter().any(|u| u == unit)
502    }
503}
504
505/// One local mutation (§6.1 shapes, schema-agnostic local form per §0).
506#[derive(Debug, Clone)]
507pub enum Mutation {
508    Upsert {
509        table: String,
510        values: Map<String, Value>,
511        base_version: Option<i64>,
512    },
513    Delete {
514        table: String,
515        row_id: String,
516        base_version: Option<i64>,
517    },
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
521#[serde(rename_all = "camelCase")]
522pub struct SchemaFloor {
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub required_schema_version: Option<i32>,
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub latest_schema_version: Option<i32>,
527}
528
529/// §7.3.5: the client's opaque auth-lease state — `leaseId`/`expiresAtMs`
530/// from the last `LEASE` frame, and `errorCode` once a round was rejected
531/// with a request-level lease code (stop-and-surface; no data purge).
532#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
533#[serde(rename_all = "camelCase")]
534pub struct LeaseState {
535    #[serde(skip_serializing_if = "Option::is_none")]
536    pub lease_id: Option<String>,
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub expires_at_ms: Option<i64>,
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub error_code: Option<String>,
541}
542
543/// §8.6 a peer's ephemeral presence document on a scope key.
544#[derive(Debug, Clone, Serialize)]
545#[serde(rename_all = "camelCase")]
546pub struct PresencePeer {
547    pub actor_id: String,
548    pub client_id: String,
549    pub doc: serde_json::Value,
550}
551
552#[derive(Debug, Clone, Serialize, Default)]
553#[serde(rename_all = "camelCase")]
554pub struct SyncReport {
555    pub pushed: u32,
556    pub applied: Vec<String>,
557    pub rejected: Vec<String>,
558    pub retryable: Vec<String>,
559    pub conflicts: u32,
560    pub commits_applied: u32,
561    pub segment_rows_applied: u32,
562    pub bootstrapping: Vec<String>,
563    pub resets: Vec<String>,
564    pub revoked: Vec<String>,
565    pub failed: Vec<String>,
566    pub deferred_commits: usize,
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub schema_floor: Option<SchemaFloor>,
569}
570
571/// `sync()` never panics or errors out-of-band: transport and protocol
572/// failures come back as `Failed` (the driver's `{ ok: false }`).
573#[derive(Debug, Clone)]
574pub enum SyncOutcome {
575    Ok(SyncReport),
576    Failed { error_code: String, message: String },
577}
578
579impl SyncOutcome {
580    pub fn to_json(&self) -> Value {
581        match self {
582            SyncOutcome::Ok(report) => {
583                let mut map = Map::new();
584                map.insert("ok".to_owned(), Value::Bool(true));
585                map.insert(
586                    "report".to_owned(),
587                    serde_json::to_value(report).expect("report serializes"),
588                );
589                Value::Object(map)
590            }
591            SyncOutcome::Failed {
592                error_code,
593                message,
594            } => {
595                let mut map = Map::new();
596                map.insert("ok".to_owned(), Value::Bool(false));
597                map.insert("errorCode".to_owned(), Value::from(error_code.clone()));
598                map.insert("message".to_owned(), Value::from(message.clone()));
599                Value::Object(map)
600            }
601        }
602    }
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
606#[serde(rename_all = "camelCase")]
607pub struct ConflictRecord {
608    pub client_commit_id: String,
609    pub op_index: i32,
610    pub table: String,
611    pub row_id: String,
612    pub code: String,
613    pub message: String,
614    pub server_version: i64,
615    /// Driver row (bytes as `{"$bytes": hex}`), decoded from the conflict
616    /// record's `serverRow` (§6.3).
617    pub server_row: Map<String, Value>,
618    /// §6.3 `conflictColumns`: the present columns whose `column_version`
619    /// exceeded `baseVersion`, decoded from the bitmap into column names in
620    /// declaration order. Absent on a legacy journal entry, which reads as an
621    /// empty list.
622    #[serde(default, skip_serializing_if = "Vec::is_empty")]
623    pub conflict_columns: Vec<String>,
624    #[serde(skip_serializing_if = "Option::is_none")]
625    pub operation: Option<CommitOperation>,
626}
627
628/// Bounded code-like metadata explicitly declared safe for authorized client
629/// recovery UI. Diagnostic prose remains in `RejectionRecord.message`.
630#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
631#[serde(rename_all = "camelCase", deny_unknown_fields)]
632pub struct RejectionDetails {
633    #[serde(skip_serializing_if = "Option::is_none")]
634    pub field_paths: Option<Vec<String>>,
635    #[serde(skip_serializing_if = "Option::is_none")]
636    pub reason: Option<String>,
637    #[serde(skip_serializing_if = "Option::is_none")]
638    pub required_action: Option<String>,
639    #[serde(skip_serializing_if = "Option::is_none")]
640    pub references: Option<BTreeMap<String, String>>,
641}
642
643impl RejectionDetails {
644    pub(crate) fn parse(raw: &str) -> Result<Self, String> {
645        if raw.len() > 4_096 {
646            return Err("rejection details exceed 4096 encoded bytes".to_owned());
647        }
648        let details: Self = serde_json::from_str(raw)
649            .map_err(|error| format!("invalid rejection details: {error}"))?;
650        details.validate()?;
651        Ok(details)
652    }
653
654    fn validate(&self) -> Result<(), String> {
655        let mut members = 0;
656        if let Some(paths) = &self.field_paths {
657            members += 1;
658            if paths.is_empty() || paths.len() > 32 {
659                return Err("fieldPaths must contain 1-32 paths".to_owned());
660            }
661            let mut seen = std::collections::BTreeSet::new();
662            for path in paths {
663                if path.len() > 160 || !path.split('.').all(valid_identifier) || !seen.insert(path)
664                {
665                    return Err("fieldPaths contains an invalid or duplicate path".to_owned());
666                }
667            }
668        }
669        if let Some(reason) = &self.reason {
670            members += 1;
671            if !valid_token(reason, 96) {
672                return Err("reason must be a lowercase stable token".to_owned());
673            }
674        }
675        if let Some(action) = &self.required_action {
676            members += 1;
677            if !valid_token(action, 96) {
678                return Err("requiredAction must be a lowercase stable token".to_owned());
679            }
680        }
681        if let Some(references) = &self.references {
682            members += 1;
683            if references.is_empty() || references.len() > 16 {
684                return Err("references must contain 1-16 entries".to_owned());
685            }
686            for (key, value) in references {
687                if !valid_token(key, 64)
688                    || value.is_empty()
689                    || value.len() > 256
690                    || value.trim() != value
691                    || value.chars().any(char::is_control)
692                {
693                    return Err("references contains an invalid key or value".to_owned());
694                }
695            }
696        }
697        if members == 0 {
698            return Err("rejection details must not be empty".to_owned());
699        }
700        Ok(())
701    }
702}
703
704fn valid_identifier(segment: &str) -> bool {
705    let mut chars = segment.chars();
706    matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic())
707        && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
708}
709
710fn valid_token(token: &str, max: usize) -> bool {
711    if token.is_empty() || token.len() > max || !token.starts_with(|c: char| c.is_ascii_lowercase())
712    {
713        return false;
714    }
715    let mut previous_separator = false;
716    for character in token.chars() {
717        if character.is_ascii_lowercase() || character.is_ascii_digit() {
718            previous_separator = false;
719        } else if matches!(character, '.' | '_' | '-') && !previous_separator {
720            previous_separator = true;
721        } else {
722            return false;
723        }
724    }
725    !previous_separator
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
729#[serde(rename_all = "camelCase")]
730pub struct RejectionRecord {
731    pub client_commit_id: String,
732    pub op_index: i32,
733    pub code: String,
734    pub message: String,
735    pub retryable: bool,
736    #[serde(skip_serializing_if = "Option::is_none")]
737    pub details: Option<RejectionDetails>,
738    #[serde(skip_serializing_if = "Option::is_none")]
739    pub operation: Option<CommitOperation>,
740}
741
742/// Schema-agnostic local operation retained with a failed final outcome.
743#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
744#[serde(rename_all = "camelCase")]
745pub struct CommitOperation {
746    pub table: String,
747    pub row_id: String,
748    pub op: String,
749    #[serde(skip_serializing_if = "Option::is_none")]
750    pub base_version: Option<i64>,
751    #[serde(skip_serializing_if = "Option::is_none")]
752    pub values: Option<Map<String, Value>>,
753}
754
755#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
756#[serde(rename_all = "snake_case")]
757pub enum CommitOutcomeStatus {
758    Applied,
759    Cached,
760    Conflict,
761    Rejected,
762}
763
764#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
765#[serde(rename_all = "snake_case")]
766pub enum CommitOutcomeResolution {
767    Active,
768    ResolvedKeepServer,
769    Superseded,
770    Dismissed,
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
774#[serde(tag = "status", rename_all = "snake_case")]
775pub enum CommitOperationOutcome {
776    Applied {
777        #[serde(rename = "opIndex")]
778        op_index: i32,
779    },
780    Conflict {
781        conflict: ConflictRecord,
782    },
783    Error {
784        rejection: RejectionRecord,
785    },
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
789#[serde(rename_all = "camelCase")]
790pub struct CommitOutcome {
791    pub sequence: i64,
792    pub client_commit_id: String,
793    pub status: CommitOutcomeStatus,
794    pub recorded_at_ms: i64,
795    pub results: Vec<CommitOperationOutcome>,
796    /// Complete local failed-commit envelope retained after outbox drain.
797    /// Absent for successful and historical outcomes; never sent over wire.
798    #[serde(skip_serializing_if = "Option::is_none")]
799    pub operations: Option<Vec<CommitOperation>>,
800    pub resolution: CommitOutcomeResolution,
801    #[serde(skip_serializing_if = "Option::is_none")]
802    pub resolved_at_ms: Option<i64>,
803    #[serde(skip_serializing_if = "Option::is_none")]
804    pub replacement_client_commit_id: Option<String>,
805}
806
807#[derive(Debug, Clone, Deserialize, Default)]
808#[serde(rename_all = "camelCase")]
809pub struct CommitOutcomeQuery {
810    pub limit: Option<usize>,
811    #[serde(default)]
812    pub active_only: bool,
813}
814
815#[derive(Debug, Clone, Deserialize)]
816#[serde(rename_all = "camelCase")]
817pub struct ResolveCommitOutcomeInput {
818    pub client_commit_id: String,
819    pub resolution: CommitOutcomeResolution,
820    pub replacement_client_commit_id: Option<String>,
821}
822
823#[derive(Debug, Clone, Serialize)]
824#[serde(rename_all = "camelCase")]
825pub struct RowState {
826    pub row_id: String,
827    /// Local synced version: `-1` = optimistic, else the server version
828    /// (from a `COMMIT` change or a segment row record, §5.2/§5.6).
829    pub version: i64,
830    pub values: Map<String, Value>,
831}
832
833#[derive(Debug, Clone, Serialize)]
834#[serde(rename_all = "camelCase")]
835pub struct SubscriptionStateView {
836    pub id: String,
837    pub table: String,
838    /// `active` | `revoked` | `failed`.
839    pub status: String,
840    pub cursor: i64,
841    pub has_resume_token: bool,
842    #[serde(skip_serializing_if = "Option::is_none")]
843    pub effective_scopes: Option<Value>,
844    #[serde(skip_serializing_if = "Option::is_none")]
845    pub reason_code: Option<String>,
846}
847
848/// One AND-combined application-authorized local purge target. Targets in
849/// one input are OR-combined. Selector columns must compile to plaintext
850/// strings; values are bounded code-like routing identifiers.
851#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
852#[serde(rename_all = "camelCase", deny_unknown_fields)]
853pub struct LocalDataPurgeTarget {
854    pub table: String,
855    pub selectors: BTreeMap<String, Vec<String>>,
856}
857
858/// Durable local idempotency key plus exact routing targets. The host owns
859/// directive authenticity and subscription gating; the client owns SQLite.
860#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
861#[serde(rename_all = "camelCase", deny_unknown_fields)]
862pub struct LocalDataPurgeInput {
863    pub purge_id: String,
864    pub targets: Vec<LocalDataPurgeTarget>,
865}
866
867/// Privacy-safe local purge acknowledgement; row ids never cross the bridge.
868#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
869#[serde(rename_all = "camelCase")]
870pub struct LocalDataPurgeResult {
871    pub already_applied: bool,
872    pub purged_rows: usize,
873    pub dropped_commits: usize,
874}
875
876/// Durable application repair id. Reusing the id is an exact no-op.
877#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
878#[serde(rename_all = "camelCase", deny_unknown_fields)]
879pub struct LocalDataRebootstrapInput {
880    pub rebootstrap_id: String,
881}
882
883/// Privacy-safe acknowledgement for a replicated-projection recovery.
884#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
885#[serde(rename_all = "camelCase")]
886pub struct LocalDataRebootstrapResult {
887    pub already_applied: bool,
888    pub retained_commits: usize,
889    pub reset_subscriptions: usize,
890}
891
892/// Client limits (§4.2 request knobs).
893#[derive(Debug, Clone, Default)]
894pub struct ClientLimits {
895    pub limit_commits: Option<i32>,
896    pub limit_snapshot_rows: Option<i32>,
897    pub max_snapshot_pages: Option<i32>,
898    /// §4.2 accept bitmask; this client defaults to `0b0111` (rows
899    /// baseline + sqlite images, §5.3 — rusqlite can always import).
900    pub accept: Option<u8>,
901    /// §5.9.7 B1 blob-cache size cap (bytes). When set and the sum of cached
902    /// body sizes exceeds it, unreferenced bodies without pending work are
903    /// evicted by creation timestamp after each cache write. `None` retains bodies
904    /// until storage pressure (default).
905    pub blob_cache_max_bytes: Option<i64>,
906    /// Maximum durable final outcomes. Active conflicts/rejections are never
907    /// pruned to satisfy the cap. Defaults to 1,000.
908    pub outcome_retention_max_entries: Option<usize>,
909    /// RFC 0005 D8: the previous-version capture config. Absent or
910    /// `enabled: false` runs the feature off (default); the descriptor write
911    /// and the container orphan sweep still happen.
912    pub previous_version_context: Option<PreviousVersionContextConfig>,
913}