Skip to main content

max / makenotwork

synckit: strongly-typed DeviceId/UserId/AppId newtypes + non_exhaustive responses Deep A+ type-safety pass (ultra-fuzz Run #1 follow-on). Introduces serde-transparent DeviceId/UserId/AppId newtypes so transposable same-type IDs (restore_session, store_key, authenticate) become compile errors; wire format unchanged. Adds #[non_exhaustive] to the two public response types (PulledChange, BlobUploadUrlResponse) that the prior remediation missed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 22:15 UTC
Commit: ffe89f407cb15ff95527e36cbcb99c31608dd508
Parent: cd46703
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 }