Skip to main content

syncular_client/
api.rs

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