Skip to main content

max / synckit-client

13.9 KB · 469 lines History Blame Raw
1 //! Request/response types matching the MNW SyncKit server API.
2
3 use chrono::{DateTime, Utc};
4 use serde::{Deserialize, Serialize};
5 use std::fmt;
6 use uuid::Uuid;
7
8 // ── Change operations ──
9
10 /// The operation type for a sync change entry.
11 ///
12 /// Serializes to/from uppercase strings (`"INSERT"`, `"UPDATE"`, `"DELETE"`)
13 /// matching the server wire protocol. Invalid values are rejected at
14 /// deserialization time.
15 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16 #[serde(rename_all = "UPPERCASE")]
17 pub enum ChangeOp {
18 Insert,
19 Update,
20 Delete,
21 }
22
23 impl fmt::Display for ChangeOp {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 match self {
26 ChangeOp::Insert => write!(f, "INSERT"),
27 ChangeOp::Update => write!(f, "UPDATE"),
28 ChangeOp::Delete => write!(f, "DELETE"),
29 }
30 }
31 }
32
33 impl ChangeOp {
34 /// Parse from a string, returning `None` for unrecognized values.
35 ///
36 /// Useful when reading raw op strings from a local database.
37 pub fn from_str_opt(s: &str) -> Option<Self> {
38 match s {
39 "INSERT" => Some(ChangeOp::Insert),
40 "UPDATE" => Some(ChangeOp::Update),
41 "DELETE" => Some(ChangeOp::Delete),
42 _ => None,
43 }
44 }
45 }
46
47 // ── Auth ──
48
49 #[derive(Serialize)]
50 pub(crate) struct AuthRequest<'a> {
51 pub email: &'a str,
52 pub password: &'a str,
53 pub api_key: &'a str,
54 }
55
56 #[derive(Deserialize)]
57 pub(crate) struct AuthResponse {
58 pub token: String,
59 pub user_id: Uuid,
60 pub app_id: Uuid,
61 }
62
63 // ── Devices ──
64
65 #[derive(Serialize)]
66 pub(crate) struct RegisterDeviceRequest {
67 pub device_name: String,
68 pub platform: String,
69 }
70
71 #[derive(Debug, Clone, Deserialize, Serialize)]
72 pub struct Device {
73 /// Server-assigned device UUID.
74 pub id: Uuid,
75 /// The SyncKit app this device belongs to.
76 pub app_id: Uuid,
77 /// The user who owns this device.
78 pub user_id: Uuid,
79 /// Human-readable name (e.g., "MacBook Pro").
80 pub device_name: String,
81 /// OS identifier (e.g., "macos", "linux", "windows").
82 pub platform: String,
83 /// Last time this device synced.
84 pub last_seen_at: DateTime<Utc>,
85 /// When this device was first registered.
86 pub created_at: DateTime<Utc>,
87 }
88
89 // ── Push / Pull ──
90
91 /// A change entry for pushing to the server.
92 /// `data` is plaintext here — the client encrypts it before sending.
93 #[derive(Debug, Clone, Serialize, Deserialize)]
94 pub struct ChangeEntry {
95 /// The source table name (opaque to server, meaningful to the app).
96 pub table: String,
97 /// Insert, Update, or Delete.
98 pub op: ChangeOp,
99 /// App-assigned row identifier (typically a UUID string).
100 pub row_id: String,
101 /// When this change was made on the originating device.
102 pub timestamp: DateTime<Utc>,
103 /// The row payload as JSON. `None` for Delete operations.
104 /// Serialized as absent (not null) when `None`.
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub data: Option<serde_json::Value>,
107 }
108
109 /// Wire format sent to server (data is already encrypted).
110 #[derive(Serialize)]
111 pub(crate) struct WirePushRequest {
112 /// The device sending the changes.
113 pub device_id: Uuid,
114 /// Encrypted change entries ready for the wire.
115 pub changes: Vec<WireChangeEntry>,
116 }
117
118 #[derive(Debug, Serialize)]
119 pub(crate) struct WireChangeEntry {
120 pub table: String,
121 pub op: ChangeOp,
122 pub row_id: String,
123 pub timestamp: DateTime<Utc>,
124 pub data: Option<serde_json::Value>,
125 }
126
127 #[derive(Deserialize)]
128 pub(crate) struct PushResponse {
129 pub cursor: i64,
130 }
131
132 #[derive(Serialize)]
133 pub(crate) struct PullRequest {
134 pub device_id: Uuid,
135 pub cursor: i64,
136 }
137
138 #[derive(Deserialize)]
139 pub(crate) struct PullResponse {
140 pub changes: Vec<PullChangeEntry>,
141 pub cursor: i64,
142 pub has_more: bool,
143 }
144
145 /// A change entry received from the server during a pull.
146 ///
147 /// `seq` and `device_id` are present in the server response and parsed for
148 /// completeness, but not used by the SDK — consumers track cursors, not
149 /// individual sequence numbers.
150 #[derive(Deserialize)]
151 pub(crate) struct PullChangeEntry {
152 #[allow(dead_code)]
153 pub seq: i64,
154 #[allow(dead_code)]
155 pub device_id: Uuid,
156 pub table: String,
157 pub op: ChangeOp,
158 pub row_id: String,
159 pub timestamp: DateTime<Utc>,
160 pub data: Option<serde_json::Value>,
161 }
162
163 // ── Filtered pull ──
164
165 /// Optional filters for [`SyncKitClient::pull_filtered`].
166 ///
167 /// Both fields are optional and compose with AND. An empty/default filter
168 /// is equivalent to an unfiltered pull.
169 #[derive(Debug, Clone, Default, Serialize)]
170 pub struct PullFilter {
171 /// Only return entries for these table names.
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub tables: Option<Vec<String>>,
174 /// Only return entries with `client_timestamp >= since`.
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub since: Option<DateTime<Utc>>,
177 }
178
179 #[derive(Serialize)]
180 pub(crate) struct FilteredPullRequest {
181 pub device_id: Uuid,
182 pub cursor: i64,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub tables: Option<Vec<String>>,
185 #[serde(skip_serializing_if = "Option::is_none")]
186 pub since: Option<DateTime<Utc>>,
187 }
188
189 // ── Pulled change (rich metadata) ──
190
191 /// A change entry from pull with server metadata preserved.
192 ///
193 /// Wraps [`ChangeEntry`] with `device_id` and `seq` fields that are normally
194 /// discarded during `pull()`. Used by [`SyncKitClient::pull_rich`] for
195 /// conflict detection — the `device_id` identifies whether a change came from
196 /// another device, and `seq` provides total server ordering.
197 #[derive(Debug, Clone)]
198 pub struct PulledChange {
199 /// The decrypted change entry.
200 pub entry: ChangeEntry,
201 /// The device that originated this change.
202 pub device_id: Uuid,
203 /// Server sequence number (total ordering).
204 pub seq: i64,
205 }
206
207 // ── Keys ──
208
209 #[derive(Serialize)]
210 pub(crate) struct PutKeyRequest {
211 pub encrypted_key: String,
212 }
213
214 #[derive(Deserialize)]
215 pub(crate) struct GetKeyResponse {
216 pub encrypted_key: String,
217 }
218
219 // ── OAuth ──
220
221 #[derive(Serialize)]
222 pub(crate) struct OAuthTokenRequest<'a> {
223 pub grant_type: &'a str,
224 pub code: &'a str,
225 pub redirect_uri: &'a str,
226 pub code_verifier: &'a str,
227 pub client_id: &'a str,
228 }
229
230 #[derive(Deserialize)]
231 pub(crate) struct OAuthTokenResponse {
232 pub access_token: String,
233 #[allow(dead_code)]
234 pub token_type: String,
235 #[allow(dead_code)]
236 pub expires_in: i64,
237 pub user_id: Uuid,
238 pub app_id: Uuid,
239 }
240
241 // ── Status ──
242
243 #[derive(Debug, Deserialize)]
244 pub struct SyncStatus {
245 /// Number of changelog entries on the server for this app/user.
246 pub total_changes: i64,
247 /// Sequence number of the most recent change. `None` if no changes exist.
248 pub latest_cursor: Option<i64>,
249 }
250
251 // ── Blobs ──
252
253 #[derive(Serialize)]
254 pub(crate) struct BlobUploadUrlRequest {
255 pub hash: String,
256 pub size_bytes: i64,
257 }
258
259 #[derive(Deserialize)]
260 pub struct BlobUploadUrlResponse {
261 /// Presigned S3 PUT URL. Empty string when `already_exists` is true.
262 pub upload_url: String,
263 /// True if the server already has a blob with this hash (skip upload).
264 pub already_exists: bool,
265 }
266
267 #[derive(Serialize)]
268 pub(crate) struct BlobConfirmRequest {
269 pub hash: String,
270 pub size_bytes: i64,
271 }
272
273 #[derive(Serialize)]
274 pub(crate) struct BlobDownloadUrlRequest {
275 pub hash: String,
276 }
277
278 #[derive(Deserialize)]
279 pub(crate) struct BlobDownloadUrlResponse {
280 pub download_url: String,
281 }
282
283 #[cfg(test)]
284 mod tests {
285 use super::*;
286 use serde_json::json;
287
288 #[test]
289 fn change_op_serde_roundtrip() {
290 for (variant, expected_str) in [
291 (ChangeOp::Insert, "\"INSERT\""),
292 (ChangeOp::Update, "\"UPDATE\""),
293 (ChangeOp::Delete, "\"DELETE\""),
294 ] {
295 let serialized = serde_json::to_string(&variant).unwrap();
296 assert_eq!(serialized, expected_str);
297 let deserialized: ChangeOp = serde_json::from_str(&serialized).unwrap();
298 assert_eq!(deserialized, variant);
299 }
300 }
301
302 #[test]
303 fn change_op_display_matches_serde() {
304 for variant in [ChangeOp::Insert, ChangeOp::Update, ChangeOp::Delete] {
305 let display = variant.to_string();
306 let serde_str = serde_json::to_string(&variant).unwrap();
307 // serde wraps in quotes, Display does not
308 assert_eq!(format!("\"{display}\""), serde_str);
309 }
310 }
311
312 #[test]
313 fn change_op_from_str_opt_rejects_lowercase() {
314 assert_eq!(ChangeOp::from_str_opt("insert"), None);
315 assert_eq!(ChangeOp::from_str_opt("update"), None);
316 assert_eq!(ChangeOp::from_str_opt("delete"), None);
317 }
318
319 #[test]
320 fn change_op_from_str_opt_rejects_unknown() {
321 assert_eq!(ChangeOp::from_str_opt("UPSERT"), None);
322 assert_eq!(ChangeOp::from_str_opt(""), None);
323 assert_eq!(ChangeOp::from_str_opt("MERGE"), None);
324 }
325
326 #[test]
327 fn change_op_is_copy_and_eq() {
328 let op = ChangeOp::Insert;
329 let copied = op; // Copy
330 assert_eq!(op, copied);
331 }
332
333 #[test]
334 fn change_op_hash_works() {
335 use std::collections::HashSet;
336 let mut set = HashSet::new();
337 set.insert(ChangeOp::Insert);
338 set.insert(ChangeOp::Update);
339 set.insert(ChangeOp::Delete);
340 set.insert(ChangeOp::Insert); // duplicate
341 assert_eq!(set.len(), 3);
342 }
343
344 #[test]
345 fn change_entry_serialization_omits_none_data() {
346 let entry = ChangeEntry {
347 table: "t".into(),
348 op: ChangeOp::Delete,
349 row_id: "r".into(),
350 timestamp: chrono::Utc::now(),
351 data: None,
352 };
353 let json = serde_json::to_string(&entry).unwrap();
354 assert!(!json.contains("\"data\""), "None data should be omitted: {json}");
355 }
356
357 #[test]
358 fn change_entry_serialization_includes_some_data() {
359 let entry = ChangeEntry {
360 table: "t".into(),
361 op: ChangeOp::Insert,
362 row_id: "r".into(),
363 timestamp: chrono::Utc::now(),
364 data: Some(json!({"k": "v"})),
365 };
366 let json = serde_json::to_string(&entry).unwrap();
367 assert!(json.contains("\"data\""), "Some data should be present: {json}");
368 }
369
370 #[test]
371 fn change_entry_deserialization_ignores_extra_fields() {
372 let json = r#"{
373 "table": "tasks",
374 "op": "INSERT",
375 "row_id": "r1",
376 "timestamp": "2025-01-15T10:00:00Z",
377 "data": {"title": "test"},
378 "extra_field": "should be ignored",
379 "another_unknown": 42
380 }"#;
381 let entry: ChangeEntry = serde_json::from_str(json).unwrap();
382 assert_eq!(entry.table, "tasks");
383 assert_eq!(entry.op, ChangeOp::Insert);
384 assert_eq!(entry.data.unwrap()["title"], "test");
385 }
386
387 #[test]
388 fn device_deserialization_with_iso_timestamps() {
389 let json = r#"{
390 "id": "550e8400-e29b-41d4-a716-446655440000",
391 "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
392 "user_id": "550e8400-e29b-41d4-a716-446655440001",
393 "device_name": "MacBook Pro",
394 "platform": "macos",
395 "last_seen_at": "2025-06-15T14:30:00.123Z",
396 "created_at": "2025-01-01T00:00:00Z"
397 }"#;
398 let device: Device = serde_json::from_str(json).unwrap();
399 assert_eq!(device.device_name, "MacBook Pro");
400 assert_eq!(device.platform, "macos");
401 assert_eq!(
402 device.id,
403 Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
404 );
405 }
406
407 #[test]
408 fn sync_status_with_zero_total_changes() {
409 let json = r#"{"total_changes": 0, "latest_cursor": null}"#;
410 let status: SyncStatus = serde_json::from_str(json).unwrap();
411 assert_eq!(status.total_changes, 0);
412 assert!(status.latest_cursor.is_none());
413 }
414
415 #[test]
416 fn blob_upload_url_response_already_exists() {
417 let json = r#"{"upload_url": "", "already_exists": true}"#;
418 let resp: BlobUploadUrlResponse = serde_json::from_str(json).unwrap();
419 assert!(resp.already_exists);
420 assert!(resp.upload_url.is_empty());
421 }
422
423 #[test]
424 fn change_op_debug_format() {
425 assert_eq!(format!("{:?}", ChangeOp::Insert), "Insert");
426 assert_eq!(format!("{:?}", ChangeOp::Update), "Update");
427 assert_eq!(format!("{:?}", ChangeOp::Delete), "Delete");
428 }
429
430 // ── PullFilter ──
431
432 #[test]
433 fn pull_filter_serialization_with_both_fields() {
434 let filter = PullFilter {
435 tables: Some(vec!["tasks".to_string(), "events".to_string()]),
436 since: Some("2025-06-15T12:00:00Z".parse().unwrap()),
437 };
438 let json = serde_json::to_string(&filter).unwrap();
439 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
440 assert_eq!(parsed["tables"].as_array().unwrap().len(), 2);
441 assert!(parsed["since"].is_string());
442 }
443
444 #[test]
445 fn pull_filter_serialization_with_none_fields() {
446 let filter = PullFilter::default();
447 let json = serde_json::to_string(&filter).unwrap();
448 // None fields should be omitted entirely
449 assert!(!json.contains("tables"));
450 assert!(!json.contains("since"));
451 assert_eq!(json, "{}");
452 }
453
454 #[test]
455 fn filtered_pull_request_includes_filter_fields() {
456 let req = FilteredPullRequest {
457 device_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
458 cursor: 42,
459 tables: Some(vec!["tasks".to_string()]),
460 since: Some("2025-01-01T00:00:00Z".parse().unwrap()),
461 };
462 let json = serde_json::to_string(&req).unwrap();
463 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
464 assert_eq!(parsed["cursor"], 42);
465 assert_eq!(parsed["tables"].as_array().unwrap().len(), 1);
466 assert!(parsed["since"].is_string());
467 }
468 }
469