//! Client-side conflict detection and resolution for SyncKit. //! //! SyncKit uses E2E encryption — the server never sees row contents and cannot //! merge or compare data. All conflict resolution must happen client-side after //! decryption. //! //! This module provides: //! - [`detect_conflicts`]: pure function that splits pulled changes into clean //! (non-conflicting) and conflicting sets //! - [`resolve_lww`]: last-write-wins resolution by `client_timestamp` //! - [`resolve_field_merge`]: 3-way JSON object merge using a base version //! - [`ConflictResolver`]: trait for custom resolution strategies use std::collections::HashMap; use chrono::{DateTime, Utc}; use uuid::Uuid; use crate::types::{ChangeEntry, ChangeOp, PulledChange}; /// A remote change that conflicts with a local pending change. #[derive(Debug, Clone)] pub struct ConflictPair { /// The remote change from pull. pub remote: PulledChange, /// The local pending change that conflicts. pub local: ChangeEntry, } /// How a conflict should be resolved. #[derive(Debug, Clone)] pub enum Resolution { /// Keep the local version; skip the remote change. /// The local version will push on the next sync cycle. KeepLocal, /// Apply the remote change, discarding the local version. KeepRemote, /// Apply a merged result that combines both changes. Merged(serde_json::Value), /// Skip both changes (neither apply nor push). Skip, } /// Trait for custom conflict resolution strategies. /// /// Implement this to plug in app-specific merge logic. The `base` parameter /// is `Some` only if the app provides a base-store adapter. pub trait ConflictResolver: Send + Sync { fn resolve( &self, local: &ChangeEntry, remote: &PulledChange, base: Option<&serde_json::Value>, ) -> Resolution; } /// Split pulled changes into non-conflicting and conflicting sets. /// /// A conflict exists when a remote change and a local pending change both /// modify the same `(table, row_id)` from different devices. Changes from /// our own device (echoes) are never treated as conflicts. /// /// **Precondition:** `local_pending` should contain at most one entry per /// `(table, row_id)`. If duplicates exist, only the last one participates /// in conflict detection (earlier entries are silently ignored). Callers /// should compact local pending changes before calling this function. /// /// If `remote` contains multiple changes for the same `(table, row_id)`, /// each produces a separate `ConflictPair` with a clone of the same local /// entry. The caller must resolve them in order (by `seq`). /// /// Returns `(clean, conflicts)` where `clean` changes can be applied directly. pub fn detect_conflicts( remote: Vec, local_pending: &[ChangeEntry], our_device_id: Uuid, ) -> (Vec, Vec) { // Build lookup: (table, row_id) -> last local pending entry. // If local_pending has duplicates for the same key, the last entry wins. let mut local_map: HashMap<(&str, &str), &ChangeEntry> = HashMap::new(); for entry in local_pending { local_map.insert((&entry.table, &entry.row_id), entry); } let mut clean = Vec::new(); let mut conflicts = Vec::new(); for pulled in remote { // Our own echo — never a conflict if pulled.device_id == our_device_id { clean.push(pulled); continue; } let key = (pulled.entry.table.as_str(), pulled.entry.row_id.as_str()); if let Some(&local_entry) = local_map.get(&key) { conflicts.push(ConflictPair { remote: pulled, local: local_entry.clone(), }); } else { clean.push(pulled); } } (clean, conflicts) } /// Last-write-wins resolution by `client_timestamp`. /// /// - DELETE vs non-DELETE: delete wins regardless of timestamp. /// - Both INSERT/UPDATE: newer timestamp wins. Ties go to local. pub fn resolve_lww(local: &ChangeEntry, remote: &PulledChange) -> Resolution { // DELETE wins over non-DELETE if local.op == ChangeOp::Delete || remote.entry.op == ChangeOp::Delete { return if local.op == ChangeOp::Delete { Resolution::KeepLocal } else { Resolution::KeepRemote }; } // Both are INSERT/UPDATE: newer timestamp wins, ties go to local if local.timestamp >= remote.entry.timestamp { Resolution::KeepLocal } else { Resolution::KeepRemote } } /// 3-way field-level merge for JSON objects (top-level keys only). /// /// Compares `local` and `remote` against `base` to determine which fields /// each side changed, then merges non-overlapping changes. For overlapping /// fields, the newer timestamp wins. /// /// Returns `Resolution::KeepRemote` if any input is not a JSON object /// (including `Value::Null` for a missing base). Callers should fall back /// to [`resolve_lww`] when the base snapshot is unavailable. pub fn resolve_field_merge( local: &serde_json::Value, remote: &serde_json::Value, base: &serde_json::Value, local_ts: DateTime, remote_ts: DateTime, ) -> Resolution { let (Some(local_obj), Some(remote_obj), Some(base_obj)) = ( local.as_object(), remote.as_object(), base.as_object(), ) else { return Resolution::KeepRemote; }; // Compute diffs: keys where local/remote differ from base let mut local_changed: HashMap<&str, Option<&serde_json::Value>> = HashMap::new(); let mut remote_changed: HashMap<&str, Option<&serde_json::Value>> = HashMap::new(); // Check keys in base for changes or deletions for key in base_obj.keys() { let base_val = &base_obj[key]; match local_obj.get(key) { Some(local_val) if local_val != base_val => { local_changed.insert(key, Some(local_val)); } None => { // Key deleted on local side local_changed.insert(key, None); } _ => {} } match remote_obj.get(key) { Some(remote_val) if remote_val != base_val => { remote_changed.insert(key, Some(remote_val)); } None => { // Key deleted on remote side remote_changed.insert(key, None); } _ => {} } } // Check for new keys added by local (not in base) for (key, val) in local_obj { if !base_obj.contains_key(key) { local_changed.insert(key, Some(val)); } } // Check for new keys added by remote (not in base) for (key, val) in remote_obj { if !base_obj.contains_key(key) { remote_changed.insert(key, Some(val)); } } // Build merged result starting from base let mut result = base_obj.clone(); // Apply local changes for (key, val) in &local_changed { match val { Some(v) => { result.insert((*key).to_string(), (*v).clone()); } None => { result.remove(*key); } } } // Apply remote changes (non-overlapping only, or newer timestamp wins) for (key, val) in &remote_changed { if local_changed.contains_key(key) { // Overlapping: newer timestamp wins, ties go to local if remote_ts > local_ts { match val { Some(v) => { result.insert((*key).to_string(), (*v).clone()); } None => { result.remove(*key); } } } // else: local already applied above } else { // Non-overlapping: apply remote match val { Some(v) => { result.insert((*key).to_string(), (*v).clone()); } None => { result.remove(*key); } } } } Resolution::Merged(serde_json::Value::Object(result)) } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn make_entry(table: &str, row_id: &str, op: ChangeOp, ts: DateTime) -> ChangeEntry { ChangeEntry { table: table.to_string(), op, row_id: row_id.to_string(), timestamp: ts, data: Some(json!({"value": "test"})), } } fn make_pulled( table: &str, row_id: &str, op: ChangeOp, ts: DateTime, device_id: Uuid, seq: i64, ) -> PulledChange { PulledChange { entry: make_entry(table, row_id, op, ts), device_id, seq, } } // ── detect_conflicts ── #[test] fn no_conflicts_when_different_rows() { let our_device = Uuid::new_v4(); let other_device = Uuid::new_v4(); let now = Utc::now(); let remote = vec![ make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1), ]; let local = vec![ make_entry("tasks", "r2", ChangeOp::Update, now), ]; let (clean, conflicts) = detect_conflicts(remote, &local, our_device); assert_eq!(clean.len(), 1); assert!(conflicts.is_empty()); } #[test] fn conflict_detected_same_row_different_device() { let our_device = Uuid::new_v4(); let other_device = Uuid::new_v4(); let now = Utc::now(); let remote = vec![ make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1), ]; let local = vec![ make_entry("tasks", "r1", ChangeOp::Update, now), ]; let (clean, conflicts) = detect_conflicts(remote, &local, our_device); assert!(clean.is_empty()); assert_eq!(conflicts.len(), 1); assert_eq!(conflicts[0].remote.entry.row_id, "r1"); assert_eq!(conflicts[0].local.row_id, "r1"); } #[test] fn own_echo_not_treated_as_conflict() { let our_device = Uuid::new_v4(); let now = Utc::now(); let remote = vec![ make_pulled("tasks", "r1", ChangeOp::Update, now, our_device, 1), ]; let local = vec![ make_entry("tasks", "r1", ChangeOp::Update, now), ]; let (clean, conflicts) = detect_conflicts(remote, &local, our_device); assert_eq!(clean.len(), 1); assert!(conflicts.is_empty()); } #[test] fn different_tables_same_row_id_no_conflict() { let our_device = Uuid::new_v4(); let other_device = Uuid::new_v4(); let now = Utc::now(); let remote = vec![ make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1), ]; let local = vec![ make_entry("events", "r1", ChangeOp::Update, now), ]; let (clean, conflicts) = detect_conflicts(remote, &local, our_device); assert_eq!(clean.len(), 1); assert!(conflicts.is_empty()); } #[test] fn detect_conflicts_correct_split() { let our_device = Uuid::new_v4(); let other_device = Uuid::new_v4(); let now = Utc::now(); let remote = vec![ make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1), make_pulled("tasks", "r2", ChangeOp::Insert, now, other_device, 2), make_pulled("events", "r3", ChangeOp::Delete, now, other_device, 3), ]; let local = vec![ make_entry("tasks", "r1", ChangeOp::Update, now), // r2 not in local → clean // r3 not in local → clean ]; let (clean, conflicts) = detect_conflicts(remote, &local, our_device); assert_eq!(clean.len(), 2); assert_eq!(conflicts.len(), 1); assert_eq!(conflicts[0].remote.entry.row_id, "r1"); } #[test] fn empty_remote_produces_no_conflicts() { let our_device = Uuid::new_v4(); let now = Utc::now(); let remote = vec![]; let local = vec![ make_entry("tasks", "r1", ChangeOp::Update, now), ]; let (clean, conflicts) = detect_conflicts(remote, &local, our_device); assert!(clean.is_empty()); assert!(conflicts.is_empty()); } #[test] fn empty_local_produces_no_conflicts() { let our_device = Uuid::new_v4(); let other_device = Uuid::new_v4(); let now = Utc::now(); let remote = vec![ make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1), ]; let local: Vec = vec![]; let (clean, conflicts) = detect_conflicts(remote, &local, our_device); assert_eq!(clean.len(), 1); assert!(conflicts.is_empty()); } // ── resolve_lww ── #[test] fn lww_picks_newer_timestamp() { let other_device = Uuid::new_v4(); let old = Utc::now() - chrono::Duration::seconds(60); let new = Utc::now(); let local = make_entry("tasks", "r1", ChangeOp::Update, old); let remote = make_pulled("tasks", "r1", ChangeOp::Update, new, other_device, 1); assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote)); } #[test] fn lww_local_wins_when_newer() { let other_device = Uuid::new_v4(); let old = Utc::now() - chrono::Duration::seconds(60); let new = Utc::now(); let local = make_entry("tasks", "r1", ChangeOp::Update, new); let remote = make_pulled("tasks", "r1", ChangeOp::Update, old, other_device, 1); assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); } #[test] fn lww_equal_timestamps_local_wins() { let other_device = Uuid::new_v4(); let now = Utc::now(); let local = make_entry("tasks", "r1", ChangeOp::Update, now); let remote = make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1); assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); } #[test] fn lww_local_delete_wins_over_update() { let other_device = Uuid::new_v4(); let now = Utc::now(); let local = make_entry("tasks", "r1", ChangeOp::Delete, now); let remote = make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1); assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); } #[test] fn lww_remote_delete_wins_over_update() { let other_device = Uuid::new_v4(); let now = Utc::now(); let local = make_entry("tasks", "r1", ChangeOp::Update, now); let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1); assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote)); } #[test] fn lww_both_delete_local_wins() { let other_device = Uuid::new_v4(); let now = Utc::now(); let local = make_entry("tasks", "r1", ChangeOp::Delete, now); let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1); assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); } // ── resolve_field_merge ── #[test] fn field_merge_non_overlapping_changes() { let base = json!({"title": "old", "status": "pending", "priority": 1}); let local = json!({"title": "new title", "status": "pending", "priority": 1}); let remote = json!({"title": "old", "status": "done", "priority": 1}); let now = Utc::now(); let result = resolve_field_merge(&local, &remote, &base, now, now); match result { Resolution::Merged(v) => { assert_eq!(v["title"], "new title"); assert_eq!(v["status"], "done"); assert_eq!(v["priority"], 1); } _ => panic!("Expected Merged"), } } #[test] fn field_merge_overlapping_newer_wins() { let base = json!({"title": "old"}); let local = json!({"title": "local title"}); let remote = json!({"title": "remote title"}); let old = Utc::now() - chrono::Duration::seconds(60); let new = Utc::now(); // Remote is newer → remote wins the overlapping field let result = resolve_field_merge(&local, &remote, &base, old, new); match result { Resolution::Merged(v) => { assert_eq!(v["title"], "remote title"); } _ => panic!("Expected Merged"), } // Local is newer → local wins the overlapping field let result = resolve_field_merge(&local, &remote, &base, new, old); match result { Resolution::Merged(v) => { assert_eq!(v["title"], "local title"); } _ => panic!("Expected Merged"), } } #[test] fn field_merge_key_deleted_on_one_side() { let base = json!({"title": "old", "notes": "some notes"}); let local = json!({"title": "old"}); // notes deleted let remote = json!({"title": "old", "notes": "some notes"}); let now = Utc::now(); let result = resolve_field_merge(&local, &remote, &base, now, now); match result { Resolution::Merged(v) => { assert_eq!(v["title"], "old"); assert!(v.get("notes").is_none(), "notes should be deleted"); } _ => panic!("Expected Merged"), } } #[test] fn field_merge_non_object_falls_back() { let base = json!("string value"); let local = json!("local string"); let remote = json!("remote string"); let now = Utc::now(); assert!(matches!( resolve_field_merge(&local, &remote, &base, now, now), Resolution::KeepRemote )); } #[test] fn field_merge_empty_base_treats_all_as_changed() { let base = json!({}); let local = json!({"title": "from local"}); let remote = json!({"status": "from remote"}); let now = Utc::now(); let result = resolve_field_merge(&local, &remote, &base, now, now); match result { Resolution::Merged(v) => { assert_eq!(v["title"], "from local"); assert_eq!(v["status"], "from remote"); } _ => panic!("Expected Merged"), } } #[test] fn field_merge_new_keys_from_both_sides() { let base = json!({"existing": 1}); let local = json!({"existing": 1, "local_new": "a"}); let remote = json!({"existing": 1, "remote_new": "b"}); let now = Utc::now(); let result = resolve_field_merge(&local, &remote, &base, now, now); match result { Resolution::Merged(v) => { assert_eq!(v["existing"], 1); assert_eq!(v["local_new"], "a"); assert_eq!(v["remote_new"], "b"); } _ => panic!("Expected Merged"), } } // ── PulledChange preserves metadata ── #[test] fn pulled_change_preserves_device_id_and_seq() { let device_id = Uuid::new_v4(); let now = Utc::now(); let pulled = make_pulled("tasks", "r1", ChangeOp::Insert, now, device_id, 42); assert_eq!(pulled.device_id, device_id); assert_eq!(pulled.seq, 42); assert_eq!(pulled.entry.table, "tasks"); assert_eq!(pulled.entry.row_id, "r1"); } #[test] fn pulled_change_clone_works() { let device_id = Uuid::new_v4(); let now = Utc::now(); let pulled = make_pulled("tasks", "r1", ChangeOp::Insert, now, device_id, 1); let cloned = pulled.clone(); assert_eq!(cloned.device_id, pulled.device_id); assert_eq!(cloned.seq, pulled.seq); assert_eq!(cloned.entry.table, pulled.entry.table); } // ── Resolution variants ── #[test] fn resolution_debug_format() { let keep_local = Resolution::KeepLocal; let keep_remote = Resolution::KeepRemote; let merged = Resolution::Merged(json!({"a": 1})); let skip = Resolution::Skip; assert!(format!("{:?}", keep_local).contains("KeepLocal")); assert!(format!("{:?}", keep_remote).contains("KeepRemote")); assert!(format!("{:?}", merged).contains("Merged")); assert!(format!("{:?}", skip).contains("Skip")); } // ── ConflictResolver trait ── #[test] fn custom_resolver_works() { struct AlwaysRemote; impl ConflictResolver for AlwaysRemote { fn resolve( &self, _local: &ChangeEntry, _remote: &PulledChange, _base: Option<&serde_json::Value>, ) -> Resolution { Resolution::KeepRemote } } let resolver = AlwaysRemote; let now = Utc::now(); let local = make_entry("tasks", "r1", ChangeOp::Update, now); let remote = make_pulled("tasks", "r1", ChangeOp::Update, now, Uuid::new_v4(), 1); assert!(matches!( resolver.resolve(&local, &remote, None), Resolution::KeepRemote )); } // ── Fuzz: edge cases and attack vectors ── // Attack vector 1: duplicate local entries for same (table, row_id). // HashMap insert means last entry wins. Verify the conflict pair uses // the LATER local entry, not the earlier one. #[test] fn detect_conflicts_duplicate_local_uses_last_entry() { let our_device = Uuid::new_v4(); let other_device = Uuid::new_v4(); let t1 = Utc::now() - chrono::Duration::seconds(60); let t2 = Utc::now(); let remote = vec![ make_pulled("tasks", "r1", ChangeOp::Update, t2, other_device, 1), ]; // Two local entries for same (table, row_id): Insert then Update. // The Update (last) should participate in conflict detection. let local = vec![ make_entry("tasks", "r1", ChangeOp::Insert, t1), make_entry("tasks", "r1", ChangeOp::Update, t2), ]; let (_clean, conflicts) = detect_conflicts(remote, &local, our_device); assert_eq!(conflicts.len(), 1); // The conflict should use the Update (last entry), not the Insert assert_eq!(conflicts[0].local.op, ChangeOp::Update); assert_eq!(conflicts[0].local.timestamp, t2); } // Attack vector 2: DELETE vs DELETE in LWW. // Both are Delete, so it hits `local.op == ChangeOp::Delete` and returns // KeepLocal — even if remote Delete has a newer timestamp. This is the // current behavior. Verify it explicitly. #[test] fn lww_both_delete_local_wins_even_when_remote_newer() { let other_device = Uuid::new_v4(); let old = Utc::now() - chrono::Duration::seconds(60); let new = Utc::now(); let local = make_entry("tasks", "r1", ChangeOp::Delete, old); let remote = make_pulled("tasks", "r1", ChangeOp::Delete, new, other_device, 1); // BUG CANDIDATE: remote Delete is newer but local wins because the // code checks `local.op == Delete` first, returning KeepLocal always. // For Delete-vs-Delete this is semantically fine (both delete the row), // but the timestamp is ignored entirely. assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); } // Attack vector 3: field_merge overlapping fields with equal timestamps. // Ties go to local (remote_ts > local_ts is false). Verify. #[test] fn field_merge_overlapping_equal_timestamps_local_wins() { let base = json!({"title": "base"}); let local = json!({"title": "local version"}); let remote = json!({"title": "remote version"}); let now = Utc::now(); let result = resolve_field_merge(&local, &remote, &base, now, now); match result { Resolution::Merged(v) => { assert_eq!(v["title"], "local version", "Equal timestamps: local should win for overlapping fields"); } _ => panic!("Expected Merged"), } } // Attack vector 4: HashMap iteration order determinism. // Non-overlapping keys from both sides should merge deterministically // regardless of HashMap iteration order. #[test] fn field_merge_deterministic_with_many_keys() { let base = json!({}); let local = json!({"a": 1, "c": 3, "e": 5, "g": 7, "i": 9}); let remote = json!({"b": 2, "d": 4, "f": 6, "h": 8, "j": 10}); let now = Utc::now(); // Run multiple times to catch iteration-order bugs for _ in 0..10 { let result = resolve_field_merge(&local, &remote, &base, now, now); match result { Resolution::Merged(v) => { assert_eq!(v["a"], 1); assert_eq!(v["b"], 2); assert_eq!(v["c"], 3); assert_eq!(v["d"], 4); assert_eq!(v["e"], 5); assert_eq!(v["f"], 6); assert_eq!(v["g"], 7); assert_eq!(v["h"], 8); assert_eq!(v["i"], 9); assert_eq!(v["j"], 10); } _ => panic!("Expected Merged"), } } } // Attack vector 5: null base with both local and remote as objects. // Falls back to KeepRemote, silently discarding local changes. #[test] fn field_merge_null_base_discards_local() { let base = json!(null); let local = json!({"title": "important local edit"}); let remote = json!({"status": "remote only"}); let now = Utc::now(); // BUG: Both sides are valid objects but null base causes KeepRemote, // which silently drops "title": "important local edit". // A better fallback might be to merge both against an empty base, // or fall back to LWW. let result = resolve_field_merge(&local, &remote, &base, now, now); assert!(matches!(result, Resolution::KeepRemote), "Null base should fall back to KeepRemote (current behavior)"); } // Attack vector 6: empty object vs changed value in field_merge. // Both `{}` and a new value differ from the base value, so both are // "changed". This is an overlapping-field conflict. #[test] fn field_merge_empty_object_counts_as_change() { let base = json!({"meta": {"nested": "data"}}); let local = json!({"meta": {}}); // Changed to empty object let remote = json!({"meta": "flat string"}); // Changed to string let old = Utc::now() - chrono::Duration::seconds(60); let new = Utc::now(); // Remote is newer, so remote wins the overlapping field let result = resolve_field_merge(&local, &remote, &base, old, new); match result { Resolution::Merged(v) => { assert_eq!(v["meta"], "flat string"); } _ => panic!("Expected Merged"), } // Local is newer, so local wins — meta becomes empty object let result = resolve_field_merge(&local, &remote, &base, new, old); match result { Resolution::Merged(v) => { assert_eq!(v["meta"], json!({})); } _ => panic!("Expected Merged"), } } // Attack vector 7: multiple remote changes for same (table, row_id). // Each produces a separate ConflictPair with a CLONE of the same local entry. #[test] fn detect_conflicts_multiple_remote_same_row() { let our_device = Uuid::new_v4(); let other_device = Uuid::new_v4(); let now = Utc::now(); let remote = vec![ make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1), make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 2), make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 3), ]; let local = vec![ make_entry("tasks", "r1", ChangeOp::Update, now), ]; let (_clean, conflicts) = detect_conflicts(remote, &local, our_device); // All 3 remote changes conflict with the same local entry assert_eq!(conflicts.len(), 3); // Each gets a clone of the same local entry assert_eq!(conflicts[0].local.row_id, "r1"); assert_eq!(conflicts[1].local.row_id, "r1"); assert_eq!(conflicts[2].local.row_id, "r1"); // Verify seq ordering is preserved assert_eq!(conflicts[0].remote.seq, 1); assert_eq!(conflicts[1].remote.seq, 2); assert_eq!(conflicts[2].remote.seq, 3); } // Attack vector 8: numeric type coercion in serde_json PartialEq. // JSON `1` (u64) and `1.0` (f64) are different Value variants. // serde_json::Value PartialEq does NOT treat them as equal. #[test] fn field_merge_integer_vs_float_treated_as_different() { let base = json!({"count": 1}); // serde_json: Number(PosInt(1)) let local = json!({"count": 1.0}); // serde_json: Number(Float(1.0)) let remote = json!({"count": 1}); // unchanged from base let now = Utc::now(); let result = resolve_field_merge(&local, &remote, &base, now, now); match result { Resolution::Merged(v) => { // BUG CANDIDATE: `1` != `1.0` in serde_json, so local sees // "count" as changed (1 -> 1.0) even though semantically // identical. Remote sees no change. Merge applies local's 1.0. assert_eq!(v["count"], 1.0, "serde_json treats 1 and 1.0 as different values"); } _ => panic!("Expected Merged"), } } // Bonus: INSERT vs DELETE in LWW. Insert is not Delete, so the Delete // branch fires. If local is Insert and remote is Delete, remote wins. // This means a remote delete can kill a row that was just created locally. #[test] fn lww_remote_delete_beats_local_insert() { let other_device = Uuid::new_v4(); let now = Utc::now(); let local = make_entry("tasks", "r1", ChangeOp::Insert, now); let remote = make_pulled("tasks", "r1", ChangeOp::Delete, now, other_device, 1); // Remote Delete wins over local Insert — the local insert is discarded. // This could be surprising: user creates a row, but a concurrent // delete from another device kills it even though the insert is "newer". assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepRemote)); } // Bonus: local delete wins over remote insert. Same logic in reverse. #[test] fn lww_local_delete_beats_remote_insert() { let other_device = Uuid::new_v4(); let now = Utc::now(); let local = make_entry("tasks", "r1", ChangeOp::Delete, now); let remote = make_pulled("tasks", "r1", ChangeOp::Insert, now, other_device, 1); assert!(matches!(resolve_lww(&local, &remote), Resolution::KeepLocal)); } // Bonus: field_merge where both sides delete the same key. // Both detect the key as deleted. Local applies deletion first. // Remote sees it as overlapping but equal-ts, so local's deletion stands. #[test] fn field_merge_both_delete_same_key() { let base = json!({"title": "old", "notes": "old notes"}); let local = json!({"title": "old"}); // deleted "notes" let remote = json!({"title": "old"}); // also deleted "notes" let now = Utc::now(); let result = resolve_field_merge(&local, &remote, &base, now, now); match result { Resolution::Merged(v) => { assert_eq!(v["title"], "old"); assert!(v.get("notes").is_none(), "Both sides deleted notes, should stay deleted"); } _ => panic!("Expected Merged"), } } // Bonus: field_merge where local deletes a key and remote modifies it. // Equal timestamps: local wins (deletion). #[test] fn field_merge_local_deletes_remote_modifies_equal_ts() { let base = json!({"title": "old", "notes": "original"}); let local = json!({"title": "old"}); // deleted "notes" let remote = json!({"title": "old", "notes": "updated notes"}); let now = Utc::now(); let result = resolve_field_merge(&local, &remote, &base, now, now); match result { Resolution::Merged(v) => { // Local wins at equal timestamps: notes stays deleted assert!(v.get("notes").is_none(), "Equal ts: local delete should win over remote modify"); } _ => panic!("Expected Merged"), } } // Bonus: field_merge where both sides add the SAME new key with // different values. Both are in local_changed and remote_changed. #[test] fn field_merge_both_add_same_new_key_different_values() { let base = json!({"existing": 1}); let local = json!({"existing": 1, "new_key": "local value"}); let remote = json!({"existing": 1, "new_key": "remote value"}); let old = Utc::now() - chrono::Duration::seconds(60); let new = Utc::now(); // Remote newer: remote wins the overlapping new key let result = resolve_field_merge(&local, &remote, &base, old, new); match result { Resolution::Merged(v) => { assert_eq!(v["new_key"], "remote value"); } _ => panic!("Expected Merged"), } // Local newer: local wins the overlapping new key let result = resolve_field_merge(&local, &remote, &base, new, old); match result { Resolution::Merged(v) => { assert_eq!(v["new_key"], "local value"); } _ => panic!("Expected Merged"), } // Equal: local wins let result = resolve_field_merge(&local, &remote, &base, new, new); match result { Resolution::Merged(v) => { assert_eq!(v["new_key"], "local value"); } _ => panic!("Expected Merged"), } } }