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