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, unit 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.1: a unit's scope map, one value per requested scope variable.
98pub type UnitScopes = BTreeMap<String, String>;
99
100/// §4.1 persisted unit status.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum UnitStatus {
104    Active,
105    Revoked,
106    Failed,
107}
108
109impl UnitStatus {
110    pub fn name(self) -> &'static str {
111        match self {
112            UnitStatus::Active => "active",
113            UnitStatus::Revoked => "revoked",
114            UnitStatus::Failed => "failed",
115        }
116    }
117}
118
119/// §4.4 one fenced record of a persisted echo: the effective lists the
120/// wildcard fence added for a covered table, or its revocation.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct EchoRecord {
123    pub table: String,
124    /// `active` or `revoked`.
125    pub status: String,
126    pub scopes: BTreeMap<String, Vec<String>>,
127}
128
129/// §4.1 one registered unit as the client persists it.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct UnitRecord {
133    /// §4.1: derived from the scope map.
134    pub id: String,
135    pub scopes: UnitScopes,
136    /// An explicit `subscribe` holds the unit; window bases hold it through
137    /// the window registry (§4.1 references).
138    pub explicit: bool,
139    pub status: UnitStatus,
140    /// §10 code when not active, or `sync.cursor_expired` after a reset.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub reason_code: Option<String>,
143    /// Absent while the unit is live (§4.5); otherwise `-1`, the pin, or the
144    /// post-bootstrap cursor `P`.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub cursor: Option<i64>,
147    /// Opaque resume token from `SUB_END` (§4.7); present mid-bootstrap.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub bootstrap_state: Option<String>,
150    /// Tables that received a segment in the current bootstrap (§5.6).
151    pub seeded: Vec<String>,
152    /// The echo persisted at the last `SUB_END` (§4.4).
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub echo: Option<Vec<EchoRecord>>,
155}
156
157impl UnitRecord {
158    /// §4.1: a catching-up unit holding cursor `-1` or a resume token.
159    pub fn bootstrapping(&self) -> bool {
160        self.cursor
161            .is_some_and(|cursor| cursor < 0 || self.bootstrap_state.is_some())
162    }
163}
164
165/// §4.8 window base: the selected scope variable plus the fixed scope values
166/// every unit of its family shares. A base names no table: each unit covers
167/// every table declaring its variables (§4.1).
168#[derive(Debug, Clone, Default)]
169pub struct WindowBase {
170    pub variable: String,
171    /// One value per other variable, shared by every unit.
172    pub fixed_scopes: UnitScopes,
173}
174
175/// §4.8 completeness oracle (I3): the windowed-in units for a base, plus
176/// the subset whose bootstrap has not yet completed. Registration alone is
177/// not completeness — a `pending` unit's local replica may be empty or
178/// partial (it still has `cursor: -1` or holds a resume token), and MUST NOT
179/// be rendered as complete. A unit with zero server
180/// rows still completes once its bootstrap round finishes.
181#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
182#[serde(rename_all = "camelCase")]
183pub struct WindowState {
184    /// Windowed-in units for this base, ordered by value.
185    pub units: Vec<String>,
186    /// Registered units whose bootstrap has not yet completed.
187    pub pending: Vec<String>,
188}
189
190#[cfg(test)]
191mod time_bucket_tests {
192    use super::{creation_time_bucket, last, TimeBucketUnit};
193
194    #[test]
195    fn utc_month_buckets_cross_year_boundaries() {
196        let now = 1_770_508_800_000; // 2026-02-08T00:00:00Z
197        assert_eq!(
198            creation_time_bucket(now, TimeBucketUnit::Month).unwrap(),
199            "2026-02"
200        );
201        assert_eq!(
202            last(3, TimeBucketUnit::Month, now).unwrap(),
203            vec!["2025-12", "2026-01", "2026-02"]
204        );
205    }
206
207    #[test]
208    fn utc_month_buckets_reject_unbounded_input() {
209        assert!(last(0, TimeBucketUnit::Month, 0).is_err());
210        assert!(last(1_201, TimeBucketUnit::Month, 0).is_err());
211        assert!(last(2, TimeBucketUnit::Month, 0).is_err());
212        assert!(creation_time_bucket(-1, TimeBucketUnit::Month).is_err());
213    }
214}
215
216#[derive(Debug, Clone, Serialize)]
217#[serde(rename_all = "camelCase")]
218pub struct TableChange {
219    pub table: String,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub scope_keys: Option<Vec<String>>,
222}
223
224#[derive(Debug, Clone, Serialize)]
225#[serde(rename_all = "camelCase")]
226pub struct WindowChange {
227    pub base_key: String,
228    /// Changed values of the base.
229    pub units: Vec<String>,
230}
231
232#[derive(Debug, Clone, Serialize)]
233#[serde(rename_all = "camelCase")]
234pub struct SyncStatusSnapshot {
235    pub current_schema_version: i32,
236    pub outbox: usize,
237    pub upgrading: bool,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub lease_state: Option<LeaseState>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub schema_floor: Option<SchemaFloor>,
242    pub sync_needed: bool,
243    /// RFC 0005 A2: container presence, so a host can enforce the downgrade
244    /// precondition (`previousVersionDiscard`) instead of documenting it.
245    pub previous_version_context: PreviousVersionStatus,
246}
247
248/// RFC 0005 A2: presence of the previous-version container file.
249#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
250#[serde(rename_all = "camelCase")]
251pub struct PreviousVersionStatus {
252    pub present: bool,
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub created_at_ms: Option<i64>,
255}
256
257pub const CLIENT_DIAGNOSTICS_VERSION: u8 = 2;
258pub const MAX_DIAGNOSTIC_QUERY_FAILURES: usize = 256;
259
260/// §8.8 client-local code: the `required` policy refused a sync round because
261/// the realtime socket is not connected. Never a wire code (§10.3).
262pub const REALTIME_UNAVAILABLE_CODE: &str = "sync.realtime_unavailable";
263/// §8.8 client-local code: a connected socket ended without a deliberate
264/// disconnect and the round reported no transport code. Never a wire code.
265pub const REALTIME_LOST_CODE: &str = "client.realtime_lost";
266/// §12 client-local code: a local-authority call or config on a host that
267/// has no local authority (the build lacks the `local-authority` feature, the
268/// config names no `localAuthorityPath`, or attach has no remote server).
269pub const LOCAL_AUTHORITY_UNCONFIGURED_CODE: &str = "client.local_authority_unconfigured";
270
271#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
272#[serde(rename_all = "camelCase")]
273pub struct ClientDiagnosticsHost {
274    pub kind: String,
275    pub role: String,
276    pub connectivity: String,
277    pub realtime: RealtimeState,
278    pub realtime_policy: RealtimePolicy,
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub realtime_reason_code: Option<String>,
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub realtime_retry_delay_ms: Option<u64>,
283}
284
285/// §7.6: registered units per state; a unit id never enters diagnostics.
286#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
287#[serde(rename_all = "camelCase")]
288pub struct DiagnosticUnits {
289    pub bootstrapping: usize,
290    pub complete: usize,
291    pub reset: usize,
292    pub revoked: usize,
293    pub failed: usize,
294    /// Distinct stable reason codes of registered units, ascending.
295    pub reason_codes: Vec<String>,
296}
297
298#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
299#[serde(rename_all = "camelCase")]
300pub struct DiagnosticRoundCounters {
301    pub pushed: u32,
302    pub applied: usize,
303    pub rejected: usize,
304    pub retryable: usize,
305    pub conflicts: u32,
306    pub commits_applied: u32,
307    pub segment_rows_applied: u32,
308    pub bootstrapping: usize,
309    pub resets: usize,
310    pub revoked: usize,
311    pub failed: usize,
312    pub deferred_commits: usize,
313}
314
315/// Bounded transport classification for progress and diagnostics (§1.8).
316#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
317#[serde(rename_all = "snake_case")]
318pub enum TransportCauseKind {
319    Network,
320    Connect,
321    Tls,
322    Io,
323    Request,
324    Protocol,
325    Timeout,
326    RoundDeadline,
327    Aborted,
328    Body,
329    Status,
330    Redirect,
331    Unknown,
332}
333
334#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
335#[serde(rename_all = "camelCase")]
336pub struct DiagnosticLastRound {
337    pub status: String,
338    pub started_at_ms: i64,
339    pub completed_at_ms: i64,
340    pub duration_ms: i64,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub counters: Option<DiagnosticRoundCounters>,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub error_code: Option<String>,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub cause_kind: Option<TransportCauseKind>,
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub http_status: Option<u16>,
349}
350
351#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
352#[serde(rename_all = "camelCase")]
353pub struct DiagnosticLastChange {
354    pub revision: String,
355    pub recorded_at_ms: i64,
356    pub tables: Vec<String>,
357    pub windows: Vec<String>,
358    pub domains_truncated: bool,
359    pub status_changed: bool,
360    pub conflicts_changed: bool,
361    pub rejections_changed: bool,
362    pub outcomes_changed: bool,
363}
364
365#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
366#[serde(rename_all = "camelCase")]
367pub struct ClientDiagnosticsStorage {
368    pub status: String,
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub database_bytes_approx: Option<i64>,
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub pending_outbox_bytes_approx: Option<i64>,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub retained_outcome_bytes_approx: Option<i64>,
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub retained_outcome_entries: Option<i64>,
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub blob_cache_bytes_approx: Option<i64>,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub pressure_reason_code: Option<String>,
381}
382
383/// §7.6: the latest owned snapshot read of one query id failed.
384#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
385#[serde(rename_all = "camelCase")]
386pub struct DiagnosticQueryFailure {
387    pub id: String,
388    /// Distinct generated table names, ascending.
389    pub tables: Vec<String>,
390    /// `client.storage_corrupt`, `client.storage_io`, or `client.query_failed`.
391    pub code: String,
392    /// SQLite extended result code, when the driver exposed one.
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub sqlite_code: Option<i32>,
395    /// First failure since this id's last successful read.
396    pub at_ms: i64,
397}
398
399#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
400#[serde(rename_all = "camelCase")]
401pub struct ClientDiagnosticsSnapshot {
402    pub version: u8,
403    pub captured_at_ms: i64,
404    pub host: ClientDiagnosticsHost,
405    pub security_lifecycle: String,
406    pub schema: ClientDiagnosticsSchema,
407    pub replica: ClientDiagnosticsReplica,
408    pub lease: ClientDiagnosticsLease,
409    pub units: DiagnosticUnits,
410    /// Units the next registration list carries; the server limit counts
411    /// these (§4.1).
412    pub active_units: usize,
413    /// §4.5 replica cursor; `-1` while no unit is live.
414    pub replica_cursor: i64,
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub last_round: Option<DiagnosticLastRound>,
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub last_change: Option<DiagnosticLastChange>,
419    pub storage: ClientDiagnosticsStorage,
420    pub query_failures: Vec<DiagnosticQueryFailure>,
421}
422
423#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
424#[serde(rename_all = "camelCase")]
425pub struct ClientDiagnosticsSchema {
426    pub current_version: i32,
427    pub upgrading: bool,
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub required_version: Option<i32>,
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub latest_version: Option<i32>,
432}
433
434#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
435#[serde(rename_all = "camelCase")]
436pub struct ClientDiagnosticsReplica {
437    pub local_revision: String,
438    pub sync_needed: bool,
439    pub pending_outbox: usize,
440}
441
442/// §8.8: how this client treats the realtime binding for sync rounds.
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
444#[serde(rename_all = "lowercase")]
445pub enum RealtimePolicy {
446    Required,
447    #[default]
448    Optional,
449    Off,
450}
451
452impl RealtimePolicy {
453    #[must_use]
454    pub fn as_str(self) -> &'static str {
455        match self {
456            RealtimePolicy::Required => "required",
457            RealtimePolicy::Optional => "optional",
458            RealtimePolicy::Off => "off",
459        }
460    }
461}
462
463/// §8.8: the explicit availability state of the realtime binding. The
464/// TypeScript host adds `unsupported` (no connector configured) and `unknown`
465/// (a wrapper without evidence); the Rust core reaches neither because a
466/// transport always carries a connect path.
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
468#[serde(rename_all = "lowercase")]
469pub enum RealtimeState {
470    Connected,
471    Connecting,
472    #[default]
473    Disconnected,
474    Lost,
475    Refused,
476    Disabled,
477}
478
479#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
480#[serde(rename_all = "camelCase")]
481pub struct ClientDiagnosticsLease {
482    pub state: String,
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub expires_at_ms: Option<i64>,
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub error_code: Option<String>,
487}
488
489/// JSON bindings carry the u64 revision as a decimal string (§7.5).
490#[derive(Debug, Clone, Serialize)]
491#[serde(rename_all = "camelCase")]
492pub struct AppliedServerCommit {
493    pub table: String,
494    pub commit_seq: i64,
495    pub source: AppliedServerCommitSource,
496}
497
498#[derive(Debug, Clone, Copy, Serialize)]
499#[serde(rename_all = "camelCase")]
500pub enum AppliedServerCommitSource {
501    Delta,
502    Pull,
503}
504
505#[derive(Debug, Clone, Serialize)]
506#[serde(rename_all = "camelCase")]
507pub struct ClientChangeBatch {
508    pub revision: String,
509    pub tables: Vec<TableChange>,
510    pub windows: Vec<WindowChange>,
511    #[serde(skip_serializing_if = "Option::is_none")]
512    pub status: Option<SyncStatusSnapshot>,
513    pub conflicts_changed: bool,
514    pub rejections_changed: bool,
515    pub outcomes_changed: bool,
516    #[serde(skip_serializing_if = "Vec::is_empty")]
517    pub applied_commits: Vec<AppliedServerCommit>,
518}
519
520#[derive(Debug, Clone, Serialize)]
521#[serde(tag = "kind", rename_all = "camelCase")]
522pub enum SyncIntent {
523    None,
524    Interactive,
525    Background {
526        #[serde(rename = "delayMs")]
527        delay_ms: u64,
528    },
529}
530
531#[derive(Debug, Clone, Serialize)]
532pub struct CommandEffects {
533    pub sync: SyncIntent,
534}
535
536impl CommandEffects {
537    #[must_use]
538    pub fn none() -> Self {
539        Self {
540            sync: SyncIntent::None,
541        }
542    }
543
544    #[must_use]
545    pub fn interactive() -> Self {
546        Self {
547            sync: SyncIntent::Interactive,
548        }
549    }
550}
551
552#[derive(Debug, Clone)]
553pub struct WindowCoverage {
554    pub base: WindowBase,
555    pub units: Vec<String>,
556}
557
558#[derive(Debug, Clone, Serialize)]
559#[serde(rename_all = "camelCase")]
560pub struct WindowUnitRef {
561    pub base_key: String,
562    pub unit: String,
563}
564
565#[derive(Debug, Clone, Serialize)]
566#[serde(rename_all = "camelCase")]
567pub struct CoverageSnapshot {
568    pub complete: bool,
569    pub pending: Vec<WindowUnitRef>,
570    pub missing: Vec<WindowUnitRef>,
571}
572
573/// §7.5: the query that owns a snapshot read. The id is application-owned,
574/// stable, and PHI-free; generated named queries pass their `ID` and `TABLES`.
575#[derive(Debug, Clone, Copy)]
576pub struct QueryOwner<'a> {
577    pub id: &'a str,
578    pub tables: &'a [&'a str],
579}
580
581/// §7.5: a failed snapshot read or a failed local authoring write. `code` is
582/// `client.storage_corrupt`, `client.storage_io`, `client.storage_full`, or
583/// `client.storage_busy` when SQLite reported that condition, and `None` for
584/// every other failure, which keeps its existing message.
585#[derive(Debug, Clone, PartialEq, Eq)]
586pub struct QueryReadFailure {
587    pub code: Option<&'static str>,
588    pub sqlite_code: Option<i32>,
589    pub sqlite_message: Option<String>,
590    pub rollback_failure: Option<Box<QueryReadFailure>>,
591    pub message: String,
592}
593
594impl std::fmt::Display for QueryReadFailure {
595    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
596        match self.code {
597            Some(code) => write!(f, "{code}: {}", self.message),
598            None => f.write_str(&self.message),
599        }
600    }
601}
602
603impl From<String> for QueryReadFailure {
604    fn from(message: String) -> Self {
605        Self {
606            code: None,
607            sqlite_code: None,
608            sqlite_message: None,
609            rollback_failure: None,
610            message,
611        }
612    }
613}
614
615impl From<rusqlite::Error> for QueryReadFailure {
616    /// Classify by SQLite's primary result code, never by message text.
617    fn from(error: rusqlite::Error) -> Self {
618        let sqlite_code = match &error {
619            rusqlite::Error::SqliteFailure(failure, _) => Some(failure.extended_code),
620            _ => None,
621        };
622        match sqlite_code.map(|code| code & 0xff) {
623            Some(11 | 26) => Self {
624                code: Some("client.storage_corrupt"),
625                sqlite_code,
626                sqlite_message: Some(error.to_string()),
627                rollback_failure: None,
628                message: "local SQLite storage is corrupt".to_owned(),
629            },
630            Some(13) => Self {
631                code: Some("client.storage_full"),
632                sqlite_code,
633                sqlite_message: Some(error.to_string()),
634                rollback_failure: None,
635                message: "local SQLite storage is full".to_owned(),
636            },
637            Some(10) => Self {
638                code: Some("client.storage_io"),
639                sqlite_code,
640                sqlite_message: Some(error.to_string()),
641                rollback_failure: None,
642                message: "local SQLite storage I/O failed".to_owned(),
643            },
644            // SQLITE_BUSY (5) and SQLITE_LOCKED (6), including every extended
645            // code, are transient lock contention: the same operation can
646            // succeed after the conflicting operation releases its lock.
647            Some(5 | 6) => Self {
648                code: Some("client.storage_busy"),
649                sqlite_code,
650                sqlite_message: Some(error.to_string()),
651                rollback_failure: None,
652                message: "local SQLite storage is busy".to_owned(),
653            },
654            _ => Self {
655                code: None,
656                sqlite_code,
657                sqlite_message: Some(error.to_string()),
658                rollback_failure: None,
659                message: error.to_string(),
660            },
661        }
662    }
663}
664
665impl QueryReadFailure {
666    /// The operation can be retried after lock contention is resolved.
667    /// SQLITE_LOCKED can involve another statement on the same connection.
668    pub fn retryable(&self) -> bool {
669        self.code == Some("client.storage_busy")
670    }
671
672    pub fn details(&self) -> Option<Value> {
673        self.sqlite_code.map(|code| {
674            let mut details =
675                serde_json::json!({ "sqliteCode": code, "sqliteMessage": self.sqlite_message });
676            if let Some(rollback) = &self.rollback_failure {
677                details["rollbackFailure"] = serde_json::json!({
678                    "sqliteCode": rollback.sqlite_code, "message": rollback.sqlite_message
679                });
680            }
681            details
682        })
683    }
684}
685
686/// A structured failure from a local authoring API (`mutate`/`patch`). `code`
687/// is always a stable `client.*`/`sync.*` identity, `message` is static text
688/// with no table/row/column values, `details` carries SQLite classification
689/// evidence or the preserved legacy cause, and `retryable` is true only for
690/// transient storage contention. Callers read these fields instead of parsing
691/// a `"<code>: "` prefix.
692#[derive(Debug, Clone, PartialEq, Eq)]
693pub struct ClientError {
694    pub code: String,
695    pub message: String,
696    pub details: Option<Value>,
697    pub retryable: bool,
698}
699
700/// The stable default for an authoring failure with no recognized code.
701const DEFAULT_CLIENT_ERROR_CODE: &str = "client.failed";
702
703/// The static text used when no specific static authoring message exists.
704const GENERIC_AUTHORING_MESSAGE: &str = "the local authoring operation failed";
705
706/// The fixed user-facing text for a known authoring code. Dynamic values from
707/// a legacy cause string move to `details.legacyCause` instead.
708fn static_authoring_message(code: &str) -> Option<&'static str> {
709    match code {
710        "sync.unknown_table" => Some("the commit targets an unknown table"),
711        "sync.invalid_request" => Some("the authoring request is invalid"),
712        "sync.row_missing" => Some("a sparse patch requires a local row"),
713        "sync.constraint_violation" => Some("local write violates a unique constraint"),
714        DEFAULT_CLIENT_ERROR_CODE => Some(GENERIC_AUTHORING_MESSAGE),
715        _ => None,
716    }
717}
718
719fn is_client_code(candidate: &str) -> bool {
720    candidate.starts_with("client.") || candidate.starts_with("sync.")
721}
722
723impl std::fmt::Display for ClientError {
724    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
725        write!(f, "{}: {}", self.code, self.message)
726    }
727}
728
729impl std::error::Error for ClientError {}
730
731impl From<QueryReadFailure> for ClientError {
732    fn from(failure: QueryReadFailure) -> Self {
733        match failure.code {
734            // Classified storage messages are already static.
735            Some(code) => {
736                let details = failure.details();
737                let retryable = failure.retryable();
738                Self {
739                    code: code.to_owned(),
740                    message: failure.message,
741                    details,
742                    retryable,
743                }
744            }
745            None => {
746                let mut details = failure.details().unwrap_or_else(|| serde_json::json!({}));
747                if let Some(object) = details.as_object_mut() {
748                    object.insert("legacyCause".to_owned(), Value::String(failure.message));
749                }
750                Self {
751                    code: DEFAULT_CLIENT_ERROR_CODE.to_owned(),
752                    message: GENERIC_AUTHORING_MESSAGE.to_owned(),
753                    details: Some(details),
754                    retryable: false,
755                }
756            }
757        }
758    }
759}
760
761impl From<String> for ClientError {
762    /// Split a core error into structured fields. A recognized code identity
763    /// is preserved and gets static text; any dynamic cause stays in
764    /// `details.legacyCause`. Everything else defaults to `client.failed` with
765    /// the generic message.
766    fn from(message: String) -> Self {
767        // A code-only identity, e.g. `sync.unknown_table`.
768        if !message.contains(": ") && is_client_code(&message) {
769            return Self {
770                code: message.clone(),
771                message: static_authoring_message(&message)
772                    .unwrap_or(GENERIC_AUTHORING_MESSAGE)
773                    .to_owned(),
774                details: None,
775                retryable: false,
776            };
777        }
778        let (code, rest) = match message.split_once(": ") {
779            Some((code, rest)) if is_client_code(code) => (code, Some(rest)),
780            _ => (DEFAULT_CLIENT_ERROR_CODE, None),
781        };
782        match static_authoring_message(code) {
783            Some(fixed) => Self {
784                code: code.to_owned(),
785                message: fixed.to_owned(),
786                details: (rest != Some(fixed))
787                    .then(|| serde_json::json!({ "legacyCause": message })),
788                retryable: false,
789            },
790            None => Self {
791                code: code.to_owned(),
792                message: GENERIC_AUTHORING_MESSAGE.to_owned(),
793                details: Some(serde_json::json!({ "legacyCause": message })),
794                retryable: false,
795            },
796        }
797    }
798}
799
800#[derive(Debug, Clone, Serialize)]
801#[serde(rename_all = "camelCase")]
802pub struct QuerySnapshot {
803    pub revision: String,
804    pub rows: Vec<QueryRow>,
805    pub coverage: CoverageSnapshot,
806}
807
808impl WindowState {
809    /// The per-unit verdict: registered AND bootstrap-complete.
810    #[must_use]
811    pub fn complete(&self, unit: &str) -> bool {
812        self.units.iter().any(|u| u == unit) && !self.pending.iter().any(|u| u == unit)
813    }
814}
815
816/// One local mutation (§6.1 shapes, schema-agnostic local form per §0).
817#[derive(Debug, Clone)]
818pub enum Mutation {
819    Patch {
820        table: String,
821        values: Map<String, Value>,
822        base_version: Option<i64>,
823    },
824    Upsert {
825        table: String,
826        values: Map<String, Value>,
827        base_version: Option<i64>,
828    },
829    Delete {
830        table: String,
831        row_id: String,
832        base_version: Option<i64>,
833    },
834}
835
836#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
837#[serde(rename_all = "camelCase")]
838pub struct SchemaFloor {
839    #[serde(skip_serializing_if = "Option::is_none")]
840    pub required_schema_version: Option<i32>,
841    #[serde(skip_serializing_if = "Option::is_none")]
842    pub latest_schema_version: Option<i32>,
843}
844
845/// §7.3.5: the client's opaque auth-lease state — `leaseId`/`expiresAtMs`
846/// from the last `LEASE` frame, and `errorCode` once a round was rejected
847/// with a request-level lease code (stop-and-surface; no data purge).
848#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
849#[serde(rename_all = "camelCase")]
850pub struct LeaseState {
851    #[serde(skip_serializing_if = "Option::is_none")]
852    pub lease_id: Option<String>,
853    #[serde(skip_serializing_if = "Option::is_none")]
854    pub expires_at_ms: Option<i64>,
855    #[serde(skip_serializing_if = "Option::is_none")]
856    pub error_code: Option<String>,
857}
858
859/// §8.6 a peer's ephemeral presence document on a scope key.
860#[derive(Debug, Clone, Serialize)]
861#[serde(rename_all = "camelCase")]
862pub struct PresencePeer {
863    pub actor_id: String,
864    pub client_id: String,
865    pub doc: serde_json::Value,
866}
867
868#[derive(Debug, Clone, Serialize, Default)]
869#[serde(rename_all = "camelCase")]
870pub struct SyncReport {
871    pub pushed: u32,
872    pub applied: Vec<String>,
873    pub rejected: Vec<String>,
874    pub retryable: Vec<String>,
875    pub conflicts: u32,
876    pub commits_applied: u32,
877    pub segment_rows_applied: u32,
878    pub bootstrapping: Vec<String>,
879    pub resets: Vec<String>,
880    pub revoked: Vec<String>,
881    pub failed: Vec<String>,
882    pub deferred_commits: usize,
883    #[serde(skip_serializing_if = "Option::is_none")]
884    pub schema_floor: Option<SchemaFloor>,
885}
886
887impl SyncReport {
888    /// Aggregate completed rounds. Historical counters and outcome lists
889    /// accumulate; current readiness (`bootstrapping`, `deferred_commits`,
890    /// `schema_floor`) describes the latest round. A schema floor stops the
891    /// loop, so it can only arrive from the latest round.
892    pub fn merge(&mut self, report: &Self) {
893        self.pushed += report.pushed;
894        self.applied.extend(report.applied.iter().cloned());
895        self.rejected.extend(report.rejected.iter().cloned());
896        self.retryable.extend(report.retryable.iter().cloned());
897        self.conflicts += report.conflicts;
898        self.commits_applied += report.commits_applied;
899        self.segment_rows_applied += report.segment_rows_applied;
900        self.bootstrapping = report.bootstrapping.clone();
901        self.resets.extend(report.resets.iter().cloned());
902        self.revoked.extend(report.revoked.iter().cloned());
903        self.failed.extend(report.failed.iter().cloned());
904        self.deferred_commits = report.deferred_commits;
905        self.schema_floor = report.schema_floor.clone();
906    }
907}
908
909/// `sync()` never panics or errors out-of-band: transport and protocol
910/// failures come back as `Failed` (the driver's `{ ok: false }`).
911#[derive(Debug, Clone)]
912pub enum SyncOutcome {
913    Ok(SyncReport),
914    Failed {
915        error_code: String,
916        message: String,
917        details: Option<Value>,
918        /// §10.1 `recommendedAction` from the server's error body, when the
919        /// transport preserved one. Client-local and storage failures carry
920        /// `None`.
921        recommended_action: Option<String>,
922    },
923    /// §8.8: the `required` policy refused a round because the socket is not
924    /// connected. `reason_code` is present for `lost` and `refused` states,
925    /// and `retry_delay_ms` is the delay of the background retry intent this
926    /// outcome scheduled.
927    RealtimeUnavailable {
928        state: RealtimeState,
929        reason_code: Option<String>,
930        retry_delay_ms: u64,
931    },
932    /// §7.8: the round budget ran out while the client was still not idle.
933    /// The aggregate report of every round that ran is retained. This is a
934    /// partial success, not a failure, and MUST NOT be read as idle.
935    BudgetExhausted(SyncReport),
936}
937
938impl SyncOutcome {
939    pub fn to_json(&self) -> Value {
940        match self {
941            SyncOutcome::Ok(report) => {
942                let mut map = Map::new();
943                map.insert("ok".to_owned(), Value::Bool(true));
944                map.insert(
945                    "report".to_owned(),
946                    serde_json::to_value(report).expect("report serializes"),
947                );
948                Value::Object(map)
949            }
950            SyncOutcome::Failed {
951                error_code,
952                message,
953                details,
954                recommended_action,
955            } => {
956                let mut map = Map::new();
957                map.insert("ok".to_owned(), Value::Bool(false));
958                map.insert("errorCode".to_owned(), Value::from(error_code.clone()));
959                map.insert("message".to_owned(), Value::from(message.clone()));
960                if let Some(details) = details {
961                    map.insert("details".to_owned(), details.clone());
962                }
963                if let Some(recommended_action) = recommended_action {
964                    map.insert(
965                        "recommendedAction".to_owned(),
966                        Value::from(recommended_action.clone()),
967                    );
968                }
969                Value::Object(map)
970            }
971            SyncOutcome::RealtimeUnavailable {
972                state,
973                reason_code,
974                retry_delay_ms,
975            } => {
976                let mut map = Map::new();
977                map.insert("ok".to_owned(), Value::Bool(false));
978                map.insert(
979                    "errorCode".to_owned(),
980                    Value::from(REALTIME_UNAVAILABLE_CODE),
981                );
982                map.insert(
983                    "message".to_owned(),
984                    Value::from("realtime is required and the realtime socket is not connected"),
985                );
986                map.insert(
987                    "realtimeState".to_owned(),
988                    serde_json::to_value(state).expect("state serializes"),
989                );
990                if let Some(reason_code) = reason_code {
991                    map.insert(
992                        "realtimeReasonCode".to_owned(),
993                        Value::from(reason_code.clone()),
994                    );
995                }
996                map.insert("retryDelayMs".to_owned(), Value::from(*retry_delay_ms));
997                Value::Object(map)
998            }
999            SyncOutcome::BudgetExhausted(report) => {
1000                let mut map = Map::new();
1001                map.insert("ok".to_owned(), Value::Bool(true));
1002                map.insert("budgetExhausted".to_owned(), Value::Bool(true));
1003                map.insert(
1004                    "report".to_owned(),
1005                    serde_json::to_value(report).expect("report serializes"),
1006                );
1007                Value::Object(map)
1008            }
1009        }
1010    }
1011}
1012
1013#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1014#[serde(rename_all = "camelCase")]
1015pub struct ConflictRecord {
1016    pub client_commit_id: String,
1017    pub op_index: i32,
1018    pub table: String,
1019    pub row_id: String,
1020    pub code: String,
1021    pub message: String,
1022    pub server_version: i64,
1023    /// Driver row (bytes as `{"$bytes": hex}`), decoded from the conflict
1024    /// record's `serverRow` (§6.3).
1025    pub server_row: Map<String, Value>,
1026    /// §6.3 `conflictColumns`: the present columns whose `column_version`
1027    /// exceeded `baseVersion`, decoded from the bitmap into column names in
1028    /// declaration order. Absent on a legacy journal entry, which reads as an
1029    /// empty list.
1030    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1031    pub conflict_columns: Vec<String>,
1032    #[serde(skip_serializing_if = "Option::is_none")]
1033    pub operation: Option<CommitOperation>,
1034}
1035
1036/// Bounded code-like metadata explicitly declared safe for authorized client
1037/// recovery UI. Diagnostic prose remains in `RejectionRecord.message`.
1038#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1039#[serde(rename_all = "camelCase", deny_unknown_fields)]
1040pub struct RejectionDetails {
1041    #[serde(skip_serializing_if = "Option::is_none")]
1042    pub field_paths: Option<Vec<String>>,
1043    #[serde(skip_serializing_if = "Option::is_none")]
1044    pub reason: Option<String>,
1045    #[serde(skip_serializing_if = "Option::is_none")]
1046    pub required_action: Option<String>,
1047    #[serde(skip_serializing_if = "Option::is_none")]
1048    pub references: Option<BTreeMap<String, String>>,
1049}
1050
1051impl RejectionDetails {
1052    pub(crate) fn parse(raw: &str) -> Result<Self, String> {
1053        if raw.len() > 4_096 {
1054            return Err("rejection details exceed 4096 encoded bytes".to_owned());
1055        }
1056        let details: Self = serde_json::from_str(raw)
1057            .map_err(|error| format!("invalid rejection details: {error}"))?;
1058        details.validate()?;
1059        Ok(details)
1060    }
1061
1062    fn validate(&self) -> Result<(), String> {
1063        let mut members = 0;
1064        if let Some(paths) = &self.field_paths {
1065            members += 1;
1066            if paths.is_empty() || paths.len() > 32 {
1067                return Err("fieldPaths must contain 1-32 paths".to_owned());
1068            }
1069            let mut seen = std::collections::BTreeSet::new();
1070            for path in paths {
1071                if path.len() > 160 || !path.split('.').all(valid_identifier) || !seen.insert(path)
1072                {
1073                    return Err("fieldPaths contains an invalid or duplicate path".to_owned());
1074                }
1075            }
1076        }
1077        if let Some(reason) = &self.reason {
1078            members += 1;
1079            if !valid_token(reason, 96) {
1080                return Err("reason must be a lowercase stable token".to_owned());
1081            }
1082        }
1083        if let Some(action) = &self.required_action {
1084            members += 1;
1085            if !valid_token(action, 96) {
1086                return Err("requiredAction must be a lowercase stable token".to_owned());
1087            }
1088        }
1089        if let Some(references) = &self.references {
1090            members += 1;
1091            if references.is_empty() || references.len() > 16 {
1092                return Err("references must contain 1-16 entries".to_owned());
1093            }
1094            for (key, value) in references {
1095                if !valid_token(key, 64)
1096                    || value.is_empty()
1097                    || value.len() > 256
1098                    || value.trim() != value
1099                    || value.chars().any(char::is_control)
1100                {
1101                    return Err("references contains an invalid key or value".to_owned());
1102                }
1103            }
1104        }
1105        if members == 0 {
1106            return Err("rejection details must not be empty".to_owned());
1107        }
1108        Ok(())
1109    }
1110}
1111
1112fn valid_identifier(segment: &str) -> bool {
1113    let mut chars = segment.chars();
1114    matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic())
1115        && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
1116}
1117
1118fn valid_token(token: &str, max: usize) -> bool {
1119    if token.is_empty() || token.len() > max || !token.starts_with(|c: char| c.is_ascii_lowercase())
1120    {
1121        return false;
1122    }
1123    let mut previous_separator = false;
1124    for character in token.chars() {
1125        if character.is_ascii_lowercase() || character.is_ascii_digit() {
1126            previous_separator = false;
1127        } else if matches!(character, '.' | '_' | '-') && !previous_separator {
1128            previous_separator = true;
1129        } else {
1130            return false;
1131        }
1132    }
1133    !previous_separator
1134}
1135
1136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1137#[serde(rename_all = "camelCase")]
1138pub struct RejectionRecord {
1139    pub client_commit_id: String,
1140    pub op_index: i32,
1141    pub code: String,
1142    pub message: String,
1143    pub retryable: bool,
1144    #[serde(skip_serializing_if = "Option::is_none")]
1145    pub details: Option<RejectionDetails>,
1146    #[serde(skip_serializing_if = "Option::is_none")]
1147    pub operation: Option<CommitOperation>,
1148}
1149
1150/// Schema-agnostic local operation retained with a failed final outcome.
1151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1152#[serde(rename_all = "camelCase")]
1153pub struct CommitOperation {
1154    pub table: String,
1155    pub row_id: String,
1156    pub op: String,
1157    #[serde(skip_serializing_if = "Option::is_none")]
1158    pub base_version: Option<i64>,
1159    #[serde(skip_serializing_if = "Option::is_none")]
1160    pub values: Option<Map<String, Value>>,
1161    /// §7.1 insert intent: serialized as `"insert": true`, absent otherwise.
1162    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1163    pub insert: bool,
1164}
1165
1166#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1167#[serde(rename_all = "snake_case")]
1168pub enum CommitOutcomeStatus {
1169    Applied,
1170    Cached,
1171    Conflict,
1172    Rejected,
1173}
1174
1175#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1176#[serde(rename_all = "snake_case")]
1177pub enum CommitOutcomeResolution {
1178    Active,
1179    ResolvedKeepServer,
1180    Superseded,
1181    Dismissed,
1182}
1183
1184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1185#[serde(tag = "status", rename_all = "snake_case")]
1186pub enum CommitOperationOutcome {
1187    Applied {
1188        #[serde(rename = "opIndex")]
1189        op_index: i32,
1190    },
1191    Conflict {
1192        conflict: ConflictRecord,
1193    },
1194    Error {
1195        rejection: RejectionRecord,
1196    },
1197}
1198
1199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1200#[serde(rename_all = "camelCase")]
1201pub struct RetainedUniqueConflict {
1202    pub index: String,
1203    pub columns: Vec<String>,
1204    pub row_id: String,
1205    pub server_row: Map<String, Value>,
1206    pub server_version: i64,
1207}
1208
1209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1210#[serde(rename_all = "camelCase")]
1211pub struct RetainedCommitRow {
1212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1213    pub unique_conflicts: Vec<RetainedUniqueConflict>,
1214    pub table: String,
1215    pub row_id: String,
1216    pub local_row: Option<Map<String, Value>>,
1217    /// The latest delivered authorized base; rejection can precede the pull.
1218    pub server_row: Option<Map<String, Value>>,
1219    pub server_version: Option<i64>,
1220}
1221
1222/// Result of proving every retained base after a completed pull (§7.2.1).
1223#[derive(Debug, Clone, Serialize, PartialEq)]
1224#[serde(rename_all = "camelCase")]
1225pub struct CurrentBaseProof {
1226    pub proven: bool,
1227    #[serde(skip_serializing_if = "Option::is_none")]
1228    pub rows: Option<Vec<RetainedCommitRow>>,
1229    #[serde(skip_serializing_if = "Option::is_none")]
1230    pub reason: Option<CurrentBaseProofReason>,
1231}
1232
1233#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
1234#[serde(rename_all = "snake_case")]
1235pub enum CurrentBaseProofReason {
1236    Offline,
1237    SyncFailed,
1238    SchemaFloor,
1239    BudgetExhausted,
1240    SnapshotFailed,
1241    MissingCoverage,
1242    Revoked,
1243    OutcomeUnavailable,
1244}
1245
1246impl CurrentBaseProof {
1247    pub fn failure(reason: CurrentBaseProofReason) -> Self {
1248        Self {
1249            proven: false,
1250            rows: None,
1251            reason: Some(reason),
1252        }
1253    }
1254}
1255
1256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1257#[serde(rename_all = "camelCase")]
1258pub struct CommitOutcome {
1259    pub sequence: i64,
1260    pub client_commit_id: String,
1261    pub status: CommitOutcomeStatus,
1262    pub recorded_at_ms: i64,
1263    pub results: Vec<CommitOperationOutcome>,
1264    /// Complete local failed-commit envelope retained after outbox drain.
1265    /// Absent for successful and historical outcomes; never sent over wire.
1266    #[serde(skip_serializing_if = "Option::is_none")]
1267    pub operations: Option<Vec<CommitOperation>>,
1268    #[serde(skip_serializing_if = "Option::is_none")]
1269    pub retained_rows: Option<Vec<RetainedCommitRow>>,
1270    pub resolution: CommitOutcomeResolution,
1271    #[serde(skip_serializing_if = "Option::is_none")]
1272    pub resolved_at_ms: Option<i64>,
1273    #[serde(skip_serializing_if = "Option::is_none")]
1274    pub replacement_client_commit_id: Option<String>,
1275}
1276
1277#[derive(Debug, Clone, Deserialize, Default)]
1278#[serde(rename_all = "camelCase")]
1279pub struct CommitOutcomeQuery {
1280    pub limit: Option<usize>,
1281    #[serde(default)]
1282    pub active_only: bool,
1283}
1284
1285/// §7.5: the catch-up state of one unit as persisted in the snapshot, never
1286/// the mutable owner cache. `Unknown` means the client does not currently
1287/// hold the unit (never registered, released, or removed).
1288#[derive(Debug, Clone, Serialize)]
1289#[serde(
1290    tag = "state",
1291    rename_all = "camelCase",
1292    rename_all_fields = "camelCase"
1293)]
1294pub enum UnitCatchup {
1295    Unknown {
1296        id: String,
1297    },
1298    Known {
1299        id: String,
1300        status: UnitStatus,
1301        live: bool,
1302        /// The unit's cursor while it catches up, the replica cursor while live.
1303        cursor: i64,
1304        has_resume_token: bool,
1305        /// True only for an `active` unit that is live or post-bootstrap.
1306        bootstrap_complete: bool,
1307        /// True only for an `active` unit with cursor `-1` or a resume token.
1308        known_pending_pages: bool,
1309        #[serde(skip_serializing_if = "Option::is_none")]
1310        reason_code: Option<String>,
1311    },
1312}
1313
1314/// §7.5: delivery status for one client commit id. A commit still in the
1315/// outbox is `pending`; a drained commit carries its persisted retained
1316/// outcome; an id the client never held, or whose outcome was pruned by
1317/// retention, is `unknown`. The outcome preserves every operation result,
1318/// rejection code, and conflict. This is the persisted-journal view in both
1319/// cores: it omits the owner-derived `retained_rows` images that
1320/// `SyncClient::commit_outcome` attaches from its in-memory failed-commit
1321/// cache.
1322#[derive(Debug, Clone, Serialize)]
1323#[serde(tag = "status", rename_all = "snake_case")]
1324pub enum CommitDelivery {
1325    Pending {
1326        #[serde(rename = "clientCommitId")]
1327        client_commit_id: String,
1328    },
1329    Known {
1330        #[serde(rename = "clientCommitId")]
1331        client_commit_id: String,
1332        outcome: CommitOutcome,
1333    },
1334    Unknown {
1335        #[serde(rename = "clientCommitId")]
1336        client_commit_id: String,
1337    },
1338}
1339
1340/// §7.5: the result of one atomic snapshot read over a single SQLite read
1341/// transaction: one revision, every requested read-only statement's rows,
1342/// requested window coverage, requested unit catch-up states, and requested
1343/// commit deliveries.
1344#[derive(Debug, Clone, Serialize)]
1345#[serde(rename_all = "camelCase")]
1346pub struct SnapshotRead {
1347    pub revision: String,
1348    pub queries: Vec<Vec<QueryRow>>,
1349    pub coverage: CoverageSnapshot,
1350    pub units: Vec<UnitCatchup>,
1351    pub deliveries: Vec<CommitDelivery>,
1352}
1353
1354#[derive(Debug, Clone, Deserialize)]
1355#[serde(rename_all = "camelCase")]
1356pub struct ResolveCommitOutcomeInput {
1357    pub client_commit_id: String,
1358    pub resolution: CommitOutcomeResolution,
1359    pub replacement_client_commit_id: Option<String>,
1360}
1361
1362#[derive(Debug, Clone, Serialize)]
1363#[serde(rename_all = "camelCase")]
1364pub struct RowState {
1365    pub row_id: String,
1366    /// Local synced version: `-1` = optimistic, else the server version
1367    /// (from a `COMMIT` change or a segment row record, §5.2/§5.6).
1368    pub version: i64,
1369    pub values: Map<String, Value>,
1370}
1371
1372/// One AND-combined application-authorized local purge target. Targets in
1373/// one input are OR-combined. Selector columns must compile to plaintext
1374/// strings; values are bounded code-like routing identifiers.
1375#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1376#[serde(rename_all = "camelCase", deny_unknown_fields)]
1377pub struct LocalDataPurgeTarget {
1378    pub table: String,
1379    pub selectors: BTreeMap<String, Vec<String>>,
1380}
1381
1382/// Durable local idempotency key plus exact routing targets. The host owns
1383/// directive authenticity and subscription gating; the client owns SQLite.
1384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1385#[serde(rename_all = "camelCase", deny_unknown_fields)]
1386pub struct LocalDataPurgeInput {
1387    pub purge_id: String,
1388    pub targets: Vec<LocalDataPurgeTarget>,
1389}
1390
1391/// Privacy-safe local purge acknowledgement; row ids never cross the bridge.
1392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1393#[serde(rename_all = "camelCase")]
1394pub struct LocalDataPurgeResult {
1395    pub already_applied: bool,
1396    pub purged_rows: usize,
1397    pub dropped_commits: usize,
1398}
1399
1400/// Durable application repair id. Reusing the id is an exact no-op.
1401#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1402#[serde(rename_all = "camelCase", deny_unknown_fields)]
1403pub struct LocalDataRebootstrapInput {
1404    pub rebootstrap_id: String,
1405}
1406
1407/// Privacy-safe acknowledgement for a replicated-projection recovery.
1408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1409#[serde(rename_all = "camelCase")]
1410pub struct LocalDataRebootstrapResult {
1411    pub already_applied: bool,
1412    pub retained_commits: usize,
1413    pub reset_subscriptions: usize,
1414}
1415
1416/// Client limits (§4.2 request knobs).
1417#[derive(Debug, Clone, Default)]
1418pub struct ClientLimits {
1419    pub limit_commits: Option<i32>,
1420    pub limit_snapshot_rows: Option<i32>,
1421    pub max_snapshot_pages: Option<i32>,
1422    /// §4.2 accept bitmask; this client defaults to `0b0111` (rows
1423    /// baseline + sqlite images, §5.3 — rusqlite can always import).
1424    pub accept: Option<u8>,
1425    /// §5.9.7 B1 blob-cache size cap (bytes). When set and the sum of cached
1426    /// body sizes exceeds it, unreferenced bodies without pending work are
1427    /// evicted by creation timestamp after each cache write. `None` retains bodies
1428    /// until storage pressure (default).
1429    pub blob_cache_max_bytes: Option<i64>,
1430    /// Maximum durable final outcomes. Active conflicts/rejections are never
1431    /// pruned to satisfy the cap. Defaults to 1,000.
1432    pub outcome_retention_max_entries: Option<usize>,
1433    /// §7.1: maximum whole commits in one push request. `None` is unbounded.
1434    pub max_push_commits_per_request: Option<usize>,
1435    /// §7.1: maximum operations across the push commits in one request. `None`
1436    /// uses the reference default of 500. A first commit that alone exceeds it
1437    /// is a typed `client.push_request_too_large` capacity error and stays
1438    /// queued.
1439    pub max_push_operations_per_request: Option<usize>,
1440    /// §7.1: maximum bytes of the complete encoded SSP2 request (8-byte
1441    /// envelope, request header, push commits, pull header, units and progress frames,
1442    /// framing, and any §5.11 ciphertext). `None` is unbounded. A request whose
1443    /// fixed frames alone exceed it, or whose first commit alone does not fit,
1444    /// fails with `client.push_request_too_large` and keeps its intent.
1445    pub max_push_request_bytes: Option<usize>,
1446    /// RFC 0005 D8: the previous-version capture config. Absent or
1447    /// `enabled: false` runs the feature off (default); the descriptor write
1448    /// and the container orphan sweep still happen.
1449    pub previous_version_context: Option<PreviousVersionContextConfig>,
1450    /// Immutable, explicit preflight authority read policy.
1451    pub authority_reads: Vec<AuthorityReadDeclaration>,
1452}
1453
1454/// Static read policy; never accepted as snapshot parameters.
1455#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1456#[serde(rename_all = "camelCase", deny_unknown_fields)]
1457pub struct AuthorityReadDeclaration {
1458    pub table: String,
1459    pub columns: Vec<String>,
1460    pub scopes: std::collections::BTreeMap<String, Vec<String>>,
1461}