max / makenotwork
12 files changed,
+242 insertions,
-117 deletions
| @@ -1,10 +1,12 @@ | |||
| 1 | 1 | use bytes::Bytes; | |
| 2 | 2 | use std::sync::Arc; | |
| 3 | 3 | use tracing::instrument; | |
| 4 | + | #[cfg(test)] | |
| 4 | 5 | use uuid::Uuid; | |
| 5 | 6 | ||
| 6 | 7 | use crate::{ | |
| 7 | 8 | error::Result, | |
| 9 | + | ids::{AppId, UserId}, | |
| 8 | 10 | types::*, | |
| 9 | 11 | }; | |
| 10 | 12 | ||
| @@ -30,7 +32,7 @@ impl SyncKitClient { | |||
| 30 | 32 | email: &str, | |
| 31 | 33 | password: &str, | |
| 32 | 34 | key: &str, | |
| 33 | - | ) -> Result<(Uuid, Uuid)> { | |
| 35 | + | ) -> Result<(UserId, AppId)> { | |
| 34 | 36 | let body = Bytes::from(serde_json::to_vec(&AuthRequest { | |
| 35 | 37 | email, | |
| 36 | 38 | password, | |
| @@ -69,7 +71,7 @@ impl SyncKitClient { | |||
| 69 | 71 | /// | |
| 70 | 72 | /// Sets the internal session state without making any HTTP calls. | |
| 71 | 73 | /// Used on app startup to restore from stored credentials without re-authenticating. | |
| 72 | - | pub fn restore_session(&self, token: &str, user_id: Uuid, app_id: Uuid) { | |
| 74 | + | pub fn restore_session(&self, token: &str, user_id: UserId, app_id: AppId) { | |
| 73 | 75 | let token_exp = jwt_exp(token); | |
| 74 | 76 | *self.session.write() = Some(Session { | |
| 75 | 77 | token: Arc::new(token.to_string()), | |
| @@ -146,7 +148,7 @@ impl SyncKitClient { | |||
| 146 | 148 | code_verifier: &str, | |
| 147 | 149 | redirect_port: u16, | |
| 148 | 150 | key: &str, | |
| 149 | - | ) -> Result<(Uuid, Uuid)> { | |
| 151 | + | ) -> Result<(UserId, AppId)> { | |
| 150 | 152 | let redirect_uri = format!("http://127.0.0.1:{}/", redirect_port); | |
| 151 | 153 | ||
| 152 | 154 | let form_params = [ | |
| @@ -203,10 +205,10 @@ mod tests { | |||
| 203 | 205 | } | |
| 204 | 206 | } | |
| 205 | 207 | ||
| 206 | - | fn test_ids() -> (Uuid, Uuid) { | |
| 208 | + | fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) { | |
| 207 | 209 | ( | |
| 208 | - | Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), | |
| 209 | - | Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(), | |
| 210 | + | crate::ids::AppId::new(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()), | |
| 211 | + | crate::ids::UserId::new(Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap()), | |
| 210 | 212 | ) | |
| 211 | 213 | } | |
| 212 | 214 | ||
| @@ -409,7 +411,7 @@ mod tests { | |||
| 409 | 411 | let resp: AuthResponse = serde_json::from_str(json).unwrap(); | |
| 410 | 412 | assert_eq!(resp.token, "jwt.token.here"); | |
| 411 | 413 | assert_eq!( | |
| 412 | - | resp.user_id, | |
| 414 | + | resp.user_id.as_uuid(), | |
| 413 | 415 | Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap() | |
| 414 | 416 | ); | |
| 415 | 417 | } |
| @@ -190,10 +190,10 @@ mod tests { | |||
| 190 | 190 | } | |
| 191 | 191 | } | |
| 192 | 192 | ||
| 193 | - | fn test_ids() -> (uuid::Uuid, uuid::Uuid) { | |
| 193 | + | fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) { | |
| 194 | 194 | ( | |
| 195 | - | uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), | |
| 196 | - | uuid::Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(), | |
| 195 | + | crate::ids::AppId::new(uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()), | |
| 196 | + | crate::ids::UserId::new(uuid::Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap()), | |
| 197 | 197 | ) | |
| 198 | 198 | } | |
| 199 | 199 |
| @@ -126,7 +126,7 @@ impl SyncKitClient { | |||
| 126 | 126 | table: entry.table, | |
| 127 | 127 | op: entry.op, | |
| 128 | 128 | row_id: entry.row_id, | |
| 129 | - | hlc: Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id), | |
| 129 | + | hlc: Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id.as_uuid()), | |
| 130 | 130 | timestamp: entry.timestamp, | |
| 131 | 131 | data: None, | |
| 132 | 132 | }) | |
| @@ -243,10 +243,10 @@ impl SyncKitClient { | |||
| 243 | 243 | let (hlc, data) = match entry.data { | |
| 244 | 244 | Some(ref value) => { | |
| 245 | 245 | let decrypted = crypto::decrypt_json_aad(value, master_key, &aad)?; | |
| 246 | - | Self::split_hlc_envelope(decrypted, entry.device_id, entry.timestamp.timestamp_millis()) | |
| 246 | + | Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis()) | |
| 247 | 247 | } | |
| 248 | 248 | None => ( | |
| 249 | - | Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id), | |
| 249 | + | Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id.as_uuid()), | |
| 250 | 250 | None, | |
| 251 | 251 | ), | |
| 252 | 252 | }; | |
| @@ -267,10 +267,10 @@ impl SyncKitClient { | |||
| 267 | 267 | let (hlc, data) = match entry.data { | |
| 268 | 268 | Some(ref value) => { | |
| 269 | 269 | let decrypted = crypto::decrypt_json_aad(value, master_key, &aad)?; | |
| 270 | - | Self::split_hlc_envelope(decrypted, entry.device_id, entry.timestamp.timestamp_millis()) | |
| 270 | + | Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis()) | |
| 271 | 271 | } | |
| 272 | 272 | None => ( | |
| 273 | - | Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id), | |
| 273 | + | Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id.as_uuid()), | |
| 274 | 274 | None, | |
| 275 | 275 | ), | |
| 276 | 276 | }; | |
| @@ -454,7 +454,7 @@ mod tests { | |||
| 454 | 454 | ||
| 455 | 455 | let pull_entry = PullChangeEntry { | |
| 456 | 456 | seq: 1, | |
| 457 | - | device_id: device, | |
| 457 | + | device_id: crate::ids::DeviceId::new(device), | |
| 458 | 458 | table: wire.table, | |
| 459 | 459 | op: wire.op, | |
| 460 | 460 | row_id: wire.row_id, | |
| @@ -532,7 +532,7 @@ mod tests { | |||
| 532 | 532 | let wire = client.encrypt_change(entry).unwrap(); | |
| 533 | 533 | let pull_entry = PullChangeEntry { | |
| 534 | 534 | seq: 1, | |
| 535 | - | device_id: uuid::Uuid::new_v4(), | |
| 535 | + | device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()), | |
| 536 | 536 | table: wire.table, | |
| 537 | 537 | op: wire.op, | |
| 538 | 538 | row_id: wire.row_id, | |
| @@ -553,7 +553,7 @@ mod tests { | |||
| 553 | 553 | let client = SyncKitClient::new(test_config()); | |
| 554 | 554 | let pull_entry = PullChangeEntry { | |
| 555 | 555 | seq: 5, | |
| 556 | - | device_id: uuid::Uuid::new_v4(), | |
| 556 | + | device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()), | |
| 557 | 557 | table: "events".to_string(), | |
| 558 | 558 | op: ChangeOp::Delete, | |
| 559 | 559 | row_id: "evt-1".to_string(), | |
| @@ -573,7 +573,7 @@ mod tests { | |||
| 573 | 573 | let client = SyncKitClient::new(test_config()); | |
| 574 | 574 | let pull_entry = PullChangeEntry { | |
| 575 | 575 | seq: 1, | |
| 576 | - | device_id: uuid::Uuid::new_v4(), | |
| 576 | + | device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()), | |
| 577 | 577 | table: "tasks".to_string(), | |
| 578 | 578 | op: ChangeOp::Insert, | |
| 579 | 579 | row_id: "row-1".to_string(), | |
| @@ -830,7 +830,7 @@ mod tests { | |||
| 830 | 830 | for (i, wire) in wire_entries.into_iter().enumerate() { | |
| 831 | 831 | let pull = PullChangeEntry { | |
| 832 | 832 | seq: i as i64, | |
| 833 | - | device_id: uuid::Uuid::new_v4(), | |
| 833 | + | device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()), | |
| 834 | 834 | table: wire.table, | |
| 835 | 835 | op: wire.op, | |
| 836 | 836 | row_id: wire.row_id, | |
| @@ -865,7 +865,7 @@ mod tests { | |||
| 865 | 865 | let wire = client.encrypt_change(entry).unwrap(); | |
| 866 | 866 | let pull = PullChangeEntry { | |
| 867 | 867 | seq: 1, | |
| 868 | - | device_id: uuid::Uuid::new_v4(), | |
| 868 | + | device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()), | |
| 869 | 869 | table: wire.table, | |
| 870 | 870 | op: wire.op, | |
| 871 | 871 | row_id: wire.row_id, | |
| @@ -898,7 +898,7 @@ mod tests { | |||
| 898 | 898 | let wire = client.encrypt_change(entry).unwrap(); | |
| 899 | 899 | let pull = PullChangeEntry { | |
| 900 | 900 | seq: 1, | |
| 901 | - | device_id: uuid::Uuid::new_v4(), | |
| 901 | + | device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()), | |
| 902 | 902 | table: wire.table, | |
| 903 | 903 | op: wire.op, | |
| 904 | 904 | row_id: wire.row_id, | |
| @@ -931,7 +931,7 @@ mod tests { | |||
| 931 | 931 | ) -> PullChangeEntry { | |
| 932 | 932 | PullChangeEntry { | |
| 933 | 933 | seq: 1, | |
| 934 | - | device_id: uuid::Uuid::new_v4(), | |
| 934 | + | device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()), | |
| 935 | 935 | table: "tasks".to_string(), | |
| 936 | 936 | op: ChangeOp::Insert, | |
| 937 | 937 | row_id: "row-multikey".to_string(), |
| @@ -66,11 +66,13 @@ use parking_lot::RwLock; | |||
| 66 | 66 | use reqwest::Client; | |
| 67 | 67 | use std::sync::Arc; | |
| 68 | 68 | use std::time::Duration; | |
| 69 | + | #[cfg(test)] | |
| 69 | 70 | use uuid::Uuid; | |
| 70 | 71 | ||
| 71 | 72 | use crate::{ | |
| 72 | 73 | crypto, | |
| 73 | 74 | error::{Result, SyncKitError}, | |
| 75 | + | ids::{AppId, UserId}, | |
| 74 | 76 | }; | |
| 75 | 77 | ||
| 76 | 78 | /// Maximum number of retry attempts for transient failures. | |
| @@ -143,8 +145,8 @@ struct Session { | |||
| 143 | 145 | token: Arc<String>, | |
| 144 | 146 | /// Cached `exp` claim from the JWT, extracted once at session creation. | |
| 145 | 147 | token_exp: Option<i64>, | |
| 146 | - | user_id: Uuid, | |
| 147 | - | app_id: Uuid, | |
| 148 | + | user_id: UserId, | |
| 149 | + | app_id: AppId, | |
| 148 | 150 | } | |
| 149 | 151 | ||
| 150 | 152 | /// Public session info returned by `session_info()`. | |
| @@ -152,9 +154,9 @@ pub struct SessionInfo { | |||
| 152 | 154 | /// The JWT bearer token for API requests (shared ref-counted to avoid cloning). | |
| 153 | 155 | pub token: Arc<String>, | |
| 154 | 156 | /// The authenticated user's UUID. | |
| 155 | - | pub user_id: Uuid, | |
| 157 | + | pub user_id: UserId, | |
| 156 | 158 | /// The SyncKit app UUID this session belongs to. | |
| 157 | - | pub app_id: Uuid, | |
| 159 | + | pub app_id: AppId, | |
| 158 | 160 | } | |
| 159 | 161 | ||
| 160 | 162 | /// Info about a pending key rotation, cached from `GET /keys`. | |
| @@ -273,7 +275,7 @@ impl SyncKitClient { | |||
| 273 | 275 | /// Extract `(app_id, user_id)` from the current session. | |
| 274 | 276 | /// | |
| 275 | 277 | /// Returns `NotAuthenticated` if no session exists. | |
| 276 | - | pub(crate) fn require_session_ids(&self) -> Result<(Uuid, Uuid)> { | |
| 278 | + | pub(crate) fn require_session_ids(&self) -> Result<(AppId, UserId)> { | |
| 277 | 279 | let guard = self.session.read(); | |
| 278 | 280 | guard | |
| 279 | 281 | .as_ref() | |
| @@ -376,10 +378,10 @@ mod tests { | |||
| 376 | 378 | } | |
| 377 | 379 | } | |
| 378 | 380 | ||
| 379 | - | fn test_ids() -> (Uuid, Uuid) { | |
| 381 | + | fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) { | |
| 380 | 382 | ( | |
| 381 | - | Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), | |
| 382 | - | Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(), | |
| 383 | + | crate::ids::AppId::new(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()), | |
| 384 | + | crate::ids::UserId::new(Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap()), | |
| 383 | 385 | ) | |
| 384 | 386 | } | |
| 385 | 387 |
| @@ -5,6 +5,7 @@ use uuid::Uuid; | |||
| 5 | 5 | use crate::{ | |
| 6 | 6 | crypto, | |
| 7 | 7 | error::Result, | |
| 8 | + | ids::DeviceId, | |
| 8 | 9 | types::*, | |
| 9 | 10 | }; | |
| 10 | 11 | ||
| @@ -32,7 +33,7 @@ impl SyncKitClient { | |||
| 32 | 33 | #[instrument(skip(self, password))] | |
| 33 | 34 | pub async fn rotate_key( | |
| 34 | 35 | &self, | |
| 35 | - | device_id: Uuid, | |
| 36 | + | device_id: DeviceId, | |
| 36 | 37 | password: &str, | |
| 37 | 38 | ) -> Result<()> { | |
| 38 | 39 | // 1. Verify password against the still-active key, and learn whether a | |
| @@ -186,7 +187,7 @@ impl SyncKitClient { | |||
| 186 | 187 | ||
| 187 | 188 | async fn begin_rotation( | |
| 188 | 189 | &self, | |
| 189 | - | device_id: Uuid, | |
| 190 | + | device_id: DeviceId, | |
| 190 | 191 | new_encrypted_key: &str, | |
| 191 | 192 | expected_key_version: i32, | |
| 192 | 193 | ) -> Result<BeginRotationResponse> { | |
| @@ -363,7 +364,7 @@ mod tests { | |||
| 363 | 364 | #[test] | |
| 364 | 365 | fn rotation_request_types_serialize() { | |
| 365 | 366 | let req = BeginRotationRequest { | |
| 366 | - | device_id: Uuid::new_v4(), | |
| 367 | + | device_id: DeviceId::new(Uuid::new_v4()), | |
| 367 | 368 | new_encrypted_key: "envelope-json".to_string(), | |
| 368 | 369 | expected_key_version: 1, | |
| 369 | 370 | }; |
| @@ -5,6 +5,7 @@ use uuid::Uuid; | |||
| 5 | 5 | use crate::{ | |
| 6 | 6 | crypto, | |
| 7 | 7 | error::Result, | |
| 8 | + | ids::DeviceId, | |
| 8 | 9 | types::*, | |
| 9 | 10 | }; | |
| 10 | 11 | ||
| @@ -65,7 +66,7 @@ impl SyncKitClient { | |||
| 65 | 66 | #[instrument(skip(self, changes))] | |
| 66 | 67 | pub async fn push( | |
| 67 | 68 | &self, | |
| 68 | - | device_id: Uuid, | |
| 69 | + | device_id: DeviceId, | |
| 69 | 70 | changes: Vec<ChangeEntry>, | |
| 70 | 71 | ) -> Result<i64> { | |
| 71 | 72 | let token = self.require_token()?; | |
| @@ -118,7 +119,7 @@ impl SyncKitClient { | |||
| 118 | 119 | #[instrument(skip(self))] | |
| 119 | 120 | pub async fn pull( | |
| 120 | 121 | &self, | |
| 121 | - | device_id: Uuid, | |
| 122 | + | device_id: DeviceId, | |
| 122 | 123 | cursor: i64, | |
| 123 | 124 | ) -> Result<(Vec<ChangeEntry>, i64, bool)> { | |
| 124 | 125 | let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?); | |
| @@ -133,7 +134,7 @@ impl SyncKitClient { | |||
| 133 | 134 | #[instrument(skip(self, filter))] | |
| 134 | 135 | pub async fn pull_filtered( | |
| 135 | 136 | &self, | |
| 136 | - | device_id: Uuid, | |
| 137 | + | device_id: DeviceId, | |
| 137 | 138 | cursor: i64, | |
| 138 | 139 | filter: PullFilter, | |
| 139 | 140 | ) -> Result<(Vec<ChangeEntry>, i64, bool)> { | |
| @@ -154,7 +155,7 @@ impl SyncKitClient { | |||
| 154 | 155 | #[instrument(skip(self))] | |
| 155 | 156 | pub async fn pull_rich( | |
| 156 | 157 | &self, | |
| 157 | - | device_id: Uuid, | |
| 158 | + | device_id: DeviceId, | |
| 158 | 159 | cursor: i64, | |
| 159 | 160 | ) -> Result<(Vec<PulledChange>, i64, bool)> { | |
| 160 | 161 | let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?); | |
| @@ -168,7 +169,7 @@ impl SyncKitClient { | |||
| 168 | 169 | #[instrument(skip(self, filter))] | |
| 169 | 170 | pub async fn pull_filtered_rich( | |
| 170 | 171 | &self, | |
| 171 | - | device_id: Uuid, | |
| 172 | + | device_id: DeviceId, | |
| 172 | 173 | cursor: i64, | |
| 173 | 174 | filter: PullFilter, | |
| 174 | 175 | ) -> Result<(Vec<PulledChange>, i64, bool)> { | |
| @@ -266,6 +267,7 @@ mod tests { | |||
| 266 | 267 | use chrono::Utc; | |
| 267 | 268 | use uuid::Uuid; | |
| 268 | 269 | ||
| 270 | + | use crate::ids::{AppId, DeviceId, UserId}; | |
| 269 | 271 | use crate::types::*; | |
| 270 | 272 | ||
| 271 | 273 | // ── Type serialization / deserialization ── | |
| @@ -323,9 +325,9 @@ mod tests { | |||
| 323 | 325 | #[test] | |
| 324 | 326 | fn device_serialization_roundtrip() { | |
| 325 | 327 | let device = Device { | |
| 326 | - | id: Uuid::new_v4(), | |
| 327 | - | app_id: Uuid::new_v4(), | |
| 328 | - | user_id: Uuid::new_v4(), | |
| 328 | + | id: DeviceId::new(Uuid::new_v4()), | |
| 329 | + | app_id: AppId::new(Uuid::new_v4()), | |
| 330 | + | user_id: UserId::new(Uuid::new_v4()), | |
| 329 | 331 | device_name: "MacBook Pro".to_string(), | |
| 330 | 332 | platform: "macos".to_string(), | |
| 331 | 333 | last_seen_at: Utc::now(), | |
| @@ -373,7 +375,7 @@ mod tests { | |||
| 373 | 375 | ||
| 374 | 376 | #[test] | |
| 375 | 377 | fn wire_push_request_serialization() { | |
| 376 | - | let device_id = Uuid::new_v4(); | |
| 378 | + | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 377 | 379 | let req = WirePushRequest { | |
| 378 | 380 | device_id, | |
| 379 | 381 | batch_id: Uuid::new_v4(), | |
| @@ -394,7 +396,7 @@ mod tests { | |||
| 394 | 396 | ||
| 395 | 397 | #[test] | |
| 396 | 398 | fn pull_request_serialization() { | |
| 397 | - | let device_id = Uuid::new_v4(); | |
| 399 | + | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 398 | 400 | let req = PullRequest { | |
| 399 | 401 | device_id, | |
| 400 | 402 | cursor: 42, |
| @@ -14,8 +14,10 @@ | |||
| 14 | 14 | use std::collections::HashMap; | |
| 15 | 15 | ||
| 16 | 16 | use chrono::{DateTime, Utc}; | |
| 17 | + | #[cfg(test)] | |
| 17 | 18 | use uuid::Uuid; | |
| 18 | 19 | ||
| 20 | + | use crate::ids::DeviceId; | |
| 19 | 21 | use crate::types::{ChangeEntry, PulledChange}; | |
| 20 | 22 | ||
| 21 | 23 | /// A remote change that conflicts with a local pending change. | |
| @@ -82,7 +84,7 @@ pub trait ConflictResolver: Send + Sync { | |||
| 82 | 84 | pub fn detect_conflicts( | |
| 83 | 85 | remote: Vec<PulledChange>, | |
| 84 | 86 | local_pending: &[ChangeEntry], | |
| 85 | - | our_device_id: Uuid, | |
| 87 | + | our_device_id: DeviceId, | |
| 86 | 88 | ) -> (Vec<PulledChange>, Vec<ConflictPair>) { | |
| 87 | 89 | // Build lookup: (table, row_id) -> last local pending entry. | |
| 88 | 90 | // If local_pending has duplicates for the same key, the last entry wins. | |
| @@ -296,7 +298,7 @@ mod tests { | |||
| 296 | 298 | ) -> PulledChange { | |
| 297 | 299 | let mut entry = make_entry(table, row_id, op, ts); | |
| 298 | 300 | entry.hlc = Hlc::from_legacy(ts.timestamp_millis(), device_id); | |
| 299 | - | PulledChange { entry, device_id, seq } | |
| 301 | + | PulledChange { entry, device_id: DeviceId::new(device_id), seq } | |
| 300 | 302 | } | |
| 301 | 303 | ||
| 302 | 304 | /// Build a pulled change with an explicit HLC, for resolution tests that need | |
| @@ -322,7 +324,7 @@ mod tests { | |||
| 322 | 324 | make_entry("tasks", "r2", ChangeOp::Update, now), | |
| 323 | 325 | ]; | |
| 324 | 326 | ||
| 325 | - | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 327 | + | let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device)); | |
| 326 | 328 | assert_eq!(clean.len(), 1); | |
| 327 | 329 | assert!(conflicts.is_empty()); | |
| 328 | 330 | } | |
| @@ -340,7 +342,7 @@ mod tests { | |||
| 340 | 342 | make_entry("tasks", "r1", ChangeOp::Update, now), | |
| 341 | 343 | ]; | |
| 342 | 344 | ||
| 343 | - | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 345 | + | let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device)); | |
| 344 | 346 | assert!(clean.is_empty()); | |
| 345 | 347 | assert_eq!(conflicts.len(), 1); | |
| 346 | 348 | assert_eq!(conflicts[0].remote.entry.row_id, "r1"); | |
| @@ -359,7 +361,7 @@ mod tests { | |||
| 359 | 361 | make_entry("tasks", "r1", ChangeOp::Update, now), | |
| 360 | 362 | ]; | |
| 361 | 363 | ||
| 362 | - | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 364 | + | let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device)); | |
| 363 | 365 | assert_eq!(clean.len(), 1); | |
| 364 | 366 | assert!(conflicts.is_empty()); | |
| 365 | 367 | } | |
| @@ -377,7 +379,7 @@ mod tests { | |||
| 377 | 379 | make_entry("events", "r1", ChangeOp::Update, now), | |
| 378 | 380 | ]; | |
| 379 | 381 | ||
| 380 | - | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 382 | + | let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device)); | |
| 381 | 383 | assert_eq!(clean.len(), 1); | |
| 382 | 384 | assert!(conflicts.is_empty()); | |
| 383 | 385 | } | |
| @@ -399,7 +401,7 @@ mod tests { | |||
| 399 | 401 | // r3 not in local → clean | |
| 400 | 402 | ]; | |
| 401 | 403 | ||
| 402 | - | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 404 | + | let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device)); | |
| 403 | 405 | assert_eq!(clean.len(), 2); | |
| 404 | 406 | assert_eq!(conflicts.len(), 1); | |
| 405 | 407 | assert_eq!(conflicts[0].remote.entry.row_id, "r1"); | |
| @@ -415,7 +417,7 @@ mod tests { | |||
| 415 | 417 | make_entry("tasks", "r1", ChangeOp::Update, now), | |
| 416 | 418 | ]; | |
| 417 | 419 | ||
| 418 | - | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 420 | + | let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device)); | |
| 419 | 421 | assert!(clean.is_empty()); | |
| 420 | 422 | assert!(conflicts.is_empty()); | |
| 421 | 423 | } | |
| @@ -431,7 +433,7 @@ mod tests { | |||
| 431 | 433 | ]; | |
| 432 | 434 | let local: Vec<ChangeEntry> = vec![]; | |
| 433 | 435 | ||
| 434 | - | let (clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 436 | + | let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device)); | |
| 435 | 437 | assert_eq!(clean.len(), 1); | |
| 436 | 438 | assert!(conflicts.is_empty()); | |
| 437 | 439 | } | |
| @@ -665,7 +667,7 @@ mod tests { | |||
| 665 | 667 | let now = Utc::now(); | |
| 666 | 668 | let pulled = make_pulled("tasks", "r1", ChangeOp::Insert, now, device_id, 42); | |
| 667 | 669 | ||
| 668 | - | assert_eq!(pulled.device_id, device_id); | |
| 670 | + | assert_eq!(pulled.device_id.as_uuid(), device_id); | |
| 669 | 671 | assert_eq!(pulled.seq, 42); | |
| 670 | 672 | assert_eq!(pulled.entry.table, "tasks"); | |
| 671 | 673 | assert_eq!(pulled.entry.row_id, "r1"); | |
| @@ -747,7 +749,7 @@ mod tests { | |||
| 747 | 749 | make_entry("tasks", "r1", ChangeOp::Update, t2), | |
| 748 | 750 | ]; | |
| 749 | 751 | ||
| 750 | - | let (_clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 752 | + | let (_clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device)); | |
| 751 | 753 | assert_eq!(conflicts.len(), 1); | |
| 752 | 754 | // The conflict should use the Update (last entry), not the Insert | |
| 753 | 755 | assert_eq!(conflicts[0].local.op, ChangeOp::Update); | |
| @@ -884,7 +886,7 @@ mod tests { | |||
| 884 | 886 | make_entry("tasks", "r1", ChangeOp::Update, now), | |
| 885 | 887 | ]; | |
| 886 | 888 | ||
| 887 | - | let (_clean, conflicts) = detect_conflicts(remote, &local, our_device); | |
| 889 | + | let (_clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device)); | |
| 888 | 890 | // All 3 remote changes conflict with the same local entry | |
| 889 | 891 | assert_eq!(conflicts.len(), 3); | |
| 890 | 892 | // Each gets a clone of the same local entry |
| @@ -0,0 +1,106 @@ | |||
| 1 | + | //! Strongly-typed identifier newtypes. | |
| 2 | + | //! | |
| 3 | + | //! Every identifier the SDK exchanges with the server is a [`Uuid`], but the | |
| 4 | + | //! three that name distinct entities — a device, a user, and an app — are easy | |
| 5 | + | //! to transpose at a call site because they share a type. `restore_session` | |
| 6 | + | //! takes a user and an app; `store_key` takes an app and a user; both compiled | |
| 7 | + | //! happily with the arguments reversed. | |
| 8 | + | //! | |
| 9 | + | //! These newtypes make that transposition a compile error. They are | |
| 10 | + | //! `#[serde(transparent)]`, so the wire format is unchanged — a `DeviceId` | |
| 11 | + | //! serializes exactly as the bare `Uuid` it wraps. Conversion in either | |
| 12 | + | //! direction is `From`, and [`as_uuid`](DeviceId::as_uuid) hands back the raw | |
| 13 | + | //! value when a consumer needs to persist it. | |
| 14 | + | ||
| 15 | + | use serde::{Deserialize, Serialize}; | |
| 16 | + | use std::fmt; | |
| 17 | + | use uuid::Uuid; | |
| 18 | + | ||
| 19 | + | macro_rules! id_newtype { | |
| 20 | + | ($(#[$doc:meta])* $name:ident) => { | |
| 21 | + | $(#[$doc])* | |
| 22 | + | #[derive( | |
| 23 | + | Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, | |
| 24 | + | )] | |
| 25 | + | #[serde(transparent)] | |
| 26 | + | pub struct $name(pub Uuid); | |
| 27 | + | ||
| 28 | + | impl $name { | |
| 29 | + | /// Wrap a raw [`Uuid`]. | |
| 30 | + | pub const fn new(id: Uuid) -> Self { | |
| 31 | + | Self(id) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// The underlying [`Uuid`], for persistence or interop with code | |
| 35 | + | /// that has not adopted the newtypes. | |
| 36 | + | pub const fn as_uuid(self) -> Uuid { | |
| 37 | + | self.0 | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | impl From<Uuid> for $name { | |
| 42 | + | fn from(id: Uuid) -> Self { | |
| 43 | + | Self(id) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | impl From<$name> for Uuid { | |
| 48 | + | fn from(value: $name) -> Self { | |
| 49 | + | value.0 | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | impl fmt::Display for $name { | |
| 54 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 55 | + | fmt::Display::fmt(&self.0, f) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | }; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | id_newtype!( | |
| 62 | + | /// A SyncKit device, server-assigned at registration. | |
| 63 | + | DeviceId | |
| 64 | + | ); | |
| 65 | + | id_newtype!( | |
| 66 | + | /// An authenticated MNW user. | |
| 67 | + | UserId | |
| 68 | + | ); | |
| 69 | + | id_newtype!( | |
| 70 | + | /// A SyncKit application (one per product integrating the SDK). | |
| 71 | + | AppId | |
| 72 | + | ); | |
| 73 | + | ||
| 74 | + | #[cfg(test)] | |
| 75 | + | mod tests { | |
| 76 | + | use super::*; | |
| 77 | + | ||
| 78 | + | #[test] | |
| 79 | + | fn serde_is_transparent_to_uuid() { | |
| 80 | + | let raw = Uuid::from_u128(0x1234); | |
| 81 | + | let id = DeviceId::new(raw); | |
| 82 | + | // A newtype serializes identically to the bare Uuid it wraps. | |
| 83 | + | assert_eq!( | |
| 84 | + | serde_json::to_string(&id).unwrap(), | |
| 85 | + | serde_json::to_string(&raw).unwrap() | |
| 86 | + | ); | |
| 87 | + | // And round-trips from the same wire bytes. | |
| 88 | + | let back: DeviceId = serde_json::from_str(&serde_json::to_string(&raw).unwrap()).unwrap(); | |
| 89 | + | assert_eq!(back, id); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | #[test] | |
| 93 | + | fn conversions_round_trip() { | |
| 94 | + | let raw = Uuid::from_u128(7); | |
| 95 | + | assert_eq!(Uuid::from(AppId::from(raw)), raw); | |
| 96 | + | assert_eq!(UserId::new(raw).as_uuid(), raw); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | #[test] | |
| 100 | + | fn distinct_types_do_not_unify() { | |
| 101 | + | // This is a compile-time property; the assertion here is a smoke check | |
| 102 | + | // that Display matches the inner Uuid for log/UI parity. | |
| 103 | + | let raw = Uuid::from_u128(0xabcd); | |
| 104 | + | assert_eq!(DeviceId::new(raw).to_string(), raw.to_string()); | |
| 105 | + | } | |
| 106 | + | } |
| @@ -18,6 +18,8 @@ use crate::error::Result; | |||
| 18 | 18 | use crate::error::SyncKitError; | |
| 19 | 19 | #[cfg(any(feature = "keychain", test))] | |
| 20 | 20 | use base64::{engine::general_purpose::STANDARD as B64, Engine}; | |
| 21 | + | use crate::ids::{AppId, UserId}; | |
| 22 | + | #[cfg(test)] | |
| 21 | 23 | use uuid::Uuid; | |
| 22 | 24 | ||
| 23 | 25 | // These keychain helpers are only referenced by the keychain code paths and the | |
| @@ -30,7 +32,7 @@ const SERVICE_PREFIX: &str = "synckit"; | |||
| 30 | 32 | /// Each SyncKit app gets its own keychain namespace so that keys from | |
| 31 | 33 | /// different apps never collide. | |
| 32 | 34 | #[cfg(any(feature = "keychain", test))] | |
| 33 | - | fn service_name(app_id: Uuid) -> String { | |
| 35 | + | fn service_name(app_id: AppId) -> String { | |
| 34 | 36 | format!("{SERVICE_PREFIX}:{app_id}") | |
| 35 | 37 | } | |
| 36 | 38 | ||
| @@ -39,13 +41,13 @@ fn service_name(app_id: Uuid) -> String { | |||
| 39 | 41 | /// Combined with `service_name`, this uniquely identifies the keychain entry | |
| 40 | 42 | /// for a given (app, user) pair. | |
| 41 | 43 | #[cfg(any(feature = "keychain", test))] | |
| 42 | - | fn user_key(user_id: Uuid) -> String { | |
| 44 | + | fn user_key(user_id: UserId) -> String { | |
| 43 | 45 | user_id.to_string() | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 46 | 48 | /// Store the master key in the OS keychain. | |
| 47 | 49 | #[cfg(feature = "keychain")] | |
| 48 | - | pub fn store_key(app_id: Uuid, user_id: Uuid, master_key: &[u8; 32]) -> Result<()> { | |
| 50 | + | pub fn store_key(app_id: AppId, user_id: UserId, master_key: &[u8; 32]) -> Result<()> { | |
| 49 | 51 | use zeroize::Zeroize; | |
| 50 | 52 | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?; | |
| 51 | 53 | let mut encoded = B64.encode(master_key); | |
| @@ -60,7 +62,7 @@ pub fn store_key(app_id: Uuid, user_id: Uuid, master_key: &[u8; 32]) -> Result<( | |||
| 60 | 62 | /// Load the master key from the OS keychain. | |
| 61 | 63 | /// Returns None if no key is stored (not an error). | |
| 62 | 64 | #[cfg(feature = "keychain")] | |
| 63 | - | pub fn load_key(app_id: Uuid, user_id: Uuid) -> Result<Option<[u8; 32]>> { | |
| 65 | + | pub fn load_key(app_id: AppId, user_id: UserId) -> Result<Option<[u8; 32]>> { | |
| 64 | 66 | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?; | |
| 65 | 67 | ||
| 66 | 68 | match entry.get_password() { | |
| @@ -86,7 +88,7 @@ pub fn load_key(app_id: Uuid, user_id: Uuid) -> Result<Option<[u8; 32]>> { | |||
| 86 | 88 | ||
| 87 | 89 | /// Delete the master key from the OS keychain. | |
| 88 | 90 | #[cfg(feature = "keychain")] | |
| 89 | - | pub fn delete_key(app_id: Uuid, user_id: Uuid) -> Result<()> { | |
| 91 | + | pub fn delete_key(app_id: AppId, user_id: UserId) -> Result<()> { | |
| 90 | 92 | let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?; | |
| 91 | 93 | ||
| 92 | 94 | match entry.delete_credential() { | |
| @@ -102,18 +104,18 @@ pub fn delete_key(app_id: Uuid, user_id: Uuid) -> Result<()> { | |||
| 102 | 104 | // ── No-op stubs when keychain feature is disabled ── | |
| 103 | 105 | ||
| 104 | 106 | #[cfg(not(feature = "keychain"))] | |
| 105 | - | pub fn store_key(_app_id: Uuid, _user_id: Uuid, _master_key: &[u8; 32]) -> Result<()> { | |
| 107 | + | pub fn store_key(_app_id: AppId, _user_id: UserId, _master_key: &[u8; 32]) -> Result<()> { | |
| 106 | 108 | tracing::warn!("Keychain support disabled — master key not persisted"); | |
| 107 | 109 | Ok(()) | |
| 108 | 110 | } | |
| 109 | 111 | ||
| 110 | 112 | #[cfg(not(feature = "keychain"))] | |
| 111 | - | pub fn load_key(_app_id: Uuid, _user_id: Uuid) -> Result<Option<[u8; 32]>> { | |
| 113 | + | pub fn load_key(_app_id: AppId, _user_id: UserId) -> Result<Option<[u8; 32]>> { | |
| 112 | 114 | Ok(None) | |
| 113 | 115 | } | |
| 114 | 116 | ||
| 115 | 117 | #[cfg(not(feature = "keychain"))] | |
| 116 | - | pub fn delete_key(_app_id: Uuid, _user_id: Uuid) -> Result<()> { | |
| 118 | + | pub fn delete_key(_app_id: AppId, _user_id: UserId) -> Result<()> { | |
| 117 | 119 | Ok(()) | |
| 118 | 120 | } | |
| 119 | 121 | ||
| @@ -132,10 +134,10 @@ mod keystore_tests { | |||
| 132 | 134 | use super::*; | |
| 133 | 135 | use base64::{engine::general_purpose::STANDARD as B64, Engine}; | |
| 134 | 136 | ||
| 135 | - | fn test_ids() -> (Uuid, Uuid) { | |
| 137 | + | fn test_ids() -> (AppId, UserId) { | |
| 136 | 138 | ( | |
| 137 | - | Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), | |
| 138 | - | Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(), | |
| 139 | + | AppId::new(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()), | |
| 140 | + | UserId::new(Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap()), | |
| 139 | 141 | ) | |
| 140 | 142 | } | |
| 141 | 143 | ||
| @@ -164,7 +166,8 @@ mod keystore_tests { | |||
| 164 | 166 | ||
| 165 | 167 | #[test] | |
| 166 | 168 | fn service_name_different_ids_produce_different_names() { | |
| 167 | - | let (app_id1, app_id2) = test_ids(); | |
| 169 | + | let app_id1 = AppId::new(Uuid::from_u128(1)); | |
| 170 | + | let app_id2 = AppId::new(Uuid::from_u128(2)); | |
| 168 | 171 | assert_ne!(service_name(app_id1), service_name(app_id2)); | |
| 169 | 172 | } | |
| 170 | 173 | ||
| @@ -182,7 +185,7 @@ mod keystore_tests { | |||
| 182 | 185 | let (_, user_id) = test_ids(); | |
| 183 | 186 | let key = user_key(user_id); | |
| 184 | 187 | let parsed = Uuid::parse_str(&key).expect("user_key should produce a valid UUID string"); | |
| 185 | - | assert_eq!(parsed, user_id); | |
| 188 | + | assert_eq!(parsed, user_id.as_uuid()); | |
| 186 | 189 | } | |
| 187 | 190 | ||
| 188 | 191 | // ── Base64 round-trip (mirrors store_key encode / load_key decode) ── |
| @@ -47,6 +47,7 @@ pub mod client; | |||
| 47 | 47 | pub mod conflict; | |
| 48 | 48 | pub mod crypto; | |
| 49 | 49 | pub mod error; | |
| 50 | + | pub mod ids; | |
| 50 | 51 | pub mod keystore; | |
| 51 | 52 | pub mod oauth; | |
| 52 | 53 | pub mod types; | |
| @@ -62,4 +63,5 @@ pub use conflict::{ | |||
| 62 | 63 | detect_conflicts, resolve_field_merge, resolve_lww, ConflictPair, ConflictResolver, Resolution, | |
| 63 | 64 | }; | |
| 64 | 65 | pub use error::{Result, SyncKitError}; | |
| 66 | + | pub use ids::{AppId, DeviceId, UserId}; | |
| 65 | 67 | pub use types::{ChangeEntry, ChangeOp, Device, Hlc, PullFilter, PulledChange, SyncStatus}; |
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! Request/response types matching the MNW SyncKit server API. | |
| 2 | 2 | ||
| 3 | + | use crate::ids::{AppId, DeviceId, UserId}; | |
| 3 | 4 | use chrono::{DateTime, Utc}; | |
| 4 | 5 | use serde::{Deserialize, Serialize}; | |
| 5 | 6 | use std::fmt; | |
| @@ -152,8 +153,8 @@ pub(crate) struct AuthRequest<'a> { | |||
| 152 | 153 | #[derive(Deserialize)] | |
| 153 | 154 | pub(crate) struct AuthResponse { | |
| 154 | 155 | pub token: String, | |
| 155 | - | pub user_id: Uuid, | |
| 156 | - | pub app_id: Uuid, | |
| 156 | + | pub user_id: UserId, | |
| 157 | + | pub app_id: AppId, | |
| 157 | 158 | } | |
| 158 | 159 | ||
| 159 | 160 | // ── Devices ── | |
| @@ -168,11 +169,11 @@ pub(crate) struct RegisterDeviceRequest { | |||
| 168 | 169 | #[non_exhaustive] | |
| 169 | 170 | pub struct Device { | |
| 170 | 171 | /// Server-assigned device UUID. | |
| 171 | - | pub id: Uuid, | |
| 172 | + | pub id: DeviceId, | |
| 172 | 173 | /// The SyncKit app this device belongs to. | |
| 173 | - | pub app_id: Uuid, | |
| 174 | + | pub app_id: AppId, | |
| 174 | 175 | /// The user who owns this device. | |
| 175 | - | pub user_id: Uuid, | |
| 176 | + | pub user_id: UserId, | |
| 176 | 177 | /// Human-readable name (e.g., "MacBook Pro"). | |
| 177 | 178 | pub device_name: String, | |
| 178 | 179 | /// OS identifier (e.g., "macos", "linux", "windows"). | |
| @@ -215,7 +216,7 @@ pub struct ChangeEntry { | |||
| 215 | 216 | #[derive(Serialize)] | |
| 216 | 217 | pub(crate) struct WirePushRequest { | |
| 217 | 218 | /// The device sending the changes. | |
| 218 | - | pub device_id: Uuid, | |
| 219 | + | pub device_id: DeviceId, | |
| 219 | 220 | /// Client-generated UUID for idempotent push. If a push with the same | |
| 220 | 221 | /// batch_id was already committed, the server returns the existing cursor. | |
| 221 | 222 | pub batch_id: Uuid, | |
| @@ -239,7 +240,7 @@ pub(crate) struct PushResponse { | |||
| 239 | 240 | ||
| 240 | 241 | #[derive(Serialize)] | |
| 241 | 242 | pub(crate) struct PullRequest { | |
| 242 | - | pub device_id: Uuid, | |
| 243 | + | pub device_id: DeviceId, | |
| 243 | 244 | pub cursor: i64, | |
| 244 | 245 | } | |
| 245 | 246 | ||
| @@ -260,7 +261,7 @@ pub(crate) struct PullChangeEntry { | |||
| 260 | 261 | #[allow(dead_code)] | |
| 261 | 262 | pub seq: i64, | |
| 262 | 263 | #[allow(dead_code)] | |
| 263 | - | pub device_id: Uuid, | |
| 264 | + | pub device_id: DeviceId, | |
| 264 | 265 | pub table: String, | |
| 265 | 266 | pub op: ChangeOp, | |
| 266 | 267 | pub row_id: String, | |
| @@ -299,7 +300,7 @@ impl PullFilter { | |||
| 299 | 300 | ||
| 300 | 301 | #[derive(Serialize)] | |
| 301 | 302 | pub(crate) struct FilteredPullRequest { | |
| 302 | - | pub device_id: Uuid, | |
| 303 | + | pub device_id: DeviceId, | |
| 303 | 304 | pub cursor: i64, | |
| 304 | 305 | #[serde(skip_serializing_if = "PullFilter::tables_is_empty")] | |
| 305 | 306 | pub tables: Option<Vec<String>>, | |
| @@ -316,11 +317,12 @@ pub(crate) struct FilteredPullRequest { | |||
| 316 | 317 | /// conflict detection — the `device_id` identifies whether a change came from | |
| 317 | 318 | /// another device, and `seq` provides total server ordering. | |
| 318 | 319 | #[derive(Debug, Clone)] | |
| 320 | + | #[non_exhaustive] | |
| 319 | 321 | pub struct PulledChange { | |
| 320 | 322 | /// The decrypted change entry. | |
| 321 | 323 | pub entry: ChangeEntry, | |
| 322 | 324 | /// The device that originated this change. | |
| 323 | - | pub device_id: Uuid, | |
| 325 | + | pub device_id: DeviceId, | |
| 324 | 326 | /// Server sequence number (total ordering). | |
| 325 | 327 | pub seq: i64, | |
| 326 | 328 | } | |
| @@ -360,7 +362,7 @@ pub(crate) struct PendingKeyInfo { | |||
| 360 | 362 | ||
| 361 | 363 | #[derive(Serialize)] | |
| 362 | 364 | pub(crate) struct BeginRotationRequest { | |
| 363 | - | pub device_id: Uuid, | |
| 365 | + | pub device_id: DeviceId, | |
| 364 | 366 | pub new_encrypted_key: String, | |
| 365 | 367 | pub expected_key_version: i32, | |
| 366 | 368 | } | |
| @@ -431,8 +433,8 @@ pub(crate) struct OAuthTokenResponse { | |||
| 431 | 433 | pub token_type: String, | |
| 432 | 434 | #[allow(dead_code)] | |
| 433 | 435 | pub expires_in: i64, | |
| 434 | - | pub user_id: Uuid, | |
| 435 | - | pub app_id: Uuid, | |
| 436 | + | pub user_id: UserId, | |
| 437 | + | pub app_id: AppId, | |
| 436 | 438 | } | |
| 437 | 439 | ||
| 438 | 440 | // ── Status ── | |
| @@ -455,6 +457,7 @@ pub(crate) struct BlobUploadUrlRequest { | |||
| 455 | 457 | } | |
| 456 | 458 | ||
| 457 | 459 | #[derive(Deserialize)] | |
| 460 | + | #[non_exhaustive] | |
| 458 | 461 | pub struct BlobUploadUrlResponse { | |
| 459 | 462 | /// Presigned S3 PUT URL. Empty string when `already_exists` is true. | |
| 460 | 463 | pub upload_url: String, | |
| @@ -599,7 +602,7 @@ mod tests { | |||
| 599 | 602 | assert_eq!(device.device_name, "MacBook Pro"); | |
| 600 | 603 | assert_eq!(device.platform, "macos"); | |
| 601 | 604 | assert_eq!( | |
| 602 | - | device.id, | |
| 605 | + | device.id.as_uuid(), | |
| 603 | 606 | Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap() | |
| 604 | 607 | ); | |
| 605 | 608 | } | |
| @@ -665,7 +668,7 @@ mod tests { | |||
| 665 | 668 | #[test] | |
| 666 | 669 | fn filtered_pull_request_includes_filter_fields() { | |
| 667 | 670 | let req = FilteredPullRequest { | |
| 668 | - | device_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), | |
| 671 | + | device_id: DeviceId::new(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()), | |
| 669 | 672 | cursor: 42, | |
| 670 | 673 | tables: Some(vec!["tasks".to_string()]), | |
| 671 | 674 | since: Some("2025-01-01T00:00:00Z".parse().unwrap()), |
| @@ -14,7 +14,9 @@ use wiremock::matchers::{method, path}; | |||
| 14 | 14 | use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 15 | 15 | ||
| 16 | 16 | use std::time::Duration; | |
| 17 | - | use synckit_client::{ChangeEntry, ChangeOp, SyncKitClient, SyncKitConfig, SyncKitError}; | |
| 17 | + | use synckit_client::{ | |
| 18 | + | AppId, ChangeEntry, ChangeOp, DeviceId, SyncKitClient, SyncKitConfig, SyncKitError, UserId, | |
| 19 | + | }; | |
| 18 | 20 | ||
| 19 | 21 | // ── Helpers ── | |
| 20 | 22 | ||
| @@ -37,10 +39,10 @@ fn fresh_token() -> String { | |||
| 37 | 39 | fake_jwt(Utc::now().timestamp() + 3600) | |
| 38 | 40 | } | |
| 39 | 41 | ||
| 40 | - | fn test_ids() -> (Uuid, Uuid) { | |
| 42 | + | fn test_ids() -> (UserId, AppId) { | |
| 41 | 43 | ( | |
| 42 | - | Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), | |
| 43 | - | Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(), | |
| 44 | + | UserId::new(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()), | |
| 45 | + | AppId::new(Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap()), | |
| 44 | 46 | ) | |
| 45 | 47 | } | |
| 46 | 48 | ||
| @@ -248,7 +250,7 @@ async fn push_encrypts_data() { | |||
| 248 | 250 | let key = synckit_client::crypto::generate_master_key(); | |
| 249 | 251 | client.set_master_key_raw(key); | |
| 250 | 252 | ||
| 251 | - | let device_id = Uuid::new_v4(); | |
| 253 | + | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 252 | 254 | let cursor = client | |
| 253 | 255 | .push( | |
| 254 | 256 | device_id, | |
| @@ -292,7 +294,7 @@ async fn pull_decrypts_data() { | |||
| 292 | 294 | let plaintext = json!({"title": "Decrypted task"}); | |
| 293 | 295 | let encrypted = synckit_client::crypto::encrypt_json(&plaintext, &key).unwrap(); | |
| 294 | 296 | ||
| 295 | - | let device_id = Uuid::new_v4(); | |
| 297 | + | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 296 | 298 | Mock::given(method("POST")) | |
| 297 | 299 | .and(path("/api/v1/sync/pull")) | |
| 298 | 300 | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| @@ -339,7 +341,7 @@ async fn push_retries_on_503() { | |||
| 339 | 341 | let key = synckit_client::crypto::generate_master_key(); | |
| 340 | 342 | client.set_master_key_raw(key); | |
| 341 | 343 | ||
| 342 | - | let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap(); | |
| 344 | + | let cursor = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap(); | |
| 343 | 345 | assert_eq!(cursor, 5); | |
| 344 | 346 | } | |
| 345 | 347 | ||
| @@ -358,7 +360,7 @@ async fn push_fails_immediately_on_401() { | |||
| 358 | 360 | let key = synckit_client::crypto::generate_master_key(); | |
| 359 | 361 | client.set_master_key_raw(key); | |
| 360 | 362 | ||
| 361 | - | let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err(); | |
| 363 | + | let err = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap_err(); | |
| 362 | 364 | assert!(matches!(err, SyncKitError::Server { status: 401, .. })); | |
| 363 | 365 | } | |
| 364 | 366 | ||
| @@ -370,7 +372,7 @@ async fn pull_with_has_more_pagination() { | |||
| 370 | 372 | let key = synckit_client::crypto::generate_master_key(); | |
| 371 | 373 | client.set_master_key_raw(key); | |
| 372 | 374 | ||
| 373 | - | let device_id = Uuid::new_v4(); | |
| 375 | + | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 374 | 376 | ||
| 375 | 377 | // First pull: has_more = true | |
| 376 | 378 | Mock::given(method("POST")) | |
| @@ -597,7 +599,7 @@ async fn push_without_auth_returns_not_authenticated() { | |||
| 597 | 599 | let server = MockServer::start().await; | |
| 598 | 600 | let client = client_for(&server); | |
| 599 | 601 | ||
| 600 | - | let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err(); | |
| 602 | + | let err = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap_err(); | |
| 601 | 603 | assert!(matches!(err, SyncKitError::NotAuthenticated)); | |
| 602 | 604 | } | |
| 603 | 605 | ||
| @@ -670,7 +672,7 @@ async fn concurrent_push_pull_no_panics() { | |||
| 670 | 672 | .mount(&server) | |
| 671 | 673 | .await; | |
| 672 | 674 | ||
| 673 | - | let device_id = Uuid::new_v4(); | |
| 675 | + | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 674 | 676 | Mock::given(method("POST")) | |
| 675 | 677 | .and(path("/api/v1/sync/pull")) | |
| 676 | 678 | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| @@ -1012,7 +1014,7 @@ async fn push_empty_changes_succeeds() { | |||
| 1012 | 1014 | let key = synckit_client::crypto::generate_master_key(); | |
| 1013 | 1015 | client.set_master_key_raw(key); | |
| 1014 | 1016 | ||
| 1015 | - | let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap(); | |
| 1017 | + | let cursor = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap(); | |
| 1016 | 1018 | assert_eq!(cursor, 0); | |
| 1017 | 1019 | } | |
| 1018 | 1020 | ||
| @@ -1034,7 +1036,7 @@ async fn push_malformed_json_response_handled() { | |||
| 1034 | 1036 | let key = synckit_client::crypto::generate_master_key(); | |
| 1035 | 1037 | client.set_master_key_raw(key); | |
| 1036 | 1038 | ||
| 1037 | - | let result = client.push(Uuid::new_v4(), vec![]).await; | |
| 1039 | + | let result = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await; | |
| 1038 | 1040 | assert!(result.is_err(), "Malformed JSON should produce an error"); | |
| 1039 | 1041 | // Should be a JSON parse error, not a panic | |
| 1040 | 1042 | let err = result.unwrap_err(); | |
| @@ -1060,7 +1062,7 @@ async fn pull_malformed_json_response_handled() { | |||
| 1060 | 1062 | let key = synckit_client::crypto::generate_master_key(); | |
| 1061 | 1063 | client.set_master_key_raw(key); | |
| 1062 | 1064 | ||
| 1063 | - | let result = client.pull(Uuid::new_v4(), 0).await; | |
| 1065 | + | let result = client.pull(DeviceId::new(Uuid::new_v4()), 0).await; | |
| 1064 | 1066 | assert!(result.is_err(), "Malformed JSON should produce an error"); | |
| 1065 | 1067 | } | |
| 1066 | 1068 | ||
| @@ -1129,7 +1131,7 @@ async fn concurrent_push_operations_no_data_corruption() { | |||
| 1129 | 1131 | for i in 0..8 { | |
| 1130 | 1132 | let c = Arc::clone(&client); | |
| 1131 | 1133 | handles.push(tokio::spawn(async move { | |
| 1132 | - | let device_id = Uuid::new_v4(); | |
| 1134 | + | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 1133 | 1135 | let entry = ChangeEntry { | |
| 1134 | 1136 | table: format!("table_{i}"), | |
| 1135 | 1137 | op: ChangeOp::Insert, | |
| @@ -1151,7 +1153,7 @@ async fn concurrent_push_operations_no_data_corruption() { | |||
| 1151 | 1153 | #[tokio::test] | |
| 1152 | 1154 | async fn concurrent_push_and_pull_interleaved() { | |
| 1153 | 1155 | let server = MockServer::start().await; | |
| 1154 | - | let device_id = Uuid::new_v4(); | |
| 1156 | + | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 1155 | 1157 | ||
| 1156 | 1158 | Mock::given(method("POST")) | |
| 1157 | 1159 | .and(path("/api/v1/sync/push")) | |
| @@ -1221,7 +1223,7 @@ async fn push_many_changes_succeeds() { | |||
| 1221 | 1223 | }) | |
| 1222 | 1224 | .collect(); | |
| 1223 | 1225 | ||
| 1224 | - | let cursor = client.push(Uuid::new_v4(), changes).await.unwrap(); | |
| 1226 | + | let cursor = client.push(DeviceId::new(Uuid::new_v4()), changes).await.unwrap(); | |
| 1225 | 1227 | assert_eq!(cursor, 1000); | |
| 1226 | 1228 | } | |
| 1227 | 1229 | ||
| @@ -1238,7 +1240,7 @@ async fn expired_token_detected_before_push() { | |||
| 1238 | 1240 | client | |
| 1239 | 1241 | .set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 1240 | 1242 | ||
| 1241 | - | let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err(); | |
| 1243 | + | let err = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap_err(); | |
| 1242 | 1244 | assert!( | |
| 1243 | 1245 | matches!(err, SyncKitError::TokenExpired), | |
| 1244 | 1246 | "Expired token should be detected pre-flight, got: {err:?}" | |
| @@ -1256,7 +1258,7 @@ async fn expired_token_detected_before_pull() { | |||
| 1256 | 1258 | client | |
| 1257 | 1259 | .set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 1258 | 1260 | ||
| 1259 | - | let err = client.pull(Uuid::new_v4(), 0).await.unwrap_err(); | |
| 1261 | + | let err = client.pull(DeviceId::new(Uuid::new_v4()), 0).await.unwrap_err(); | |
| 1260 | 1262 | assert!(matches!(err, SyncKitError::TokenExpired)); | |
| 1261 | 1263 | } | |
| 1262 | 1264 | ||
| @@ -1389,7 +1391,7 @@ async fn push_with_data_fails_without_master_key() { | |||
| 1389 | 1391 | data: Some(json!({"title": "test"})), | |
| 1390 | 1392 | }]; | |
| 1391 | 1393 | ||
| 1392 | - | let err = client.push(Uuid::new_v4(), changes).await.unwrap_err(); | |
| 1394 | + | let err = client.push(DeviceId::new(Uuid::new_v4()), changes).await.unwrap_err(); | |
| 1393 | 1395 | assert!( | |
| 1394 | 1396 | matches!(err, SyncKitError::NoMasterKey), | |
| 1395 | 1397 | "Push with data should fail without master key: {err:?}" | |
| @@ -1421,7 +1423,7 @@ async fn push_delete_requires_master_key() { | |||
| 1421 | 1423 | data: None, | |
| 1422 | 1424 | }]; | |
| 1423 | 1425 | ||
| 1424 | - | let err = client.push(Uuid::new_v4(), changes).await.unwrap_err(); | |
| 1426 | + | let err = client.push(DeviceId::new(Uuid::new_v4()), changes).await.unwrap_err(); | |
| 1425 | 1427 | assert!( | |
| 1426 | 1428 | matches!(err, SyncKitError::NoMasterKey), | |
| 1427 | 1429 | "Delete now seals an HLC envelope and needs the key: {err:?}" | |
| @@ -1495,10 +1497,10 @@ async fn double_push_same_data_both_succeed() { | |||
| 1495 | 1497 | }; | |
| 1496 | 1498 | ||
| 1497 | 1499 | let cursor1 = client | |
| 1498 | - | .push(Uuid::new_v4(), vec![entry.clone()]) | |
| 1500 | + | .push(DeviceId::new(Uuid::new_v4()), vec![entry.clone()]) | |
| 1499 | 1501 | .await | |
| 1500 | 1502 | .unwrap(); | |
| 1501 | - | let cursor2 = client.push(Uuid::new_v4(), vec![entry]).await.unwrap(); | |
| 1503 | + | let cursor2 = client.push(DeviceId::new(Uuid::new_v4()), vec![entry]).await.unwrap(); | |
| 1502 | 1504 | ||
| 1503 | 1505 | assert_eq!(cursor1, 1); | |
| 1504 | 1506 | assert_eq!(cursor2, 2); | |
| @@ -1720,7 +1722,7 @@ async fn encryption_setup_cross_device_roundtrip() { | |||
| 1720 | 1722 | .unwrap(); | |
| 1721 | 1723 | ||
| 1722 | 1724 | // Push encrypted data from device 1 | |
| 1723 | - | let device_id = Uuid::new_v4(); | |
| 1725 | + | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 1724 | 1726 | let original_data = json!({"title": "cross-device test", "secret": true}); | |
| 1725 | 1727 | client1 | |
| 1726 | 1728 | .push( | |
| @@ -1932,7 +1934,7 @@ async fn pull_without_auth_returns_not_authenticated() { | |||
| 1932 | 1934 | let server = MockServer::start().await; | |
| 1933 | 1935 | let client = client_for(&server); | |
| 1934 | 1936 | ||
| 1935 | - | let err = client.pull(Uuid::new_v4(), 0).await.unwrap_err(); | |
| 1937 | + | let err = client.pull(DeviceId::new(Uuid::new_v4()), 0).await.unwrap_err(); | |
| 1936 | 1938 | assert!(matches!(err, SyncKitError::NotAuthenticated)); | |
| 1937 | 1939 | } | |
| 1938 | 1940 | ||
| @@ -1968,7 +1970,7 @@ async fn end_to_end_push_pull_encryption_roundtrip() { | |||
| 1968 | 1970 | let key = synckit_client::crypto::generate_master_key(); | |
| 1969 | 1971 | client.set_master_key_raw(key); | |
| 1970 | 1972 | ||
| 1971 | - | let device_id = Uuid::new_v4(); | |
| 1973 | + | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 1972 | 1974 | let original_data = json!({ | |
| 1973 | 1975 | "title": "End-to-end test", | |
| 1974 | 1976 | "tags": ["e2e", "encryption"], | |
| @@ -2156,7 +2158,7 @@ async fn push_empty_response_body_returns_error() { | |||
| 2156 | 2158 | let key = synckit_client::crypto::generate_master_key(); | |
| 2157 | 2159 | client.set_master_key_raw(key); | |
| 2158 | 2160 | ||
| 2159 | - | let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err(); | |
| 2161 | + | let err = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap_err(); | |
| 2160 | 2162 | assert!( | |
| 2161 | 2163 | matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)), | |
| 2162 | 2164 | "Empty body should produce parse error, got: {err:?}" | |
| @@ -2183,7 +2185,7 @@ async fn pull_response_missing_has_more_returns_error() { | |||
| 2183 | 2185 | let key = synckit_client::crypto::generate_master_key(); | |
| 2184 | 2186 | client.set_master_key_raw(key); | |
| 2185 | 2187 | ||
| 2186 | - | let err = client.pull(Uuid::new_v4(), 0).await.unwrap_err(); | |
| 2188 | + | let err = client.pull(DeviceId::new(Uuid::new_v4()), 0).await.unwrap_err(); | |
| 2187 | 2189 | assert!( | |
| 2188 | 2190 | matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)), | |
| 2189 | 2191 | "Missing has_more should produce parse error, got: {err:?}" | |
| @@ -2307,7 +2309,7 @@ async fn server_returns_413_request_entity_too_large() { | |||
| 2307 | 2309 | let key = synckit_client::crypto::generate_master_key(); | |
| 2308 | 2310 | client.set_master_key_raw(key); | |
| 2309 | 2311 | ||
| 2310 | - | let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err(); | |
| 2312 | + | let err = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap_err(); | |
| 2311 | 2313 | match err { | |
| 2312 | 2314 | SyncKitError::Server { status, message, .. } => { | |
| 2313 | 2315 | assert_eq!(status, 413); | |
| @@ -2324,7 +2326,7 @@ async fn double_authenticate_overwrites_session() { | |||
| 2324 | 2326 | let server = MockServer::start().await; | |
| 2325 | 2327 | ||
| 2326 | 2328 | let (user_id, app_id) = test_ids(); | |
| 2327 | - | let second_user_id = Uuid::new_v4(); | |
| 2329 | + | let second_user_id = UserId::new(Uuid::new_v4()); | |
| 2328 | 2330 | ||
| 2329 | 2331 | // First auth response | |
| 2330 | 2332 | Mock::given(method("POST")) | |
| @@ -2394,7 +2396,7 @@ async fn restore_session_with_expired_token_then_push_returns_token_expired() { | |||
| 2394 | 2396 | client | |
| 2395 | 2397 | .set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 2396 | 2398 | ||
| 2397 | - | let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err(); | |
| 2399 | + | let err = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap_err(); | |
| 2398 | 2400 | assert!( | |
| 2399 | 2401 | matches!(err, SyncKitError::TokenExpired), | |
| 2400 | 2402 | "Restored expired token should return TokenExpired, got: {err:?}" | |
| @@ -2673,7 +2675,7 @@ async fn concurrent_push_100_entries_each() { | |||
| 2673 | 2675 | data: Some(json!({"index": i})), | |
| 2674 | 2676 | }) | |
| 2675 | 2677 | .collect(); | |
| 2676 | - | c.push(Uuid::new_v4(), changes).await | |
| 2678 | + | c.push(DeviceId::new(Uuid::new_v4()), changes).await | |
| 2677 | 2679 | })); | |
| 2678 | 2680 | } | |
| 2679 | 2681 | ||
| @@ -2758,6 +2760,6 @@ async fn push_retries_on_timeout_then_succeeds() { | |||
| 2758 | 2760 | let key = synckit_client::crypto::generate_master_key(); | |
| 2759 | 2761 | client.set_master_key_raw(key); | |
| 2760 | 2762 | ||
| 2761 | - | let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap(); | |
| 2763 | + | let cursor = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await.unwrap(); | |
| 2762 | 2764 | assert_eq!(cursor, 42); | |
| 2763 | 2765 | } |