Skip to main content

stateset_sync/
conflict.rs

1use serde::{Deserialize, Serialize};
2
3use crate::event::SyncEvent;
4
5/// Strategy for resolving conflicts between local and remote events.
6///
7/// Maps to the JS `ResolutionStrategy` type.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[non_exhaustive]
10pub enum ConflictStrategy {
11    /// Accept the remote event, discard local.
12    RemoteWins,
13    /// Keep the local event, ignore remote.
14    LocalWins,
15    /// Compare timestamps and keep the most recent.
16    LastWriterWins,
17}
18
19impl Default for ConflictStrategy {
20    fn default() -> Self {
21        Self::RemoteWins
22    }
23}
24
25/// The outcome of resolving a conflict between two events.
26#[derive(Debug, Clone, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum Resolution {
29    /// Keep the local event.
30    KeepLocal,
31    /// Keep the remote event.
32    KeepRemote,
33    /// Merge both events into a new event.
34    Merge(SyncEvent),
35}
36
37/// Resolves conflicts between a local and a remote `SyncEvent`.
38///
39/// This is a pure, stateless resolver. The JS `ConflictResolver` is
40/// SQLite-backed and more complex; this Rust version provides the core
41/// resolution logic used by `SyncEngine`.
42///
43/// # Examples
44///
45/// ```
46/// use stateset_sync::{ConflictResolver, ConflictStrategy, SyncEvent, Resolution};
47/// use serde_json::json;
48///
49/// let resolver = ConflictResolver::new(ConflictStrategy::RemoteWins);
50/// let local = SyncEvent::new("order.updated", "order", "ORD-1", json!({"status": "shipped"}));
51/// let remote = SyncEvent::new("order.updated", "order", "ORD-1", json!({"status": "cancelled"}));
52///
53/// let resolution = resolver.resolve(&local, &remote);
54/// assert!(matches!(resolution, Resolution::KeepRemote));
55/// ```
56#[derive(Debug, Clone)]
57pub struct ConflictResolver {
58    strategy: ConflictStrategy,
59}
60
61impl ConflictResolver {
62    /// Create a new resolver with the given strategy.
63    #[must_use]
64    pub const fn new(strategy: ConflictStrategy) -> Self {
65        Self { strategy }
66    }
67
68    /// Return the configured strategy.
69    #[must_use]
70    pub const fn strategy(&self) -> ConflictStrategy {
71        self.strategy
72    }
73
74    /// Resolve a conflict between a local event and a remote event.
75    #[must_use]
76    pub fn resolve(&self, local: &SyncEvent, remote: &SyncEvent) -> Resolution {
77        match self.strategy {
78            ConflictStrategy::RemoteWins => Resolution::KeepRemote,
79            ConflictStrategy::LocalWins => Resolution::KeepLocal,
80            ConflictStrategy::LastWriterWins => {
81                if local.timestamp >= remote.timestamp {
82                    Resolution::KeepLocal
83                } else {
84                    Resolution::KeepRemote
85                }
86            }
87        }
88    }
89
90    /// Resolve a batch of conflicts, returning one resolution per pair.
91    ///
92    /// Each tuple is `(local, remote)`.
93    #[must_use]
94    pub fn resolve_batch(&self, pairs: &[(&SyncEvent, &SyncEvent)]) -> Vec<Resolution> {
95        pairs.iter().map(|(l, r)| self.resolve(l, r)).collect()
96    }
97}
98
99impl Default for ConflictResolver {
100    fn default() -> Self {
101        Self::new(ConflictStrategy::default())
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use chrono::{Duration, Utc};
109    use serde_json::json;
110    use uuid::Uuid;
111
112    fn make_event_at(name: &str, ts_offset_secs: i64) -> SyncEvent {
113        let ts = Utc::now() + Duration::seconds(ts_offset_secs);
114        SyncEvent::with_id(Uuid::new_v4(), 0, name, "order", "ORD-1", json!({"action": name}), ts)
115    }
116
117    #[test]
118    fn remote_wins_strategy() {
119        let resolver = ConflictResolver::new(ConflictStrategy::RemoteWins);
120        let local = make_event_at("local", 0);
121        let remote = make_event_at("remote", 0);
122        assert_eq!(resolver.resolve(&local, &remote), Resolution::KeepRemote);
123    }
124
125    #[test]
126    fn local_wins_strategy() {
127        let resolver = ConflictResolver::new(ConflictStrategy::LocalWins);
128        let local = make_event_at("local", 0);
129        let remote = make_event_at("remote", 0);
130        assert_eq!(resolver.resolve(&local, &remote), Resolution::KeepLocal);
131    }
132
133    #[test]
134    fn last_writer_wins_local_newer() {
135        let resolver = ConflictResolver::new(ConflictStrategy::LastWriterWins);
136        let local = make_event_at("local", 10); // 10 seconds in the future
137        let remote = make_event_at("remote", -10); // 10 seconds in the past
138        assert_eq!(resolver.resolve(&local, &remote), Resolution::KeepLocal);
139    }
140
141    #[test]
142    fn last_writer_wins_remote_newer() {
143        let resolver = ConflictResolver::new(ConflictStrategy::LastWriterWins);
144        let local = make_event_at("local", -10);
145        let remote = make_event_at("remote", 10);
146        assert_eq!(resolver.resolve(&local, &remote), Resolution::KeepRemote);
147    }
148
149    #[test]
150    fn last_writer_wins_equal_timestamps_keeps_local() {
151        let resolver = ConflictResolver::new(ConflictStrategy::LastWriterWins);
152        let ts = Utc::now();
153        let local = SyncEvent::with_id(Uuid::new_v4(), 1, "local", "o", "1", json!({}), ts);
154        let remote = SyncEvent::with_id(Uuid::new_v4(), 2, "remote", "o", "1", json!({}), ts);
155        // local.timestamp >= remote.timestamp, so KeepLocal
156        assert_eq!(resolver.resolve(&local, &remote), Resolution::KeepLocal);
157    }
158
159    #[test]
160    fn default_strategy_is_remote_wins() {
161        let resolver = ConflictResolver::default();
162        assert_eq!(resolver.strategy(), ConflictStrategy::RemoteWins);
163    }
164
165    #[test]
166    fn resolve_batch() {
167        let resolver = ConflictResolver::new(ConflictStrategy::RemoteWins);
168        let l1 = make_event_at("l1", 0);
169        let r1 = make_event_at("r1", 0);
170        let l2 = make_event_at("l2", 0);
171        let r2 = make_event_at("r2", 0);
172
173        let pairs = vec![(&l1, &r1), (&l2, &r2)];
174        let resolutions = resolver.resolve_batch(&pairs);
175        assert_eq!(resolutions.len(), 2);
176        assert!(resolutions.iter().all(|r| *r == Resolution::KeepRemote));
177    }
178
179    #[test]
180    fn conflict_strategy_serde_roundtrip() {
181        let strategy = ConflictStrategy::LastWriterWins;
182        let json = serde_json::to_string(&strategy).unwrap();
183        let deserialized: ConflictStrategy = serde_json::from_str(&json).unwrap();
184        assert_eq!(deserialized, strategy);
185    }
186
187    #[test]
188    fn conflict_strategy_debug() {
189        let strategy = ConflictStrategy::LocalWins;
190        let debug = format!("{strategy:?}");
191        assert!(debug.contains("LocalWins"));
192    }
193
194    #[test]
195    fn resolver_clone() {
196        let resolver = ConflictResolver::new(ConflictStrategy::LocalWins);
197        let cloned = resolver;
198        assert_eq!(cloned.strategy(), ConflictStrategy::LocalWins);
199    }
200
201    #[test]
202    fn resolution_debug() {
203        let resolution = Resolution::KeepLocal;
204        let debug = format!("{resolution:?}");
205        assert!(debug.contains("KeepLocal"));
206    }
207
208    #[test]
209    fn resolution_clone_eq() {
210        let r1 = Resolution::KeepRemote;
211        let r2 = r1.clone();
212        assert_eq!(r1, r2);
213    }
214
215    #[test]
216    fn resolution_merge_variant() {
217        let event = make_event_at("merged", 0);
218        let resolution = Resolution::Merge(event);
219        if let Resolution::Merge(merged) = resolution {
220            assert_eq!(merged.event_type, "merged");
221        } else {
222            panic!("Expected Merge variant");
223        }
224    }
225}