Skip to main content

syncular_client/
api.rs

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