1use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11use crate::previous_version::PreviousVersionContextConfig;
12pub type QueryValue = Value;
14pub type QueryRow = Map<String, QueryValue>;
16
17#[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(×tamp_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
55pub 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
65pub 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
97pub type UnitScopes = BTreeMap<String, String>;
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum UnitStatus {
104 Active,
105 Revoked,
106 Failed,
107}
108
109impl UnitStatus {
110 pub fn name(self) -> &'static str {
111 match self {
112 UnitStatus::Active => "active",
113 UnitStatus::Revoked => "revoked",
114 UnitStatus::Failed => "failed",
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct EchoRecord {
123 pub table: String,
124 pub status: String,
126 pub scopes: BTreeMap<String, Vec<String>>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct UnitRecord {
133 pub id: String,
135 pub scopes: UnitScopes,
136 pub explicit: bool,
139 pub status: UnitStatus,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub reason_code: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
146 pub cursor: Option<i64>,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub bootstrap_state: Option<String>,
150 pub seeded: Vec<String>,
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub echo: Option<Vec<EchoRecord>>,
155}
156
157impl UnitRecord {
158 pub fn bootstrapping(&self) -> bool {
160 self.cursor
161 .is_some_and(|cursor| cursor < 0 || self.bootstrap_state.is_some())
162 }
163}
164
165#[derive(Debug, Clone, Default)]
169pub struct WindowBase {
170 pub variable: String,
171 pub fixed_scopes: UnitScopes,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
182#[serde(rename_all = "camelCase")]
183pub struct WindowState {
184 pub units: Vec<String>,
186 pub pending: Vec<String>,
188}
189
190#[cfg(test)]
191mod time_bucket_tests {
192 use super::{creation_time_bucket, last, TimeBucketUnit};
193
194 #[test]
195 fn utc_month_buckets_cross_year_boundaries() {
196 let now = 1_770_508_800_000; assert_eq!(
198 creation_time_bucket(now, TimeBucketUnit::Month).unwrap(),
199 "2026-02"
200 );
201 assert_eq!(
202 last(3, TimeBucketUnit::Month, now).unwrap(),
203 vec!["2025-12", "2026-01", "2026-02"]
204 );
205 }
206
207 #[test]
208 fn utc_month_buckets_reject_unbounded_input() {
209 assert!(last(0, TimeBucketUnit::Month, 0).is_err());
210 assert!(last(1_201, TimeBucketUnit::Month, 0).is_err());
211 assert!(last(2, TimeBucketUnit::Month, 0).is_err());
212 assert!(creation_time_bucket(-1, TimeBucketUnit::Month).is_err());
213 }
214}
215
216#[derive(Debug, Clone, Serialize)]
217#[serde(rename_all = "camelCase")]
218pub struct TableChange {
219 pub table: String,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub scope_keys: Option<Vec<String>>,
222}
223
224#[derive(Debug, Clone, Serialize)]
225#[serde(rename_all = "camelCase")]
226pub struct WindowChange {
227 pub base_key: String,
228 pub units: Vec<String>,
230}
231
232#[derive(Debug, Clone, Serialize)]
233#[serde(rename_all = "camelCase")]
234pub struct SyncStatusSnapshot {
235 pub current_schema_version: i32,
236 pub outbox: usize,
237 pub upgrading: bool,
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub lease_state: Option<LeaseState>,
240 #[serde(skip_serializing_if = "Option::is_none")]
241 pub schema_floor: Option<SchemaFloor>,
242 pub sync_needed: bool,
243 pub previous_version_context: PreviousVersionStatus,
246}
247
248#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
250#[serde(rename_all = "camelCase")]
251pub struct PreviousVersionStatus {
252 pub present: bool,
253 #[serde(skip_serializing_if = "Option::is_none")]
254 pub created_at_ms: Option<i64>,
255}
256
257pub const CLIENT_DIAGNOSTICS_VERSION: u8 = 2;
258pub const MAX_DIAGNOSTIC_QUERY_FAILURES: usize = 256;
259
260pub const REALTIME_UNAVAILABLE_CODE: &str = "sync.realtime_unavailable";
263pub const REALTIME_LOST_CODE: &str = "client.realtime_lost";
266pub const LOCAL_AUTHORITY_UNCONFIGURED_CODE: &str = "client.local_authority_unconfigured";
270
271#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
272#[serde(rename_all = "camelCase")]
273pub struct ClientDiagnosticsHost {
274 pub kind: String,
275 pub role: String,
276 pub connectivity: String,
277 pub realtime: RealtimeState,
278 pub realtime_policy: RealtimePolicy,
279 #[serde(skip_serializing_if = "Option::is_none")]
280 pub realtime_reason_code: Option<String>,
281 #[serde(skip_serializing_if = "Option::is_none")]
282 pub realtime_retry_delay_ms: Option<u64>,
283}
284
285#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
287#[serde(rename_all = "camelCase")]
288pub struct DiagnosticUnits {
289 pub bootstrapping: usize,
290 pub complete: usize,
291 pub reset: usize,
292 pub revoked: usize,
293 pub failed: usize,
294 pub reason_codes: Vec<String>,
296}
297
298#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
299#[serde(rename_all = "camelCase")]
300pub struct DiagnosticRoundCounters {
301 pub pushed: u32,
302 pub applied: usize,
303 pub rejected: usize,
304 pub retryable: usize,
305 pub conflicts: u32,
306 pub commits_applied: u32,
307 pub segment_rows_applied: u32,
308 pub bootstrapping: usize,
309 pub resets: usize,
310 pub revoked: usize,
311 pub failed: usize,
312 pub deferred_commits: usize,
313}
314
315#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
317#[serde(rename_all = "snake_case")]
318pub enum TransportCauseKind {
319 Network,
320 Connect,
321 Tls,
322 Io,
323 Request,
324 Protocol,
325 Timeout,
326 RoundDeadline,
327 Aborted,
328 Body,
329 Status,
330 Redirect,
331 Unknown,
332}
333
334#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
335#[serde(rename_all = "camelCase")]
336pub struct DiagnosticLastRound {
337 pub status: String,
338 pub started_at_ms: i64,
339 pub completed_at_ms: i64,
340 pub duration_ms: i64,
341 #[serde(skip_serializing_if = "Option::is_none")]
342 pub counters: Option<DiagnosticRoundCounters>,
343 #[serde(skip_serializing_if = "Option::is_none")]
344 pub error_code: Option<String>,
345 #[serde(skip_serializing_if = "Option::is_none")]
346 pub cause_kind: Option<TransportCauseKind>,
347 #[serde(skip_serializing_if = "Option::is_none")]
348 pub http_status: Option<u16>,
349}
350
351#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
352#[serde(rename_all = "camelCase")]
353pub struct DiagnosticLastChange {
354 pub revision: String,
355 pub recorded_at_ms: i64,
356 pub tables: Vec<String>,
357 pub windows: Vec<String>,
358 pub domains_truncated: bool,
359 pub status_changed: bool,
360 pub conflicts_changed: bool,
361 pub rejections_changed: bool,
362 pub outcomes_changed: bool,
363}
364
365#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
366#[serde(rename_all = "camelCase")]
367pub struct ClientDiagnosticsStorage {
368 pub status: String,
369 #[serde(skip_serializing_if = "Option::is_none")]
370 pub database_bytes_approx: Option<i64>,
371 #[serde(skip_serializing_if = "Option::is_none")]
372 pub pending_outbox_bytes_approx: Option<i64>,
373 #[serde(skip_serializing_if = "Option::is_none")]
374 pub retained_outcome_bytes_approx: Option<i64>,
375 #[serde(skip_serializing_if = "Option::is_none")]
376 pub retained_outcome_entries: Option<i64>,
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub blob_cache_bytes_approx: Option<i64>,
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub pressure_reason_code: Option<String>,
381}
382
383#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
385#[serde(rename_all = "camelCase")]
386pub struct DiagnosticQueryFailure {
387 pub id: String,
388 pub tables: Vec<String>,
390 pub code: String,
392 #[serde(skip_serializing_if = "Option::is_none")]
394 pub sqlite_code: Option<i32>,
395 pub at_ms: i64,
397}
398
399#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
400#[serde(rename_all = "camelCase")]
401pub struct ClientDiagnosticsSnapshot {
402 pub version: u8,
403 pub captured_at_ms: i64,
404 pub host: ClientDiagnosticsHost,
405 pub security_lifecycle: String,
406 pub schema: ClientDiagnosticsSchema,
407 pub replica: ClientDiagnosticsReplica,
408 pub lease: ClientDiagnosticsLease,
409 pub units: DiagnosticUnits,
410 pub active_units: usize,
413 pub replica_cursor: i64,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub last_round: Option<DiagnosticLastRound>,
417 #[serde(skip_serializing_if = "Option::is_none")]
418 pub last_change: Option<DiagnosticLastChange>,
419 pub storage: ClientDiagnosticsStorage,
420 pub query_failures: Vec<DiagnosticQueryFailure>,
421}
422
423#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
424#[serde(rename_all = "camelCase")]
425pub struct ClientDiagnosticsSchema {
426 pub current_version: i32,
427 pub upgrading: bool,
428 #[serde(skip_serializing_if = "Option::is_none")]
429 pub required_version: Option<i32>,
430 #[serde(skip_serializing_if = "Option::is_none")]
431 pub latest_version: Option<i32>,
432}
433
434#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
435#[serde(rename_all = "camelCase")]
436pub struct ClientDiagnosticsReplica {
437 pub local_revision: String,
438 pub sync_needed: bool,
439 pub pending_outbox: usize,
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
444#[serde(rename_all = "lowercase")]
445pub enum RealtimePolicy {
446 Required,
447 #[default]
448 Optional,
449 Off,
450}
451
452impl RealtimePolicy {
453 #[must_use]
454 pub fn as_str(self) -> &'static str {
455 match self {
456 RealtimePolicy::Required => "required",
457 RealtimePolicy::Optional => "optional",
458 RealtimePolicy::Off => "off",
459 }
460 }
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
468#[serde(rename_all = "lowercase")]
469pub enum RealtimeState {
470 Connected,
471 Connecting,
472 #[default]
473 Disconnected,
474 Lost,
475 Refused,
476 Disabled,
477}
478
479#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
480#[serde(rename_all = "camelCase")]
481pub struct ClientDiagnosticsLease {
482 pub state: String,
483 #[serde(skip_serializing_if = "Option::is_none")]
484 pub expires_at_ms: Option<i64>,
485 #[serde(skip_serializing_if = "Option::is_none")]
486 pub error_code: Option<String>,
487}
488
489#[derive(Debug, Clone, Serialize)]
491#[serde(rename_all = "camelCase")]
492pub struct AppliedServerCommit {
493 pub table: String,
494 pub commit_seq: i64,
495 pub source: AppliedServerCommitSource,
496}
497
498#[derive(Debug, Clone, Copy, Serialize)]
499#[serde(rename_all = "camelCase")]
500pub enum AppliedServerCommitSource {
501 Delta,
502 Pull,
503}
504
505#[derive(Debug, Clone, Serialize)]
506#[serde(rename_all = "camelCase")]
507pub struct ClientChangeBatch {
508 pub revision: String,
509 pub tables: Vec<TableChange>,
510 pub windows: Vec<WindowChange>,
511 #[serde(skip_serializing_if = "Option::is_none")]
512 pub status: Option<SyncStatusSnapshot>,
513 pub conflicts_changed: bool,
514 pub rejections_changed: bool,
515 pub outcomes_changed: bool,
516 #[serde(skip_serializing_if = "Vec::is_empty")]
517 pub applied_commits: Vec<AppliedServerCommit>,
518}
519
520#[derive(Debug, Clone, Serialize)]
521#[serde(tag = "kind", rename_all = "camelCase")]
522pub enum SyncIntent {
523 None,
524 Interactive,
525 Background {
526 #[serde(rename = "delayMs")]
527 delay_ms: u64,
528 },
529}
530
531#[derive(Debug, Clone, Serialize)]
532pub struct CommandEffects {
533 pub sync: SyncIntent,
534}
535
536impl CommandEffects {
537 #[must_use]
538 pub fn none() -> Self {
539 Self {
540 sync: SyncIntent::None,
541 }
542 }
543
544 #[must_use]
545 pub fn interactive() -> Self {
546 Self {
547 sync: SyncIntent::Interactive,
548 }
549 }
550}
551
552#[derive(Debug, Clone)]
553pub struct WindowCoverage {
554 pub base: WindowBase,
555 pub units: Vec<String>,
556}
557
558#[derive(Debug, Clone, Serialize)]
559#[serde(rename_all = "camelCase")]
560pub struct WindowUnitRef {
561 pub base_key: String,
562 pub unit: String,
563}
564
565#[derive(Debug, Clone, Serialize)]
566#[serde(rename_all = "camelCase")]
567pub struct CoverageSnapshot {
568 pub complete: bool,
569 pub pending: Vec<WindowUnitRef>,
570 pub missing: Vec<WindowUnitRef>,
571}
572
573#[derive(Debug, Clone, Copy)]
576pub struct QueryOwner<'a> {
577 pub id: &'a str,
578 pub tables: &'a [&'a str],
579}
580
581#[derive(Debug, Clone, PartialEq, Eq)]
586pub struct QueryReadFailure {
587 pub code: Option<&'static str>,
588 pub sqlite_code: Option<i32>,
589 pub sqlite_message: Option<String>,
590 pub rollback_failure: Option<Box<QueryReadFailure>>,
591 pub message: String,
592}
593
594impl std::fmt::Display for QueryReadFailure {
595 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
596 match self.code {
597 Some(code) => write!(f, "{code}: {}", self.message),
598 None => f.write_str(&self.message),
599 }
600 }
601}
602
603impl From<String> for QueryReadFailure {
604 fn from(message: String) -> Self {
605 Self {
606 code: None,
607 sqlite_code: None,
608 sqlite_message: None,
609 rollback_failure: None,
610 message,
611 }
612 }
613}
614
615impl From<rusqlite::Error> for QueryReadFailure {
616 fn from(error: rusqlite::Error) -> Self {
618 let sqlite_code = match &error {
619 rusqlite::Error::SqliteFailure(failure, _) => Some(failure.extended_code),
620 _ => None,
621 };
622 match sqlite_code.map(|code| code & 0xff) {
623 Some(11 | 26) => Self {
624 code: Some("client.storage_corrupt"),
625 sqlite_code,
626 sqlite_message: Some(error.to_string()),
627 rollback_failure: None,
628 message: "local SQLite storage is corrupt".to_owned(),
629 },
630 Some(13) => Self {
631 code: Some("client.storage_full"),
632 sqlite_code,
633 sqlite_message: Some(error.to_string()),
634 rollback_failure: None,
635 message: "local SQLite storage is full".to_owned(),
636 },
637 Some(10) => Self {
638 code: Some("client.storage_io"),
639 sqlite_code,
640 sqlite_message: Some(error.to_string()),
641 rollback_failure: None,
642 message: "local SQLite storage I/O failed".to_owned(),
643 },
644 Some(5 | 6) => Self {
648 code: Some("client.storage_busy"),
649 sqlite_code,
650 sqlite_message: Some(error.to_string()),
651 rollback_failure: None,
652 message: "local SQLite storage is busy".to_owned(),
653 },
654 _ => Self {
655 code: None,
656 sqlite_code,
657 sqlite_message: Some(error.to_string()),
658 rollback_failure: None,
659 message: error.to_string(),
660 },
661 }
662 }
663}
664
665impl QueryReadFailure {
666 pub fn retryable(&self) -> bool {
669 self.code == Some("client.storage_busy")
670 }
671
672 pub fn details(&self) -> Option<Value> {
673 self.sqlite_code.map(|code| {
674 let mut details =
675 serde_json::json!({ "sqliteCode": code, "sqliteMessage": self.sqlite_message });
676 if let Some(rollback) = &self.rollback_failure {
677 details["rollbackFailure"] = serde_json::json!({
678 "sqliteCode": rollback.sqlite_code, "message": rollback.sqlite_message
679 });
680 }
681 details
682 })
683 }
684}
685
686#[derive(Debug, Clone, PartialEq, Eq)]
693pub struct ClientError {
694 pub code: String,
695 pub message: String,
696 pub details: Option<Value>,
697 pub retryable: bool,
698}
699
700const DEFAULT_CLIENT_ERROR_CODE: &str = "client.failed";
702
703const GENERIC_AUTHORING_MESSAGE: &str = "the local authoring operation failed";
705
706fn static_authoring_message(code: &str) -> Option<&'static str> {
709 match code {
710 "sync.unknown_table" => Some("the commit targets an unknown table"),
711 "sync.invalid_request" => Some("the authoring request is invalid"),
712 "sync.row_missing" => Some("a sparse patch requires a local row"),
713 "sync.constraint_violation" => Some("local write violates a unique constraint"),
714 DEFAULT_CLIENT_ERROR_CODE => Some(GENERIC_AUTHORING_MESSAGE),
715 _ => None,
716 }
717}
718
719fn is_client_code(candidate: &str) -> bool {
720 candidate.starts_with("client.") || candidate.starts_with("sync.")
721}
722
723impl std::fmt::Display for ClientError {
724 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
725 write!(f, "{}: {}", self.code, self.message)
726 }
727}
728
729impl std::error::Error for ClientError {}
730
731impl From<QueryReadFailure> for ClientError {
732 fn from(failure: QueryReadFailure) -> Self {
733 match failure.code {
734 Some(code) => {
736 let details = failure.details();
737 let retryable = failure.retryable();
738 Self {
739 code: code.to_owned(),
740 message: failure.message,
741 details,
742 retryable,
743 }
744 }
745 None => {
746 let mut details = failure.details().unwrap_or_else(|| serde_json::json!({}));
747 if let Some(object) = details.as_object_mut() {
748 object.insert("legacyCause".to_owned(), Value::String(failure.message));
749 }
750 Self {
751 code: DEFAULT_CLIENT_ERROR_CODE.to_owned(),
752 message: GENERIC_AUTHORING_MESSAGE.to_owned(),
753 details: Some(details),
754 retryable: false,
755 }
756 }
757 }
758 }
759}
760
761impl From<String> for ClientError {
762 fn from(message: String) -> Self {
767 if !message.contains(": ") && is_client_code(&message) {
769 return Self {
770 code: message.clone(),
771 message: static_authoring_message(&message)
772 .unwrap_or(GENERIC_AUTHORING_MESSAGE)
773 .to_owned(),
774 details: None,
775 retryable: false,
776 };
777 }
778 let (code, rest) = match message.split_once(": ") {
779 Some((code, rest)) if is_client_code(code) => (code, Some(rest)),
780 _ => (DEFAULT_CLIENT_ERROR_CODE, None),
781 };
782 match static_authoring_message(code) {
783 Some(fixed) => Self {
784 code: code.to_owned(),
785 message: fixed.to_owned(),
786 details: (rest != Some(fixed))
787 .then(|| serde_json::json!({ "legacyCause": message })),
788 retryable: false,
789 },
790 None => Self {
791 code: code.to_owned(),
792 message: GENERIC_AUTHORING_MESSAGE.to_owned(),
793 details: Some(serde_json::json!({ "legacyCause": message })),
794 retryable: false,
795 },
796 }
797 }
798}
799
800#[derive(Debug, Clone, Serialize)]
801#[serde(rename_all = "camelCase")]
802pub struct QuerySnapshot {
803 pub revision: String,
804 pub rows: Vec<QueryRow>,
805 pub coverage: CoverageSnapshot,
806}
807
808impl WindowState {
809 #[must_use]
811 pub fn complete(&self, unit: &str) -> bool {
812 self.units.iter().any(|u| u == unit) && !self.pending.iter().any(|u| u == unit)
813 }
814}
815
816#[derive(Debug, Clone)]
818pub enum Mutation {
819 Patch {
820 table: String,
821 values: Map<String, Value>,
822 base_version: Option<i64>,
823 },
824 Upsert {
825 table: String,
826 values: Map<String, Value>,
827 base_version: Option<i64>,
828 },
829 Delete {
830 table: String,
831 row_id: String,
832 base_version: Option<i64>,
833 },
834}
835
836#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
837#[serde(rename_all = "camelCase")]
838pub struct SchemaFloor {
839 #[serde(skip_serializing_if = "Option::is_none")]
840 pub required_schema_version: Option<i32>,
841 #[serde(skip_serializing_if = "Option::is_none")]
842 pub latest_schema_version: Option<i32>,
843}
844
845#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
849#[serde(rename_all = "camelCase")]
850pub struct LeaseState {
851 #[serde(skip_serializing_if = "Option::is_none")]
852 pub lease_id: Option<String>,
853 #[serde(skip_serializing_if = "Option::is_none")]
854 pub expires_at_ms: Option<i64>,
855 #[serde(skip_serializing_if = "Option::is_none")]
856 pub error_code: Option<String>,
857}
858
859#[derive(Debug, Clone, Serialize)]
861#[serde(rename_all = "camelCase")]
862pub struct PresencePeer {
863 pub actor_id: String,
864 pub client_id: String,
865 pub doc: serde_json::Value,
866}
867
868#[derive(Debug, Clone, Serialize, Default)]
869#[serde(rename_all = "camelCase")]
870pub struct SyncReport {
871 pub pushed: u32,
872 pub applied: Vec<String>,
873 pub rejected: Vec<String>,
874 pub retryable: Vec<String>,
875 pub conflicts: u32,
876 pub commits_applied: u32,
877 pub segment_rows_applied: u32,
878 pub bootstrapping: Vec<String>,
879 pub resets: Vec<String>,
880 pub revoked: Vec<String>,
881 pub failed: Vec<String>,
882 pub deferred_commits: usize,
883 #[serde(skip_serializing_if = "Option::is_none")]
884 pub schema_floor: Option<SchemaFloor>,
885}
886
887impl SyncReport {
888 pub fn merge(&mut self, report: &Self) {
893 self.pushed += report.pushed;
894 self.applied.extend(report.applied.iter().cloned());
895 self.rejected.extend(report.rejected.iter().cloned());
896 self.retryable.extend(report.retryable.iter().cloned());
897 self.conflicts += report.conflicts;
898 self.commits_applied += report.commits_applied;
899 self.segment_rows_applied += report.segment_rows_applied;
900 self.bootstrapping = report.bootstrapping.clone();
901 self.resets.extend(report.resets.iter().cloned());
902 self.revoked.extend(report.revoked.iter().cloned());
903 self.failed.extend(report.failed.iter().cloned());
904 self.deferred_commits = report.deferred_commits;
905 self.schema_floor = report.schema_floor.clone();
906 }
907}
908
909#[derive(Debug, Clone)]
912pub enum SyncOutcome {
913 Ok(SyncReport),
914 Failed {
915 error_code: String,
916 message: String,
917 details: Option<Value>,
918 recommended_action: Option<String>,
922 },
923 RealtimeUnavailable {
928 state: RealtimeState,
929 reason_code: Option<String>,
930 retry_delay_ms: u64,
931 },
932 BudgetExhausted(SyncReport),
936}
937
938impl SyncOutcome {
939 pub fn to_json(&self) -> Value {
940 match self {
941 SyncOutcome::Ok(report) => {
942 let mut map = Map::new();
943 map.insert("ok".to_owned(), Value::Bool(true));
944 map.insert(
945 "report".to_owned(),
946 serde_json::to_value(report).expect("report serializes"),
947 );
948 Value::Object(map)
949 }
950 SyncOutcome::Failed {
951 error_code,
952 message,
953 details,
954 recommended_action,
955 } => {
956 let mut map = Map::new();
957 map.insert("ok".to_owned(), Value::Bool(false));
958 map.insert("errorCode".to_owned(), Value::from(error_code.clone()));
959 map.insert("message".to_owned(), Value::from(message.clone()));
960 if let Some(details) = details {
961 map.insert("details".to_owned(), details.clone());
962 }
963 if let Some(recommended_action) = recommended_action {
964 map.insert(
965 "recommendedAction".to_owned(),
966 Value::from(recommended_action.clone()),
967 );
968 }
969 Value::Object(map)
970 }
971 SyncOutcome::RealtimeUnavailable {
972 state,
973 reason_code,
974 retry_delay_ms,
975 } => {
976 let mut map = Map::new();
977 map.insert("ok".to_owned(), Value::Bool(false));
978 map.insert(
979 "errorCode".to_owned(),
980 Value::from(REALTIME_UNAVAILABLE_CODE),
981 );
982 map.insert(
983 "message".to_owned(),
984 Value::from("realtime is required and the realtime socket is not connected"),
985 );
986 map.insert(
987 "realtimeState".to_owned(),
988 serde_json::to_value(state).expect("state serializes"),
989 );
990 if let Some(reason_code) = reason_code {
991 map.insert(
992 "realtimeReasonCode".to_owned(),
993 Value::from(reason_code.clone()),
994 );
995 }
996 map.insert("retryDelayMs".to_owned(), Value::from(*retry_delay_ms));
997 Value::Object(map)
998 }
999 SyncOutcome::BudgetExhausted(report) => {
1000 let mut map = Map::new();
1001 map.insert("ok".to_owned(), Value::Bool(true));
1002 map.insert("budgetExhausted".to_owned(), Value::Bool(true));
1003 map.insert(
1004 "report".to_owned(),
1005 serde_json::to_value(report).expect("report serializes"),
1006 );
1007 Value::Object(map)
1008 }
1009 }
1010 }
1011}
1012
1013#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1014#[serde(rename_all = "camelCase")]
1015pub struct ConflictRecord {
1016 pub client_commit_id: String,
1017 pub op_index: i32,
1018 pub table: String,
1019 pub row_id: String,
1020 pub code: String,
1021 pub message: String,
1022 pub server_version: i64,
1023 pub server_row: Map<String, Value>,
1026 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1031 pub conflict_columns: Vec<String>,
1032 #[serde(skip_serializing_if = "Option::is_none")]
1033 pub operation: Option<CommitOperation>,
1034}
1035
1036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1039#[serde(rename_all = "camelCase", deny_unknown_fields)]
1040pub struct RejectionDetails {
1041 #[serde(skip_serializing_if = "Option::is_none")]
1042 pub field_paths: Option<Vec<String>>,
1043 #[serde(skip_serializing_if = "Option::is_none")]
1044 pub reason: Option<String>,
1045 #[serde(skip_serializing_if = "Option::is_none")]
1046 pub required_action: Option<String>,
1047 #[serde(skip_serializing_if = "Option::is_none")]
1048 pub references: Option<BTreeMap<String, String>>,
1049}
1050
1051impl RejectionDetails {
1052 pub(crate) fn parse(raw: &str) -> Result<Self, String> {
1053 if raw.len() > 4_096 {
1054 return Err("rejection details exceed 4096 encoded bytes".to_owned());
1055 }
1056 let details: Self = serde_json::from_str(raw)
1057 .map_err(|error| format!("invalid rejection details: {error}"))?;
1058 details.validate()?;
1059 Ok(details)
1060 }
1061
1062 fn validate(&self) -> Result<(), String> {
1063 let mut members = 0;
1064 if let Some(paths) = &self.field_paths {
1065 members += 1;
1066 if paths.is_empty() || paths.len() > 32 {
1067 return Err("fieldPaths must contain 1-32 paths".to_owned());
1068 }
1069 let mut seen = std::collections::BTreeSet::new();
1070 for path in paths {
1071 if path.len() > 160 || !path.split('.').all(valid_identifier) || !seen.insert(path)
1072 {
1073 return Err("fieldPaths contains an invalid or duplicate path".to_owned());
1074 }
1075 }
1076 }
1077 if let Some(reason) = &self.reason {
1078 members += 1;
1079 if !valid_token(reason, 96) {
1080 return Err("reason must be a lowercase stable token".to_owned());
1081 }
1082 }
1083 if let Some(action) = &self.required_action {
1084 members += 1;
1085 if !valid_token(action, 96) {
1086 return Err("requiredAction must be a lowercase stable token".to_owned());
1087 }
1088 }
1089 if let Some(references) = &self.references {
1090 members += 1;
1091 if references.is_empty() || references.len() > 16 {
1092 return Err("references must contain 1-16 entries".to_owned());
1093 }
1094 for (key, value) in references {
1095 if !valid_token(key, 64)
1096 || value.is_empty()
1097 || value.len() > 256
1098 || value.trim() != value
1099 || value.chars().any(char::is_control)
1100 {
1101 return Err("references contains an invalid key or value".to_owned());
1102 }
1103 }
1104 }
1105 if members == 0 {
1106 return Err("rejection details must not be empty".to_owned());
1107 }
1108 Ok(())
1109 }
1110}
1111
1112fn valid_identifier(segment: &str) -> bool {
1113 let mut chars = segment.chars();
1114 matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic())
1115 && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
1116}
1117
1118fn valid_token(token: &str, max: usize) -> bool {
1119 if token.is_empty() || token.len() > max || !token.starts_with(|c: char| c.is_ascii_lowercase())
1120 {
1121 return false;
1122 }
1123 let mut previous_separator = false;
1124 for character in token.chars() {
1125 if character.is_ascii_lowercase() || character.is_ascii_digit() {
1126 previous_separator = false;
1127 } else if matches!(character, '.' | '_' | '-') && !previous_separator {
1128 previous_separator = true;
1129 } else {
1130 return false;
1131 }
1132 }
1133 !previous_separator
1134}
1135
1136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1137#[serde(rename_all = "camelCase")]
1138pub struct RejectionRecord {
1139 pub client_commit_id: String,
1140 pub op_index: i32,
1141 pub code: String,
1142 pub message: String,
1143 pub retryable: bool,
1144 #[serde(skip_serializing_if = "Option::is_none")]
1145 pub details: Option<RejectionDetails>,
1146 #[serde(skip_serializing_if = "Option::is_none")]
1147 pub operation: Option<CommitOperation>,
1148}
1149
1150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1152#[serde(rename_all = "camelCase")]
1153pub struct CommitOperation {
1154 pub table: String,
1155 pub row_id: String,
1156 pub op: String,
1157 #[serde(skip_serializing_if = "Option::is_none")]
1158 pub base_version: Option<i64>,
1159 #[serde(skip_serializing_if = "Option::is_none")]
1160 pub values: Option<Map<String, Value>>,
1161 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1163 pub insert: bool,
1164}
1165
1166#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1167#[serde(rename_all = "snake_case")]
1168pub enum CommitOutcomeStatus {
1169 Applied,
1170 Cached,
1171 Conflict,
1172 Rejected,
1173}
1174
1175#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1176#[serde(rename_all = "snake_case")]
1177pub enum CommitOutcomeResolution {
1178 Active,
1179 ResolvedKeepServer,
1180 Superseded,
1181 Dismissed,
1182}
1183
1184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1185#[serde(tag = "status", rename_all = "snake_case")]
1186pub enum CommitOperationOutcome {
1187 Applied {
1188 #[serde(rename = "opIndex")]
1189 op_index: i32,
1190 },
1191 Conflict {
1192 conflict: ConflictRecord,
1193 },
1194 Error {
1195 rejection: RejectionRecord,
1196 },
1197}
1198
1199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1200#[serde(rename_all = "camelCase")]
1201pub struct RetainedUniqueConflict {
1202 pub index: String,
1203 pub columns: Vec<String>,
1204 pub row_id: String,
1205 pub server_row: Map<String, Value>,
1206 pub server_version: i64,
1207}
1208
1209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1210#[serde(rename_all = "camelCase")]
1211pub struct RetainedCommitRow {
1212 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1213 pub unique_conflicts: Vec<RetainedUniqueConflict>,
1214 pub table: String,
1215 pub row_id: String,
1216 pub local_row: Option<Map<String, Value>>,
1217 pub server_row: Option<Map<String, Value>>,
1219 pub server_version: Option<i64>,
1220}
1221
1222#[derive(Debug, Clone, Serialize, PartialEq)]
1224#[serde(rename_all = "camelCase")]
1225pub struct CurrentBaseProof {
1226 pub proven: bool,
1227 #[serde(skip_serializing_if = "Option::is_none")]
1228 pub rows: Option<Vec<RetainedCommitRow>>,
1229 #[serde(skip_serializing_if = "Option::is_none")]
1230 pub reason: Option<CurrentBaseProofReason>,
1231}
1232
1233#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
1234#[serde(rename_all = "snake_case")]
1235pub enum CurrentBaseProofReason {
1236 Offline,
1237 SyncFailed,
1238 SchemaFloor,
1239 BudgetExhausted,
1240 SnapshotFailed,
1241 MissingCoverage,
1242 Revoked,
1243 OutcomeUnavailable,
1244}
1245
1246impl CurrentBaseProof {
1247 pub fn failure(reason: CurrentBaseProofReason) -> Self {
1248 Self {
1249 proven: false,
1250 rows: None,
1251 reason: Some(reason),
1252 }
1253 }
1254}
1255
1256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1257#[serde(rename_all = "camelCase")]
1258pub struct CommitOutcome {
1259 pub sequence: i64,
1260 pub client_commit_id: String,
1261 pub status: CommitOutcomeStatus,
1262 pub recorded_at_ms: i64,
1263 pub results: Vec<CommitOperationOutcome>,
1264 #[serde(skip_serializing_if = "Option::is_none")]
1267 pub operations: Option<Vec<CommitOperation>>,
1268 #[serde(skip_serializing_if = "Option::is_none")]
1269 pub retained_rows: Option<Vec<RetainedCommitRow>>,
1270 pub resolution: CommitOutcomeResolution,
1271 #[serde(skip_serializing_if = "Option::is_none")]
1272 pub resolved_at_ms: Option<i64>,
1273 #[serde(skip_serializing_if = "Option::is_none")]
1274 pub replacement_client_commit_id: Option<String>,
1275}
1276
1277#[derive(Debug, Clone, Deserialize, Default)]
1278#[serde(rename_all = "camelCase")]
1279pub struct CommitOutcomeQuery {
1280 pub limit: Option<usize>,
1281 #[serde(default)]
1282 pub active_only: bool,
1283}
1284
1285#[derive(Debug, Clone, Serialize)]
1289#[serde(
1290 tag = "state",
1291 rename_all = "camelCase",
1292 rename_all_fields = "camelCase"
1293)]
1294pub enum UnitCatchup {
1295 Unknown {
1296 id: String,
1297 },
1298 Known {
1299 id: String,
1300 status: UnitStatus,
1301 live: bool,
1302 cursor: i64,
1304 has_resume_token: bool,
1305 bootstrap_complete: bool,
1307 known_pending_pages: bool,
1309 #[serde(skip_serializing_if = "Option::is_none")]
1310 reason_code: Option<String>,
1311 },
1312}
1313
1314#[derive(Debug, Clone, Serialize)]
1323#[serde(tag = "status", rename_all = "snake_case")]
1324pub enum CommitDelivery {
1325 Pending {
1326 #[serde(rename = "clientCommitId")]
1327 client_commit_id: String,
1328 },
1329 Known {
1330 #[serde(rename = "clientCommitId")]
1331 client_commit_id: String,
1332 outcome: CommitOutcome,
1333 },
1334 Unknown {
1335 #[serde(rename = "clientCommitId")]
1336 client_commit_id: String,
1337 },
1338}
1339
1340#[derive(Debug, Clone, Serialize)]
1345#[serde(rename_all = "camelCase")]
1346pub struct SnapshotRead {
1347 pub revision: String,
1348 pub queries: Vec<Vec<QueryRow>>,
1349 pub coverage: CoverageSnapshot,
1350 pub units: Vec<UnitCatchup>,
1351 pub deliveries: Vec<CommitDelivery>,
1352}
1353
1354#[derive(Debug, Clone, Deserialize)]
1355#[serde(rename_all = "camelCase")]
1356pub struct ResolveCommitOutcomeInput {
1357 pub client_commit_id: String,
1358 pub resolution: CommitOutcomeResolution,
1359 pub replacement_client_commit_id: Option<String>,
1360}
1361
1362#[derive(Debug, Clone, Serialize)]
1363#[serde(rename_all = "camelCase")]
1364pub struct RowState {
1365 pub row_id: String,
1366 pub version: i64,
1369 pub values: Map<String, Value>,
1370}
1371
1372#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1376#[serde(rename_all = "camelCase", deny_unknown_fields)]
1377pub struct LocalDataPurgeTarget {
1378 pub table: String,
1379 pub selectors: BTreeMap<String, Vec<String>>,
1380}
1381
1382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1385#[serde(rename_all = "camelCase", deny_unknown_fields)]
1386pub struct LocalDataPurgeInput {
1387 pub purge_id: String,
1388 pub targets: Vec<LocalDataPurgeTarget>,
1389}
1390
1391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1393#[serde(rename_all = "camelCase")]
1394pub struct LocalDataPurgeResult {
1395 pub already_applied: bool,
1396 pub purged_rows: usize,
1397 pub dropped_commits: usize,
1398}
1399
1400#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1402#[serde(rename_all = "camelCase", deny_unknown_fields)]
1403pub struct LocalDataRebootstrapInput {
1404 pub rebootstrap_id: String,
1405}
1406
1407#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1409#[serde(rename_all = "camelCase")]
1410pub struct LocalDataRebootstrapResult {
1411 pub already_applied: bool,
1412 pub retained_commits: usize,
1413 pub reset_subscriptions: usize,
1414}
1415
1416#[derive(Debug, Clone, Default)]
1418pub struct ClientLimits {
1419 pub limit_commits: Option<i32>,
1420 pub limit_snapshot_rows: Option<i32>,
1421 pub max_snapshot_pages: Option<i32>,
1422 pub accept: Option<u8>,
1425 pub blob_cache_max_bytes: Option<i64>,
1430 pub outcome_retention_max_entries: Option<usize>,
1433 pub max_push_commits_per_request: Option<usize>,
1435 pub max_push_operations_per_request: Option<usize>,
1440 pub max_push_request_bytes: Option<usize>,
1446 pub previous_version_context: Option<PreviousVersionContextConfig>,
1450 pub authority_reads: Vec<AuthorityReadDeclaration>,
1452}
1453
1454#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1456#[serde(rename_all = "camelCase", deny_unknown_fields)]
1457pub struct AuthorityReadDeclaration {
1458 pub table: String,
1459 pub columns: Vec<String>,
1460 pub scopes: std::collections::BTreeMap<String, Vec<String>>,
1461}