1use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11pub type QueryValue = Value;
13pub type QueryRow = Map<String, QueryValue>;
15
16#[derive(Debug, Clone)]
20pub struct WindowBase {
21 pub table: String,
22 pub variable: String,
23 pub fixed_scopes: Vec<(String, Vec<String>)>,
25 pub params: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
35#[serde(rename_all = "camelCase")]
36pub struct WindowState {
37 pub units: Vec<String>,
39 pub pending: Vec<String>,
41}
42
43#[derive(Debug, Clone, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct TableChange {
46 pub table: String,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub scope_keys: Option<Vec<String>>,
49}
50
51#[derive(Debug, Clone, Serialize)]
52#[serde(rename_all = "camelCase")]
53pub struct WindowChange {
54 pub base_key: String,
55 pub table: String,
56 pub units: Vec<String>,
57}
58
59#[derive(Debug, Clone, Serialize)]
60#[serde(rename_all = "camelCase")]
61pub struct SyncStatusSnapshot {
62 pub current_schema_version: i32,
63 pub outbox: usize,
64 pub upgrading: bool,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub lease_state: Option<LeaseState>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub schema_floor: Option<SchemaFloor>,
69 pub sync_needed: bool,
70}
71
72#[derive(Debug, Clone, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub struct ClientChangeBatch {
76 pub revision: String,
77 pub tables: Vec<TableChange>,
78 pub windows: Vec<WindowChange>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub status: Option<SyncStatusSnapshot>,
81 pub conflicts_changed: bool,
82 pub rejections_changed: bool,
83 pub outcomes_changed: bool,
84}
85
86#[derive(Debug, Clone, Serialize)]
87#[serde(tag = "kind", rename_all = "camelCase")]
88pub enum SyncIntent {
89 None,
90 Interactive,
91 Background {
92 #[serde(rename = "delayMs")]
93 delay_ms: u64,
94 },
95}
96
97#[derive(Debug, Clone, Serialize)]
98pub struct CommandEffects {
99 pub sync: SyncIntent,
100}
101
102impl CommandEffects {
103 #[must_use]
104 pub fn none() -> Self {
105 Self {
106 sync: SyncIntent::None,
107 }
108 }
109
110 #[must_use]
111 pub fn interactive() -> Self {
112 Self {
113 sync: SyncIntent::Interactive,
114 }
115 }
116}
117
118#[derive(Debug, Clone)]
119pub struct WindowCoverage {
120 pub base: WindowBase,
121 pub units: Vec<String>,
122}
123
124#[derive(Debug, Clone, Serialize)]
125#[serde(rename_all = "camelCase")]
126pub struct WindowUnitRef {
127 pub base_key: String,
128 pub unit: String,
129}
130
131#[derive(Debug, Clone, Serialize)]
132#[serde(rename_all = "camelCase")]
133pub struct CoverageSnapshot {
134 pub complete: bool,
135 pub pending: Vec<WindowUnitRef>,
136 pub missing: Vec<WindowUnitRef>,
137}
138
139#[derive(Debug, Clone, Serialize)]
140#[serde(rename_all = "camelCase")]
141pub struct QuerySnapshot {
142 pub revision: String,
143 pub rows: Vec<QueryRow>,
144 pub coverage: CoverageSnapshot,
145}
146
147impl WindowState {
148 #[must_use]
150 pub fn complete(&self, unit: &str) -> bool {
151 self.units.iter().any(|u| u == unit) && !self.pending.iter().any(|u| u == unit)
152 }
153}
154
155#[derive(Debug, Clone)]
157pub enum Mutation {
158 Upsert {
159 table: String,
160 values: Map<String, Value>,
161 base_version: Option<i64>,
162 },
163 Delete {
164 table: String,
165 row_id: String,
166 base_version: Option<i64>,
167 },
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
171#[serde(rename_all = "camelCase")]
172pub struct SchemaFloor {
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub required_schema_version: Option<i32>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub latest_schema_version: Option<i32>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
183#[serde(rename_all = "camelCase")]
184pub struct LeaseState {
185 #[serde(skip_serializing_if = "Option::is_none")]
186 pub lease_id: Option<String>,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub expires_at_ms: Option<i64>,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub error_code: Option<String>,
191}
192
193#[derive(Debug, Clone, Serialize)]
195#[serde(rename_all = "camelCase")]
196pub struct PresencePeer {
197 pub actor_id: String,
198 pub client_id: String,
199 pub doc: serde_json::Value,
200}
201
202#[derive(Debug, Clone, Serialize, Default)]
203#[serde(rename_all = "camelCase")]
204pub struct SyncReport {
205 pub pushed: u32,
206 pub applied: Vec<String>,
207 pub rejected: Vec<String>,
208 pub retryable: Vec<String>,
209 pub conflicts: u32,
210 pub commits_applied: u32,
211 pub segment_rows_applied: u32,
212 pub bootstrapping: Vec<String>,
213 pub resets: Vec<String>,
214 pub revoked: Vec<String>,
215 pub failed: Vec<String>,
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub schema_floor: Option<SchemaFloor>,
218}
219
220#[derive(Debug, Clone)]
223pub enum SyncOutcome {
224 Ok(SyncReport),
225 Failed { error_code: String, message: String },
226}
227
228impl SyncOutcome {
229 pub fn to_json(&self) -> Value {
230 match self {
231 SyncOutcome::Ok(report) => {
232 let mut map = Map::new();
233 map.insert("ok".to_owned(), Value::Bool(true));
234 map.insert(
235 "report".to_owned(),
236 serde_json::to_value(report).expect("report serializes"),
237 );
238 Value::Object(map)
239 }
240 SyncOutcome::Failed {
241 error_code,
242 message,
243 } => {
244 let mut map = Map::new();
245 map.insert("ok".to_owned(), Value::Bool(false));
246 map.insert("errorCode".to_owned(), Value::from(error_code.clone()));
247 map.insert("message".to_owned(), Value::from(message.clone()));
248 Value::Object(map)
249 }
250 }
251 }
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
255#[serde(rename_all = "camelCase")]
256pub struct ConflictRecord {
257 pub client_commit_id: String,
258 pub op_index: i32,
259 pub table: String,
260 pub row_id: String,
261 pub code: String,
262 pub message: String,
263 pub server_version: i64,
264 pub server_row: Map<String, Value>,
267 #[serde(skip_serializing_if = "Option::is_none")]
268 pub operation: Option<CommitOperation>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
274#[serde(rename_all = "camelCase", deny_unknown_fields)]
275pub struct RejectionDetails {
276 #[serde(skip_serializing_if = "Option::is_none")]
277 pub field_paths: Option<Vec<String>>,
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub reason: Option<String>,
280 #[serde(skip_serializing_if = "Option::is_none")]
281 pub required_action: Option<String>,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub references: Option<BTreeMap<String, String>>,
284}
285
286impl RejectionDetails {
287 pub(crate) fn parse(raw: &str) -> Result<Self, String> {
288 if raw.len() > 4_096 {
289 return Err("rejection details exceed 4096 encoded bytes".to_owned());
290 }
291 let details: Self = serde_json::from_str(raw)
292 .map_err(|error| format!("invalid rejection details: {error}"))?;
293 details.validate()?;
294 Ok(details)
295 }
296
297 fn validate(&self) -> Result<(), String> {
298 let mut members = 0;
299 if let Some(paths) = &self.field_paths {
300 members += 1;
301 if paths.is_empty() || paths.len() > 32 {
302 return Err("fieldPaths must contain 1-32 paths".to_owned());
303 }
304 let mut seen = std::collections::BTreeSet::new();
305 for path in paths {
306 if path.len() > 160 || !path.split('.').all(valid_identifier) || !seen.insert(path)
307 {
308 return Err("fieldPaths contains an invalid or duplicate path".to_owned());
309 }
310 }
311 }
312 if let Some(reason) = &self.reason {
313 members += 1;
314 if !valid_token(reason, 96) {
315 return Err("reason must be a lowercase stable token".to_owned());
316 }
317 }
318 if let Some(action) = &self.required_action {
319 members += 1;
320 if !valid_token(action, 96) {
321 return Err("requiredAction must be a lowercase stable token".to_owned());
322 }
323 }
324 if let Some(references) = &self.references {
325 members += 1;
326 if references.is_empty() || references.len() > 16 {
327 return Err("references must contain 1-16 entries".to_owned());
328 }
329 for (key, value) in references {
330 if !valid_token(key, 64)
331 || value.is_empty()
332 || value.len() > 256
333 || value.trim() != value
334 || value.chars().any(char::is_control)
335 {
336 return Err("references contains an invalid key or value".to_owned());
337 }
338 }
339 }
340 if members == 0 {
341 return Err("rejection details must not be empty".to_owned());
342 }
343 Ok(())
344 }
345}
346
347fn valid_identifier(segment: &str) -> bool {
348 let mut chars = segment.chars();
349 matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic())
350 && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
351}
352
353fn valid_token(token: &str, max: usize) -> bool {
354 if token.is_empty() || token.len() > max || !token.starts_with(|c: char| c.is_ascii_lowercase())
355 {
356 return false;
357 }
358 let mut previous_separator = false;
359 for character in token.chars() {
360 if character.is_ascii_lowercase() || character.is_ascii_digit() {
361 previous_separator = false;
362 } else if matches!(character, '.' | '_' | '-') && !previous_separator {
363 previous_separator = true;
364 } else {
365 return false;
366 }
367 }
368 !previous_separator
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
372#[serde(rename_all = "camelCase")]
373pub struct RejectionRecord {
374 pub client_commit_id: String,
375 pub op_index: i32,
376 pub code: String,
377 pub message: String,
378 pub retryable: bool,
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub details: Option<RejectionDetails>,
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub operation: Option<CommitOperation>,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
387#[serde(rename_all = "camelCase")]
388pub struct CommitOperation {
389 pub table: String,
390 pub row_id: String,
391 pub op: String,
392 #[serde(skip_serializing_if = "Option::is_none")]
393 pub base_version: Option<i64>,
394 #[serde(skip_serializing_if = "Option::is_none")]
395 pub values: Option<Map<String, Value>>,
396 #[serde(skip_serializing_if = "Option::is_none")]
399 pub changed_fields: Option<Vec<String>>,
400}
401
402#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
403#[serde(rename_all = "snake_case")]
404pub enum CommitOutcomeStatus {
405 Applied,
406 Cached,
407 Conflict,
408 Rejected,
409}
410
411#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
412#[serde(rename_all = "snake_case")]
413pub enum CommitOutcomeResolution {
414 Active,
415 ResolvedKeepServer,
416 Superseded,
417 Dismissed,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
421#[serde(tag = "status", rename_all = "snake_case")]
422pub enum CommitOperationOutcome {
423 Applied {
424 #[serde(rename = "opIndex")]
425 op_index: i32,
426 },
427 Conflict {
428 conflict: ConflictRecord,
429 },
430 Error {
431 rejection: RejectionRecord,
432 },
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
436#[serde(rename_all = "camelCase")]
437pub struct CommitOutcome {
438 pub sequence: i64,
439 pub client_commit_id: String,
440 pub status: CommitOutcomeStatus,
441 pub recorded_at_ms: i64,
442 pub results: Vec<CommitOperationOutcome>,
443 #[serde(skip_serializing_if = "Option::is_none")]
446 pub operations: Option<Vec<CommitOperation>>,
447 pub resolution: CommitOutcomeResolution,
448 #[serde(skip_serializing_if = "Option::is_none")]
449 pub resolved_at_ms: Option<i64>,
450 #[serde(skip_serializing_if = "Option::is_none")]
451 pub replacement_client_commit_id: Option<String>,
452}
453
454#[derive(Debug, Clone, Deserialize, Default)]
455#[serde(rename_all = "camelCase")]
456pub struct CommitOutcomeQuery {
457 pub limit: Option<usize>,
458 #[serde(default)]
459 pub active_only: bool,
460}
461
462#[derive(Debug, Clone, Deserialize)]
463#[serde(rename_all = "camelCase")]
464pub struct ResolveCommitOutcomeInput {
465 pub client_commit_id: String,
466 pub resolution: CommitOutcomeResolution,
467 pub replacement_client_commit_id: Option<String>,
468}
469
470#[derive(Debug, Clone, Serialize)]
471#[serde(rename_all = "camelCase")]
472pub struct RowState {
473 pub row_id: String,
474 pub version: i64,
477 pub values: Map<String, Value>,
478}
479
480#[derive(Debug, Clone, Serialize)]
481#[serde(rename_all = "camelCase")]
482pub struct SubscriptionStateView {
483 pub id: String,
484 pub table: String,
485 pub status: String,
487 pub cursor: i64,
488 pub has_resume_token: bool,
489 #[serde(skip_serializing_if = "Option::is_none")]
490 pub effective_scopes: Option<Value>,
491 #[serde(skip_serializing_if = "Option::is_none")]
492 pub reason_code: Option<String>,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
499#[serde(rename_all = "camelCase", deny_unknown_fields)]
500pub struct LocalDataPurgeTarget {
501 pub table: String,
502 pub selectors: BTreeMap<String, Vec<String>>,
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
508#[serde(rename_all = "camelCase", deny_unknown_fields)]
509pub struct LocalDataPurgeInput {
510 pub purge_id: String,
511 pub targets: Vec<LocalDataPurgeTarget>,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
516#[serde(rename_all = "camelCase")]
517pub struct LocalDataPurgeResult {
518 pub already_applied: bool,
519 pub purged_rows: usize,
520 pub dropped_commits: usize,
521}
522
523#[derive(Debug, Clone, Default)]
525pub struct ClientLimits {
526 pub limit_commits: Option<i32>,
527 pub limit_snapshot_rows: Option<i32>,
528 pub max_snapshot_pages: Option<i32>,
529 pub accept: Option<u8>,
532 pub blob_cache_max_bytes: Option<i64>,
536 pub outcome_retention_max_entries: Option<usize>,
539}