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