//! Request/response types matching the MNW SyncKit server API. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fmt; use uuid::Uuid; // ── Change operations ── /// The operation type for a sync change entry. /// /// Serializes to/from uppercase strings (`"INSERT"`, `"UPDATE"`, `"DELETE"`) /// matching the server wire protocol. Invalid values are rejected at /// deserialization time. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "UPPERCASE")] pub enum ChangeOp { Insert, Update, Delete, } impl fmt::Display for ChangeOp { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ChangeOp::Insert => write!(f, "INSERT"), ChangeOp::Update => write!(f, "UPDATE"), ChangeOp::Delete => write!(f, "DELETE"), } } } impl ChangeOp { /// Parse from a string, returning `None` for unrecognized values. /// /// Useful when reading raw op strings from a local database. pub fn from_str_opt(s: &str) -> Option { match s { "INSERT" => Some(ChangeOp::Insert), "UPDATE" => Some(ChangeOp::Update), "DELETE" => Some(ChangeOp::Delete), _ => None, } } } // ── Auth ── #[derive(Serialize)] pub(crate) struct AuthRequest<'a> { pub email: &'a str, pub password: &'a str, pub api_key: &'a str, } #[derive(Deserialize)] pub(crate) struct AuthResponse { pub token: String, pub user_id: Uuid, pub app_id: Uuid, } // ── Devices ── #[derive(Serialize)] pub(crate) struct RegisterDeviceRequest { pub device_name: String, pub platform: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Device { /// Server-assigned device UUID. pub id: Uuid, /// The SyncKit app this device belongs to. pub app_id: Uuid, /// The user who owns this device. pub user_id: Uuid, /// Human-readable name (e.g., "MacBook Pro"). pub device_name: String, /// OS identifier (e.g., "macos", "linux", "windows"). pub platform: String, /// Last time this device synced. pub last_seen_at: DateTime, /// When this device was first registered. pub created_at: DateTime, } // ── Push / Pull ── /// A change entry for pushing to the server. /// `data` is plaintext here — the client encrypts it before sending. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChangeEntry { /// The source table name (opaque to server, meaningful to the app). pub table: String, /// Insert, Update, or Delete. pub op: ChangeOp, /// App-assigned row identifier (typically a UUID string). pub row_id: String, /// When this change was made on the originating device. pub timestamp: DateTime, /// The row payload as JSON. `None` for Delete operations. /// Serialized as absent (not null) when `None`. #[serde(skip_serializing_if = "Option::is_none")] pub data: Option, } /// Wire format sent to server (data is already encrypted). #[derive(Serialize)] pub(crate) struct WirePushRequest { /// The device sending the changes. pub device_id: Uuid, /// Encrypted change entries ready for the wire. pub changes: Vec, } #[derive(Debug, Serialize)] pub(crate) struct WireChangeEntry { pub table: String, pub op: ChangeOp, pub row_id: String, pub timestamp: DateTime, pub data: Option, } #[derive(Deserialize)] pub(crate) struct PushResponse { pub cursor: i64, } #[derive(Serialize)] pub(crate) struct PullRequest { pub device_id: Uuid, pub cursor: i64, } #[derive(Deserialize)] pub(crate) struct PullResponse { pub changes: Vec, pub cursor: i64, pub has_more: bool, } /// A change entry received from the server during a pull. /// /// `seq` and `device_id` are present in the server response and parsed for /// completeness, but not used by the SDK — consumers track cursors, not /// individual sequence numbers. #[derive(Deserialize)] pub(crate) struct PullChangeEntry { #[allow(dead_code)] pub seq: i64, #[allow(dead_code)] pub device_id: Uuid, pub table: String, pub op: ChangeOp, pub row_id: String, pub timestamp: DateTime, pub data: Option, } // ── Filtered pull ── /// Optional filters for [`SyncKitClient::pull_filtered`]. /// /// Both fields are optional and compose with AND. An empty/default filter /// is equivalent to an unfiltered pull. #[derive(Debug, Clone, Default, Serialize)] pub struct PullFilter { /// Only return entries for these table names. #[serde(skip_serializing_if = "Option::is_none")] pub tables: Option>, /// Only return entries with `client_timestamp >= since`. #[serde(skip_serializing_if = "Option::is_none")] pub since: Option>, } #[derive(Serialize)] pub(crate) struct FilteredPullRequest { pub device_id: Uuid, pub cursor: i64, #[serde(skip_serializing_if = "Option::is_none")] pub tables: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub since: Option>, } // ── Pulled change (rich metadata) ── /// A change entry from pull with server metadata preserved. /// /// Wraps [`ChangeEntry`] with `device_id` and `seq` fields that are normally /// discarded during `pull()`. Used by [`SyncKitClient::pull_rich`] for /// conflict detection — the `device_id` identifies whether a change came from /// another device, and `seq` provides total server ordering. #[derive(Debug, Clone)] pub struct PulledChange { /// The decrypted change entry. pub entry: ChangeEntry, /// The device that originated this change. pub device_id: Uuid, /// Server sequence number (total ordering). pub seq: i64, } // ── Keys ── #[derive(Serialize)] pub(crate) struct PutKeyRequest { pub encrypted_key: String, } #[derive(Deserialize)] pub(crate) struct GetKeyResponse { pub encrypted_key: String, } // ── OAuth ── #[derive(Serialize)] pub(crate) struct OAuthTokenRequest<'a> { pub grant_type: &'a str, pub code: &'a str, pub redirect_uri: &'a str, pub code_verifier: &'a str, pub client_id: &'a str, } #[derive(Deserialize)] pub(crate) struct OAuthTokenResponse { pub access_token: String, #[allow(dead_code)] pub token_type: String, #[allow(dead_code)] pub expires_in: i64, pub user_id: Uuid, pub app_id: Uuid, } // ── Status ── #[derive(Debug, Deserialize)] pub struct SyncStatus { /// Number of changelog entries on the server for this app/user. pub total_changes: i64, /// Sequence number of the most recent change. `None` if no changes exist. pub latest_cursor: Option, } // ── Blobs ── #[derive(Serialize)] pub(crate) struct BlobUploadUrlRequest { pub hash: String, pub size_bytes: i64, } #[derive(Deserialize)] pub struct BlobUploadUrlResponse { /// Presigned S3 PUT URL. Empty string when `already_exists` is true. pub upload_url: String, /// True if the server already has a blob with this hash (skip upload). pub already_exists: bool, } #[derive(Serialize)] pub(crate) struct BlobConfirmRequest { pub hash: String, pub size_bytes: i64, } #[derive(Serialize)] pub(crate) struct BlobDownloadUrlRequest { pub hash: String, } #[derive(Deserialize)] pub(crate) struct BlobDownloadUrlResponse { pub download_url: String, } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn change_op_serde_roundtrip() { for (variant, expected_str) in [ (ChangeOp::Insert, "\"INSERT\""), (ChangeOp::Update, "\"UPDATE\""), (ChangeOp::Delete, "\"DELETE\""), ] { let serialized = serde_json::to_string(&variant).unwrap(); assert_eq!(serialized, expected_str); let deserialized: ChangeOp = serde_json::from_str(&serialized).unwrap(); assert_eq!(deserialized, variant); } } #[test] fn change_op_display_matches_serde() { for variant in [ChangeOp::Insert, ChangeOp::Update, ChangeOp::Delete] { let display = variant.to_string(); let serde_str = serde_json::to_string(&variant).unwrap(); // serde wraps in quotes, Display does not assert_eq!(format!("\"{display}\""), serde_str); } } #[test] fn change_op_from_str_opt_rejects_lowercase() { assert_eq!(ChangeOp::from_str_opt("insert"), None); assert_eq!(ChangeOp::from_str_opt("update"), None); assert_eq!(ChangeOp::from_str_opt("delete"), None); } #[test] fn change_op_from_str_opt_rejects_unknown() { assert_eq!(ChangeOp::from_str_opt("UPSERT"), None); assert_eq!(ChangeOp::from_str_opt(""), None); assert_eq!(ChangeOp::from_str_opt("MERGE"), None); } #[test] fn change_op_is_copy_and_eq() { let op = ChangeOp::Insert; let copied = op; // Copy assert_eq!(op, copied); } #[test] fn change_op_hash_works() { use std::collections::HashSet; let mut set = HashSet::new(); set.insert(ChangeOp::Insert); set.insert(ChangeOp::Update); set.insert(ChangeOp::Delete); set.insert(ChangeOp::Insert); // duplicate assert_eq!(set.len(), 3); } #[test] fn change_entry_serialization_omits_none_data() { let entry = ChangeEntry { table: "t".into(), op: ChangeOp::Delete, row_id: "r".into(), timestamp: chrono::Utc::now(), data: None, }; let json = serde_json::to_string(&entry).unwrap(); assert!(!json.contains("\"data\""), "None data should be omitted: {json}"); } #[test] fn change_entry_serialization_includes_some_data() { let entry = ChangeEntry { table: "t".into(), op: ChangeOp::Insert, row_id: "r".into(), timestamp: chrono::Utc::now(), data: Some(json!({"k": "v"})), }; let json = serde_json::to_string(&entry).unwrap(); assert!(json.contains("\"data\""), "Some data should be present: {json}"); } #[test] fn change_entry_deserialization_ignores_extra_fields() { let json = r#"{ "table": "tasks", "op": "INSERT", "row_id": "r1", "timestamp": "2025-01-15T10:00:00Z", "data": {"title": "test"}, "extra_field": "should be ignored", "another_unknown": 42 }"#; let entry: ChangeEntry = serde_json::from_str(json).unwrap(); assert_eq!(entry.table, "tasks"); assert_eq!(entry.op, ChangeOp::Insert); assert_eq!(entry.data.unwrap()["title"], "test"); } #[test] fn device_deserialization_with_iso_timestamps() { let json = r#"{ "id": "550e8400-e29b-41d4-a716-446655440000", "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "user_id": "550e8400-e29b-41d4-a716-446655440001", "device_name": "MacBook Pro", "platform": "macos", "last_seen_at": "2025-06-15T14:30:00.123Z", "created_at": "2025-01-01T00:00:00Z" }"#; let device: Device = serde_json::from_str(json).unwrap(); assert_eq!(device.device_name, "MacBook Pro"); assert_eq!(device.platform, "macos"); assert_eq!( device.id, Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap() ); } #[test] fn sync_status_with_zero_total_changes() { let json = r#"{"total_changes": 0, "latest_cursor": null}"#; let status: SyncStatus = serde_json::from_str(json).unwrap(); assert_eq!(status.total_changes, 0); assert!(status.latest_cursor.is_none()); } #[test] fn blob_upload_url_response_already_exists() { let json = r#"{"upload_url": "", "already_exists": true}"#; let resp: BlobUploadUrlResponse = serde_json::from_str(json).unwrap(); assert!(resp.already_exists); assert!(resp.upload_url.is_empty()); } #[test] fn change_op_debug_format() { assert_eq!(format!("{:?}", ChangeOp::Insert), "Insert"); assert_eq!(format!("{:?}", ChangeOp::Update), "Update"); assert_eq!(format!("{:?}", ChangeOp::Delete), "Delete"); } // ── PullFilter ── #[test] fn pull_filter_serialization_with_both_fields() { let filter = PullFilter { tables: Some(vec!["tasks".to_string(), "events".to_string()]), since: Some("2025-06-15T12:00:00Z".parse().unwrap()), }; let json = serde_json::to_string(&filter).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(parsed["tables"].as_array().unwrap().len(), 2); assert!(parsed["since"].is_string()); } #[test] fn pull_filter_serialization_with_none_fields() { let filter = PullFilter::default(); let json = serde_json::to_string(&filter).unwrap(); // None fields should be omitted entirely assert!(!json.contains("tables")); assert!(!json.contains("since")); assert_eq!(json, "{}"); } #[test] fn filtered_pull_request_includes_filter_fields() { let req = FilteredPullRequest { device_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), cursor: 42, tables: Some(vec!["tasks".to_string()]), since: Some("2025-01-01T00:00:00Z".parse().unwrap()), }; let json = serde_json::to_string(&req).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(parsed["cursor"], 42); assert_eq!(parsed["tables"].as_array().unwrap().len(), 1); assert!(parsed["since"].is_string()); } }