Skip to main content

max / makenotwork

33.6 KB · 951 lines History Blame Raw
1 //! Client-side conflict detection and resolution for SyncKit.
2 //!
3 //! SyncKit uses E2E encryption — the server never sees row contents and cannot
4 //! merge or compare data. All conflict resolution must happen client-side after
5 //! decryption.
6 //!
7 //! This module provides:
8 //! - [`detect_conflicts`]: pure function that splits pulled changes into clean
9 //! (non-conflicting) and conflicting sets
10 //! - [`resolve_lww`]: last-write-wins resolution by `client_timestamp`
11 //! - [`resolve_field_merge`]: 3-way JSON object merge using a base version
12 //! - [`ConflictResolver`]: trait for custom resolution strategies
13
14 use std::collections::HashMap;
15
16 use chrono::{DateTime, Utc};
17 use uuid::Uuid;
18
19 use crate::types::{ChangeEntry, ChangeOp, PulledChange};
20
21 /// A remote change that conflicts with a local pending change.
22 #[derive(Debug, Clone)]
23 pub struct ConflictPair {
24 /// The remote change from pull.
25 pub remote: PulledChange,
26 /// The local pending change that conflicts.
27 pub local: ChangeEntry,
28 }
29
30 /// How a conflict should be resolved.
31 #[derive(Debug, Clone)]
32 pub enum Resolution {
33 /// Keep the local version; skip the remote change.
34 /// The local version will push on the next sync cycle.
35 KeepLocal,
36 /// Apply the remote change, discarding the local version.
37 KeepRemote,
38 /// Apply a merged result that combines both changes.
39 Merged(serde_json::Value),
40 /// Skip both changes (neither apply nor push).
41 Skip,
42 }
43
44 /// Trait for custom conflict resolution strategies.
45 ///
46 /// Implement this to plug in app-specific merge logic. The `base` parameter
47 /// is `Some` only if the app provides a base-store adapter.
48 pub trait ConflictResolver: Send + Sync {
49 fn resolve(
50 &self,
51 local: &ChangeEntry,
52 remote: &PulledChange,
53 base: Option<&serde_json::Value>,
54 ) -> Resolution;
55 }
56
57 /// Split pulled changes into non-conflicting and conflicting sets.
58 ///
59 /// A conflict exists when a remote change and a local pending change both
60 /// modify the same `(table, row_id)` from different devices. Changes from
61 /// our own device (echoes) are never treated as conflicts.
62 ///
63 /// **Precondition:** `local_pending` should contain at most one entry per
64 /// `(table, row_id)`. If duplicates exist, only the last one participates
65 /// in conflict detection (earlier entries are silently ignored). Callers
66 /// should compact local pending changes before calling this function.
67 ///
68 /// If `remote` contains multiple changes for the same `(table, row_id)`,
69 /// each produces a separate `ConflictPair` with a clone of the same local
70 /// entry. The caller must resolve them in order (by `seq`).
71 ///
72 /// Returns `(clean, conflicts)` where `clean` changes can be applied directly.
73 pub fn detect_conflicts(
74 remote: Vec<PulledChange>,
75 local_pending: &[ChangeEntry],
76 our_device_id: Uuid,
77 ) -> (Vec<PulledChange>, Vec<ConflictPair>) {
78 // Build lookup: (table, row_id) -> last local pending entry.
79 // If local_pending has duplicates for the same key, the last entry wins.
80 let mut local_map: HashMap<(&str, &str), &ChangeEntry> = HashMap::new();
81 for entry in local_pending {
82 local_map.insert((&entry.table, &entry.row_id), entry);
83 }
84
85 let mut clean = Vec::new();
86 let mut conflicts = Vec::new();
87
88 for pulled in remote {
89 // Our own echo — never a conflict
90 if pulled.device_id == our_device_id {
91 clean.push(pulled);
92 continue;
93 }
94
95 let key = (pulled.entry.table.as_str(), pulled.entry.row_id.as_str());
96 if let Some(&local_entry) = local_map.get(&key) {
97 conflicts.push(ConflictPair {
98 remote: pulled,
99 local: local_entry.clone(),
100 });
101 } else {
102 clean.push(pulled);
103 }
104 }
105
106 (clean, conflicts)
107 }
108
109 /// Last-write-wins resolution by `client_timestamp`.
110 ///
111 /// - DELETE vs non-DELETE: delete wins regardless of timestamp.
112 /// - Both INSERT/UPDATE: newer timestamp wins. Ties go to local.
113 pub fn resolve_lww(local: &ChangeEntry, remote: &PulledChange) -> Resolution {
114 // DELETE wins over non-DELETE
115 if local.op == ChangeOp::Delete || remote.entry.op == ChangeOp::Delete {
116 return if local.op == ChangeOp::Delete {
117 Resolution::KeepLocal
118 } else {
119 Resolution::KeepRemote
120 };
121 }
122
123 // Both are INSERT/UPDATE: newer timestamp wins, ties go to local
124 if local.timestamp >= remote.entry.timestamp {
125 Resolution::KeepLocal
126 } else {
127 Resolution::KeepRemote
128 }
129 }
130
131 /// 3-way field-level merge for JSON objects (top-level keys only).
132 ///
133 /// Compares `local` and `remote` against `base` to determine which fields
134 /// each side changed, then merges non-overlapping changes. For overlapping
135 /// fields, the newer timestamp wins.
136 ///
137 /// Returns `Resolution::KeepRemote` if any input is not a JSON object
138 /// (including `Value::Null` for a missing base). Callers should fall back
139 /// to [`resolve_lww`] when the base snapshot is unavailable.
140 pub fn resolve_field_merge(
141 local: &serde_json::Value,
142 remote: &serde_json::Value,
143 base: &serde_json::Value,
144 local_ts: DateTime<Utc>,
145 remote_ts: DateTime<Utc>,
146 ) -> Resolution {
147 let (Some(local_obj), Some(remote_obj), Some(base_obj)) = (
148 local.as_object(),
149 remote.as_object(),
150 base.as_object(),
151 ) else {
152 return Resolution::KeepRemote;
153 };
154
155 // Compute diffs: keys where local/remote differ from base
156 let mut local_changed: HashMap<&str, Option<&serde_json::Value>> = HashMap::new();
157 let mut remote_changed: HashMap<&str, Option<&serde_json::Value>> = HashMap::new();
158
159 // Check keys in base for changes or deletions
160 for key in base_obj.keys() {
161 let base_val = &base_obj[key];
162
163 match local_obj.get(key) {
164 Some(local_val) if local_val != base_val => {
165 local_changed.insert(key, Some(local_val));
166 }
167 None => {
168 // Key deleted on local side
169 local_changed.insert(key, None);
170 }
171 _ => {}
172 }
173
174 match remote_obj.get(key) {
175 Some(remote_val) if remote_val != base_val => {
176 remote_changed.insert(key, Some(remote_val));
177 }
178 None => {
179 // Key deleted on remote side
180 remote_changed.insert(key, None);
181 }
182 _ => {}
183 }
184 }
185
186 // Check for new keys added by local (not in base)
187 for (key, val) in local_obj {
188 if !base_obj.contains_key(key) {
189 local_changed.insert(key, Some(val));
190 }
191 }
192
193 // Check for new keys added by remote (not in base)
194 for (key, val) in remote_obj {
195 if !base_obj.contains_key(key) {
196 remote_changed.insert(key, Some(val));
197 }
198 }
199
200 // Build merged result starting from base
201 let mut result = base_obj.clone();
202
203 // Apply local changes
204 for (key, val) in &local_changed {
205 match val {
206 Some(v) => { result.insert((*key).to_string(), (*v).clone()); }
207 None => { result.remove(*key); }
208 }
209 }
210
211 // Apply remote changes (non-overlapping only, or newer timestamp wins)
212 for (key, val) in &remote_changed {
213 if local_changed.contains_key(key) {
214 // Overlapping: newer timestamp wins, ties go to local
215 if remote_ts > local_ts {
216 match val {
217 Some(v) => { result.insert((*key).to_string(), (*v).clone()); }
218 None => { result.remove(*key); }
219 }
220 }
221 // else: local already applied above
222 } else {
223 // Non-overlapping: apply remote
224 match val {
225 Some(v) => { result.insert((*key).to_string(), (*v).clone()); }
226 None => { result.remove(*key); }
227 }
228 }
229 }
230
231 Resolution::Merged(serde_json::Value::Object(result))
232 }
233
234 #[cfg(test)]
235 mod tests {
236 use super::*;
237 use serde_json::json;
238
239 fn make_entry(table: &str, row_id: &str, op: ChangeOp, ts: DateTime<Utc>) -> ChangeEntry {
240 ChangeEntry {
241 table: table.to_string(),
242 op,
243 row_id: row_id.to_string(),
244 timestamp: ts,
245 data: Some(json!({"value": "test"})),
246 }
247 }
248
249 fn make_pulled(
250 table: &str,
251 row_id: &str,
252 op: ChangeOp,
253 ts: DateTime<Utc>,
254 device_id: Uuid,
255 seq: i64,
256 ) -> PulledChange {
257 PulledChange {
258 entry: make_entry(table, row_id, op, ts),
259 device_id,
260 seq,
261 }
262 }
263
264 // ── detect_conflicts ──
265
266 #[test]
267 fn no_conflicts_when_different_rows() {
268 let our_device = Uuid::new_v4();
269 let other_device = Uuid::new_v4();
270 let now = Utc::now();
271
272 let remote = vec![
273 make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
274 ];
275 let local = vec![
276 make_entry("tasks", "r2", ChangeOp::Update, now),
277 ];
278
279 let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
280 assert_eq!(clean.len(), 1);
281 assert!(conflicts.is_empty());
282 }
283
284 #[test]
285 fn conflict_detected_same_row_different_device() {
286 let our_device = Uuid::new_v4();
287 let other_device = Uuid::new_v4();
288 let now = Utc::now();
289
290 let remote = vec![
291 make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
292 ];
293 let local = vec![
294 make_entry("tasks", "r1", ChangeOp::Update, now),
295 ];
296
297 let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
298 assert!(clean.is_empty());
299 assert_eq!(conflicts.len(), 1);
300 assert_eq!(conflicts[0].remote.entry.row_id, "r1");
301 assert_eq!(conflicts[0].local.row_id, "r1");
302 }
303
304 #[test]
305 fn own_echo_not_treated_as_conflict() {
306 let our_device = Uuid::new_v4();
307 let now = Utc::now();
308
309 let remote = vec![
310 make_pulled("tasks", "r1", ChangeOp::Update, now, our_device, 1),
311 ];
312 let local = vec![
313 make_entry("tasks", "r1", ChangeOp::Update, now),
314 ];
315
316 let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
317 assert_eq!(clean.len(), 1);
318 assert!(conflicts.is_empty());
319 }
320
321 #[test]
322 fn different_tables_same_row_id_no_conflict() {
323 let our_device = Uuid::new_v4();
324 let other_device = Uuid::new_v4();
325 let now = Utc::now();
326
327 let remote = vec![
328 make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
329 ];
330 let local = vec![
331 make_entry("events", "r1", ChangeOp::Update, now),
332 ];
333
334 let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
335 assert_eq!(clean.len(), 1);
336 assert!(conflicts.is_empty());
337 }
338
339 #[test]
340 fn detect_conflicts_correct_split() {
341 let our_device = Uuid::new_v4();
342 let other_device = Uuid::new_v4();
343 let now = Utc::now();
344
345 let remote = vec![
346 make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
347 make_pulled("tasks", "r2", ChangeOp::Insert, now, other_device, 2),
348 make_pulled("events", "r3", ChangeOp::Delete, now, other_device, 3),
349 ];
350 let local = vec![
351 make_entry("tasks", "r1", ChangeOp::Update, now),
352 // r2 not in local → clean
353 // r3 not in local → clean
354 ];
355
356 let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
357 assert_eq!(clean.len(), 2);
358 assert_eq!(conflicts.len(), 1);
359 assert_eq!(conflicts[0].remote.entry.row_id, "r1");
360 }
361
362 #[test]
363 fn empty_remote_produces_no_conflicts() {
364 let our_device = Uuid::new_v4();
365 let now = Utc::now();
366
367 let remote = vec![];
368 let local = vec![
369 make_entry("tasks", "r1", ChangeOp::Update, now),
370 ];
371
372 let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
373 assert!(clean.is_empty());
374 assert!(conflicts.is_empty());
375 }
376
377 #[test]
378 fn empty_local_produces_no_conflicts() {
379 let our_device = Uuid::new_v4();
380 let other_device = Uuid::new_v4();
381 let now = Utc::now();
382
383 let remote = vec![
384 make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
385 ];
386 let local: Vec<ChangeEntry> = vec![];
387
388 let (clean, conflicts) = detect_conflicts(remote, &local, our_device);
389 assert_eq!(clean.len(), 1);
390 assert!(conflicts.is_empty());
391 }
392
393 // ── resolve_lww ──
394
395 #[test]
396 fn lww_picks_newer_timestamp() {
397 let other_device = Uuid::new_v4();
398 let old = Utc::now() - chrono::Duration::seconds(60);
399 let new = Utc::now();
400
401 let local = make_entry("tasks", "r1", ChangeOp::Update, old);
402 let remote = make_pulled("tasks", "r1", ChangeOp::Update, new, other_device, 1);
403
404 assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote));
405 }
406
407 #[test]
408 fn lww_local_wins_when_newer() {
409 let other_device = Uuid::new_v4();
410 let old = Utc::now() - chrono::Duration::seconds(60);
411 let new = Utc::now();
412
413 let local = make_entry("tasks", "r1", ChangeOp::Update, new);
414 let remote = make_pulled("tasks", "r1", ChangeOp::Update, old, other_device, 1);
415
416 assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal));
417 }
418
419 #[test]
420 fn lww_equal_timestamps_local_wins() {
421 let other_device = Uuid::new_v4();
422 let now = Utc::now();
423
424 let local = make_entry("tasks", "r1", ChangeOp::Update, now);
425 let remote = make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1);
426
427 assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal));
428 }
429
430 #[test]
431 fn lww_local_delete_wins_over_update() {
432 let other_device = Uuid::new_v4();
433 let now = Utc::now();
434
435 let local = make_entry("tasks", "r1", ChangeOp::Delete, now);
436 let remote = make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1);
437
438 assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal));
439 }
440
441 #[test]
442 fn lww_remote_delete_wins_over_update() {
443 let other_device = Uuid::new_v4();
444 let now = Utc::now();
445
446 let local = make_entry("tasks", "r1", ChangeOp::Update, now);
447 let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1);
448
449 assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote));
450 }
451
452 #[test]
453 fn lww_both_delete_local_wins() {
454 let other_device = Uuid::new_v4();
455 let now = Utc::now();
456
457 let local = make_entry("tasks", "r1", ChangeOp::Delete, now);
458 let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1);
459
460 assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal));
461 }
462
463 // ── resolve_field_merge ──
464
465 #[test]
466 fn field_merge_non_overlapping_changes() {
467 let base = json!({"title": "old", "status": "pending", "priority": 1});
468 let local = json!({"title": "new title", "status": "pending", "priority": 1});
469 let remote = json!({"title": "old", "status": "done", "priority": 1});
470 let now = Utc::now();
471
472 let result = resolve_field_merge(&local, &remote, &base, now, now);
473 match result {
474 Resolution::Merged(v) => {
475 assert_eq!(v["title"], "new title");
476 assert_eq!(v["status"], "done");
477 assert_eq!(v["priority"], 1);
478 }
479 _ => panic!("Expected Merged"),
480 }
481 }
482
483 #[test]
484 fn field_merge_overlapping_newer_wins() {
485 let base = json!({"title": "old"});
486 let local = json!({"title": "local title"});
487 let remote = json!({"title": "remote title"});
488 let old = Utc::now() - chrono::Duration::seconds(60);
489 let new = Utc::now();
490
491 // Remote is newer → remote wins the overlapping field
492 let result = resolve_field_merge(&local, &remote, &base, old, new);
493 match result {
494 Resolution::Merged(v) => {
495 assert_eq!(v["title"], "remote title");
496 }
497 _ => panic!("Expected Merged"),
498 }
499
500 // Local is newer → local wins the overlapping field
501 let result = resolve_field_merge(&local, &remote, &base, new, old);
502 match result {
503 Resolution::Merged(v) => {
504 assert_eq!(v["title"], "local title");
505 }
506 _ => panic!("Expected Merged"),
507 }
508 }
509
510 #[test]
511 fn field_merge_key_deleted_on_one_side() {
512 let base = json!({"title": "old", "notes": "some notes"});
513 let local = json!({"title": "old"}); // notes deleted
514 let remote = json!({"title": "old", "notes": "some notes"});
515 let now = Utc::now();
516
517 let result = resolve_field_merge(&local, &remote, &base, now, now);
518 match result {
519 Resolution::Merged(v) => {
520 assert_eq!(v["title"], "old");
521 assert!(v.get("notes").is_none(), "notes should be deleted");
522 }
523 _ => panic!("Expected Merged"),
524 }
525 }
526
527 #[test]
528 fn field_merge_non_object_falls_back() {
529 let base = json!("string value");
530 let local = json!("local string");
531 let remote = json!("remote string");
532 let now = Utc::now();
533
534 assert!(matches!(
535 resolve_field_merge(&local, &remote, &base, now, now),
536 Resolution::KeepRemote
537 ));
538 }
539
540 #[test]
541 fn field_merge_empty_base_treats_all_as_changed() {
542 let base = json!({});
543 let local = json!({"title": "from local"});
544 let remote = json!({"status": "from remote"});
545 let now = Utc::now();
546
547 let result = resolve_field_merge(&local, &remote, &base, now, now);
548 match result {
549 Resolution::Merged(v) => {
550 assert_eq!(v["title"], "from local");
551 assert_eq!(v["status"], "from remote");
552 }
553 _ => panic!("Expected Merged"),
554 }
555 }
556
557 #[test]
558 fn field_merge_new_keys_from_both_sides() {
559 let base = json!({"existing": 1});
560 let local = json!({"existing": 1, "local_new": "a"});
561 let remote = json!({"existing": 1, "remote_new": "b"});
562 let now = Utc::now();
563
564 let result = resolve_field_merge(&local, &remote, &base, now, now);
565 match result {
566 Resolution::Merged(v) => {
567 assert_eq!(v["existing"], 1);
568 assert_eq!(v["local_new"], "a");
569 assert_eq!(v["remote_new"], "b");
570 }
571 _ => panic!("Expected Merged"),
572 }
573 }
574
575 // ── PulledChange preserves metadata ──
576
577 #[test]
578 fn pulled_change_preserves_device_id_and_seq() {
579 let device_id = Uuid::new_v4();
580 let now = Utc::now();
581 let pulled = make_pulled("tasks", "r1", ChangeOp::Insert, now, device_id, 42);
582
583 assert_eq!(pulled.device_id, device_id);
584 assert_eq!(pulled.seq, 42);
585 assert_eq!(pulled.entry.table, "tasks");
586 assert_eq!(pulled.entry.row_id, "r1");
587 }
588
589 #[test]
590 fn pulled_change_clone_works() {
591 let device_id = Uuid::new_v4();
592 let now = Utc::now();
593 let pulled = make_pulled("tasks", "r1", ChangeOp::Insert, now, device_id, 1);
594 let cloned = pulled.clone();
595
596 assert_eq!(cloned.device_id, pulled.device_id);
597 assert_eq!(cloned.seq, pulled.seq);
598 assert_eq!(cloned.entry.table, pulled.entry.table);
599 }
600
601 // ── Resolution variants ──
602
603 #[test]
604 fn resolution_debug_format() {
605 let keep_local = Resolution::KeepLocal;
606 let keep_remote = Resolution::KeepRemote;
607 let merged = Resolution::Merged(json!({"a": 1}));
608 let skip = Resolution::Skip;
609
610 assert!(format!("{:?}", keep_local).contains("KeepLocal"));
611 assert!(format!("{:?}", keep_remote).contains("KeepRemote"));
612 assert!(format!("{:?}", merged).contains("Merged"));
613 assert!(format!("{:?}", skip).contains("Skip"));
614 }
615
616 // ── ConflictResolver trait ──
617
618 #[test]
619 fn custom_resolver_works() {
620 struct AlwaysRemote;
621 impl ConflictResolver for AlwaysRemote {
622 fn resolve(
623 &self,
624 _local: &ChangeEntry,
625 _remote: &PulledChange,
626 _base: Option<&serde_json::Value>,
627 ) -> Resolution {
628 Resolution::KeepRemote
629 }
630 }
631
632 let resolver = AlwaysRemote;
633 let now = Utc::now();
634 let local = make_entry("tasks", "r1", ChangeOp::Update, now);
635 let remote = make_pulled("tasks", "r1", ChangeOp::Update, now, Uuid::new_v4(), 1);
636
637 assert!(matches!(
638 resolver.resolve(&local, &remote, None),
639 Resolution::KeepRemote
640 ));
641 }
642
643 // ── Fuzz: edge cases and attack vectors ──
644
645 // Attack vector 1: duplicate local entries for same (table, row_id).
646 // HashMap insert means last entry wins. Verify the conflict pair uses
647 // the LATER local entry, not the earlier one.
648 #[test]
649 fn detect_conflicts_duplicate_local_uses_last_entry() {
650 let our_device = Uuid::new_v4();
651 let other_device = Uuid::new_v4();
652 let t1 = Utc::now() - chrono::Duration::seconds(60);
653 let t2 = Utc::now();
654
655 let remote = vec![
656 make_pulled("tasks", "r1", ChangeOp::Update, t2, other_device, 1),
657 ];
658 // Two local entries for same (table, row_id): Insert then Update.
659 // The Update (last) should participate in conflict detection.
660 let local = vec![
661 make_entry("tasks", "r1", ChangeOp::Insert, t1),
662 make_entry("tasks", "r1", ChangeOp::Update, t2),
663 ];
664
665 let (_clean, conflicts) = detect_conflicts(remote, &local, our_device);
666 assert_eq!(conflicts.len(), 1);
667 // The conflict should use the Update (last entry), not the Insert
668 assert_eq!(conflicts[0].local.op, ChangeOp::Update);
669 assert_eq!(conflicts[0].local.timestamp, t2);
670 }
671
672 // Attack vector 2: DELETE vs DELETE in LWW.
673 // Both are Delete, so it hits `local.op == ChangeOp::Delete` and returns
674 // KeepLocal — even if remote Delete has a newer timestamp. This is the
675 // current behavior. Verify it explicitly.
676 #[test]
677 fn lww_both_delete_local_wins_even_when_remote_newer() {
678 let other_device = Uuid::new_v4();
679 let old = Utc::now() - chrono::Duration::seconds(60);
680 let new = Utc::now();
681
682 let local = make_entry("tasks", "r1", ChangeOp::Delete, old);
683 let remote = make_pulled("tasks", "r1", ChangeOp::Delete, new, other_device, 1);
684
685 // BUG CANDIDATE: remote Delete is newer but local wins because the
686 // code checks `local.op == Delete` first, returning KeepLocal always.
687 // For Delete-vs-Delete this is semantically fine (both delete the row),
688 // but the timestamp is ignored entirely.
689 assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal));
690 }
691
692 // Attack vector 3: field_merge overlapping fields with equal timestamps.
693 // Ties go to local (remote_ts > local_ts is false). Verify.
694 #[test]
695 fn field_merge_overlapping_equal_timestamps_local_wins() {
696 let base = json!({"title": "base"});
697 let local = json!({"title": "local version"});
698 let remote = json!({"title": "remote version"});
699 let now = Utc::now();
700
701 let result = resolve_field_merge(&local, &remote, &base, now, now);
702 match result {
703 Resolution::Merged(v) => {
704 assert_eq!(v["title"], "local version",
705 "Equal timestamps: local should win for overlapping fields");
706 }
707 _ => panic!("Expected Merged"),
708 }
709 }
710
711 // Attack vector 4: HashMap iteration order determinism.
712 // Non-overlapping keys from both sides should merge deterministically
713 // regardless of HashMap iteration order.
714 #[test]
715 fn field_merge_deterministic_with_many_keys() {
716 let base = json!({});
717 let local = json!({"a": 1, "c": 3, "e": 5, "g": 7, "i": 9});
718 let remote = json!({"b": 2, "d": 4, "f": 6, "h": 8, "j": 10});
719 let now = Utc::now();
720
721 // Run multiple times to catch iteration-order bugs
722 for _ in 0..10 {
723 let result = resolve_field_merge(&local, &remote, &base, now, now);
724 match result {
725 Resolution::Merged(v) => {
726 assert_eq!(v["a"], 1);
727 assert_eq!(v["b"], 2);
728 assert_eq!(v["c"], 3);
729 assert_eq!(v["d"], 4);
730 assert_eq!(v["e"], 5);
731 assert_eq!(v["f"], 6);
732 assert_eq!(v["g"], 7);
733 assert_eq!(v["h"], 8);
734 assert_eq!(v["i"], 9);
735 assert_eq!(v["j"], 10);
736 }
737 _ => panic!("Expected Merged"),
738 }
739 }
740 }
741
742 // Attack vector 5: null base with both local and remote as objects.
743 // Falls back to KeepRemote, silently discarding local changes.
744 #[test]
745 fn field_merge_null_base_discards_local() {
746 let base = json!(null);
747 let local = json!({"title": "important local edit"});
748 let remote = json!({"status": "remote only"});
749 let now = Utc::now();
750
751 // BUG: Both sides are valid objects but null base causes KeepRemote,
752 // which silently drops "title": "important local edit".
753 // A better fallback might be to merge both against an empty base,
754 // or fall back to LWW.
755 let result = resolve_field_merge(&local, &remote, &base, now, now);
756 assert!(matches!(result, Resolution::KeepRemote),
757 "Null base should fall back to KeepRemote (current behavior)");
758 }
759
760 // Attack vector 6: empty object vs changed value in field_merge.
761 // Both `{}` and a new value differ from the base value, so both are
762 // "changed". This is an overlapping-field conflict.
763 #[test]
764 fn field_merge_empty_object_counts_as_change() {
765 let base = json!({"meta": {"nested": "data"}});
766 let local = json!({"meta": {}}); // Changed to empty object
767 let remote = json!({"meta": "flat string"}); // Changed to string
768 let old = Utc::now() - chrono::Duration::seconds(60);
769 let new = Utc::now();
770
771 // Remote is newer, so remote wins the overlapping field
772 let result = resolve_field_merge(&local, &remote, &base, old, new);
773 match result {
774 Resolution::Merged(v) => {
775 assert_eq!(v["meta"], "flat string");
776 }
777 _ => panic!("Expected Merged"),
778 }
779
780 // Local is newer, so local wins — meta becomes empty object
781 let result = resolve_field_merge(&local, &remote, &base, new, old);
782 match result {
783 Resolution::Merged(v) => {
784 assert_eq!(v["meta"], json!({}));
785 }
786 _ => panic!("Expected Merged"),
787 }
788 }
789
790 // Attack vector 7: multiple remote changes for same (table, row_id).
791 // Each produces a separate ConflictPair with a CLONE of the same local entry.
792 #[test]
793 fn detect_conflicts_multiple_remote_same_row() {
794 let our_device = Uuid::new_v4();
795 let other_device = Uuid::new_v4();
796 let now = Utc::now();
797
798 let remote = vec![
799 make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
800 make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 2),
801 make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 3),
802 ];
803 let local = vec![
804 make_entry("tasks", "r1", ChangeOp::Update, now),
805 ];
806
807 let (_clean, conflicts) = detect_conflicts(remote, &local, our_device);
808 // All 3 remote changes conflict with the same local entry
809 assert_eq!(conflicts.len(), 3);
810 // Each gets a clone of the same local entry
811 assert_eq!(conflicts[0].local.row_id, "r1");
812 assert_eq!(conflicts[1].local.row_id, "r1");
813 assert_eq!(conflicts[2].local.row_id, "r1");
814 // Verify seq ordering is preserved
815 assert_eq!(conflicts[0].remote.seq, 1);
816 assert_eq!(conflicts[1].remote.seq, 2);
817 assert_eq!(conflicts[2].remote.seq, 3);
818 }
819
820 // Attack vector 8: numeric type coercion in serde_json PartialEq.
821 // JSON `1` (u64) and `1.0` (f64) are different Value variants.
822 // serde_json::Value PartialEq does NOT treat them as equal.
823 #[test]
824 fn field_merge_integer_vs_float_treated_as_different() {
825 let base = json!({"count": 1}); // serde_json: Number(PosInt(1))
826 let local = json!({"count": 1.0}); // serde_json: Number(Float(1.0))
827 let remote = json!({"count": 1}); // unchanged from base
828 let now = Utc::now();
829
830 let result = resolve_field_merge(&local, &remote, &base, now, now);
831 match result {
832 Resolution::Merged(v) => {
833 // BUG CANDIDATE: `1` != `1.0` in serde_json, so local sees
834 // "count" as changed (1 -> 1.0) even though semantically
835 // identical. Remote sees no change. Merge applies local's 1.0.
836 assert_eq!(v["count"], 1.0,
837 "serde_json treats 1 and 1.0 as different values");
838 }
839 _ => panic!("Expected Merged"),
840 }
841 }
842
843 // Bonus: INSERT vs DELETE in LWW. Insert is not Delete, so the Delete
844 // branch fires. If local is Insert and remote is Delete, remote wins.
845 // This means a remote delete can kill a row that was just created locally.
846 #[test]
847 fn lww_remote_delete_beats_local_insert() {
848 let other_device = Uuid::new_v4();
849 let now = Utc::now();
850
851 let local = make_entry("tasks", "r1", ChangeOp::Insert, now);
852 let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1);
853
854 // Remote Delete wins over local Insert — the local insert is discarded.
855 // This could be surprising: user creates a row, but a concurrent
856 // delete from another device kills it even though the insert is "newer".
857 assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote));
858 }
859
860 // Bonus: local delete wins over remote insert. Same logic in reverse.
861 #[test]
862 fn lww_local_delete_beats_remote_insert() {
863 let other_device = Uuid::new_v4();
864 let now = Utc::now();
865
866 let local = make_entry("tasks", "r1", ChangeOp::Delete, now);
867 let remote = make_pulled("tasks", "r1", ChangeOp::Insert, now, other_device, 1);
868
869 assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal));
870 }
871
872 // Bonus: field_merge where both sides delete the same key.
873 // Both detect the key as deleted. Local applies deletion first.
874 // Remote sees it as overlapping but equal-ts, so local's deletion stands.
875 #[test]
876 fn field_merge_both_delete_same_key() {
877 let base = json!({"title": "old", "notes": "old notes"});
878 let local = json!({"title": "old"}); // deleted "notes"
879 let remote = json!({"title": "old"}); // also deleted "notes"
880 let now = Utc::now();
881
882 let result = resolve_field_merge(&local, &remote, &base, now, now);
883 match result {
884 Resolution::Merged(v) => {
885 assert_eq!(v["title"], "old");
886 assert!(v.get("notes").is_none(),
887 "Both sides deleted notes, should stay deleted");
888 }
889 _ => panic!("Expected Merged"),
890 }
891 }
892
893 // Bonus: field_merge where local deletes a key and remote modifies it.
894 // Equal timestamps: local wins (deletion).
895 #[test]
896 fn field_merge_local_deletes_remote_modifies_equal_ts() {
897 let base = json!({"title": "old", "notes": "original"});
898 let local = json!({"title": "old"}); // deleted "notes"
899 let remote = json!({"title": "old", "notes": "updated notes"});
900 let now = Utc::now();
901
902 let result = resolve_field_merge(&local, &remote, &base, now, now);
903 match result {
904 Resolution::Merged(v) => {
905 // Local wins at equal timestamps: notes stays deleted
906 assert!(v.get("notes").is_none(),
907 "Equal ts: local delete should win over remote modify");
908 }
909 _ => panic!("Expected Merged"),
910 }
911 }
912
913 // Bonus: field_merge where both sides add the SAME new key with
914 // different values. Both are in local_changed and remote_changed.
915 #[test]
916 fn field_merge_both_add_same_new_key_different_values() {
917 let base = json!({"existing": 1});
918 let local = json!({"existing": 1, "new_key": "local value"});
919 let remote = json!({"existing": 1, "new_key": "remote value"});
920 let old = Utc::now() - chrono::Duration::seconds(60);
921 let new = Utc::now();
922
923 // Remote newer: remote wins the overlapping new key
924 let result = resolve_field_merge(&local, &remote, &base, old, new);
925 match result {
926 Resolution::Merged(v) => {
927 assert_eq!(v["new_key"], "remote value");
928 }
929 _ => panic!("Expected Merged"),
930 }
931
932 // Local newer: local wins the overlapping new key
933 let result = resolve_field_merge(&local, &remote, &base, new, old);
934 match result {
935 Resolution::Merged(v) => {
936 assert_eq!(v["new_key"], "local value");
937 }
938 _ => panic!("Expected Merged"),
939 }
940
941 // Equal: local wins
942 let result = resolve_field_merge(&local, &remote, &base, new, new);
943 match result {
944 Resolution::Merged(v) => {
945 assert_eq!(v["new_key"], "local value");
946 }
947 _ => panic!("Expected Merged"),
948 }
949 }
950 }
951