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