Skip to main content

synckit_core/sync/
delta.rs

1//! Delta computation for efficient synchronization
2//!
3//! Computes minimal changes between document states to reduce bandwidth usage.
4//! Only transmits fields that actually changed rather than full documents.
5
6use crate::document::{Document, Field};
7use crate::sync::VectorClock;
8use crate::{DocumentID, FieldPath};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Represents changes between two document states
13///
14/// Contains only the fields that changed, making network transmission efficient.
15/// A delta can be applied to a document to update it to a new state.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct Delta {
18    /// Document this delta applies to
19    pub document_id: DocumentID,
20
21    /// Changed fields (only includes fields that differ)
22    pub fields: HashMap<FieldPath, Field>,
23
24    /// Vector clock after applying this delta
25    pub version: VectorClock,
26}
27
28impl Delta {
29    /// Create a new delta
30    pub fn new(
31        document_id: DocumentID,
32        fields: HashMap<FieldPath, Field>,
33        version: VectorClock,
34    ) -> Self {
35        Self {
36            document_id,
37            fields,
38            version,
39        }
40    }
41
42    /// Create an empty delta (no changes)
43    pub fn empty(document_id: DocumentID, version: VectorClock) -> Self {
44        Self {
45            document_id,
46            fields: HashMap::new(),
47            version,
48        }
49    }
50
51    /// Check if delta is empty (no changes)
52    pub fn is_empty(&self) -> bool {
53        self.fields.is_empty()
54    }
55
56    /// Get the number of changed fields
57    pub fn len(&self) -> usize {
58        self.fields.len()
59    }
60}
61
62/// Compute delta between two documents
63///
64/// Returns a Delta containing only fields that changed between old and new.
65/// If documents have the same content, returns an empty delta.
66///
67/// # Example
68/// ```ignore
69/// let old = Document::new("doc1");
70/// let mut new = old.clone();
71/// new.set_field("title", json!("Hello"), 1, "client1".into());
72///
73/// let delta = compute_delta(&old, &new);
74/// assert_eq!(delta.len(), 1); // Only "title" field changed
75/// ```
76pub fn compute_delta(old: &Document, new: &Document) -> Delta {
77    let mut changed_fields = HashMap::new();
78
79    // Find all fields in new document
80    for (field_path, new_field) in &new.fields {
81        match old.fields.get(field_path) {
82            Some(old_field) => {
83                // Field exists in both - check if it changed
84                if old_field != new_field {
85                    changed_fields.insert(field_path.clone(), new_field.clone());
86                }
87            }
88            None => {
89                // New field (didn't exist in old)
90                changed_fields.insert(field_path.clone(), new_field.clone());
91            }
92        }
93    }
94
95    // Note: Deleted fields would be represented as tombstones in a full implementation
96    // For now, we only track additions and modifications
97
98    Delta::new(new.id.clone(), changed_fields, new.version.clone())
99}
100
101/// Apply a delta to a document
102///
103/// Updates the document with all changes from the delta using LWW merge semantics.
104/// If a field in the delta is newer, it replaces the local field.
105///
106/// # Example
107/// ```ignore
108/// let mut doc = Document::new("doc1");
109/// let delta = Delta { /* ... */ };
110/// apply_delta(&mut doc, &delta);
111/// ```
112pub fn apply_delta(doc: &mut Document, delta: &Delta) {
113    // Verify we're applying to the correct document
114    assert_eq!(doc.id, delta.document_id, "Delta document ID mismatch");
115
116    // Apply each changed field using LWW merge
117    for (field_path, delta_field) in &delta.fields {
118        match doc.fields.get(field_path) {
119            Some(local_field) => {
120                // Field exists locally - use LWW merge
121                match delta_field.timestamp.cmp(&local_field.timestamp) {
122                    std::cmp::Ordering::Greater => {
123                        doc.fields.insert(field_path.clone(), delta_field.clone());
124                    }
125                    std::cmp::Ordering::Equal => {
126                        // Tie-breaking: use client_id comparison
127                        if delta_field.timestamp.client_id > local_field.timestamp.client_id {
128                            doc.fields.insert(field_path.clone(), delta_field.clone());
129                        }
130                    }
131                    std::cmp::Ordering::Less => {} // local is newer, keep local
132                }
133                // else: local is newer, keep local
134            }
135            None => {
136                // New field - insert it
137                doc.fields.insert(field_path.clone(), delta_field.clone());
138            }
139        }
140    }
141
142    // Merge vector clocks
143    doc.version.merge(&delta.version);
144}
145
146/// Merge two deltas into a single delta
147///
148/// Combines changes from both deltas, using LWW semantics when the same field
149/// is modified in both deltas.
150///
151/// Useful for combining multiple pending changes before transmission.
152pub fn merge_deltas(delta1: &Delta, delta2: &Delta) -> Delta {
153    assert_eq!(
154        delta1.document_id, delta2.document_id,
155        "Cannot merge deltas for different documents"
156    );
157
158    let mut merged_fields = delta1.fields.clone();
159
160    // Merge fields from delta2
161    for (field_path, field2) in &delta2.fields {
162        match merged_fields.get(field_path) {
163            Some(field1) => {
164                // Field in both deltas - use LWW
165                match field2.timestamp.cmp(&field1.timestamp) {
166                    std::cmp::Ordering::Greater => {
167                        merged_fields.insert(field_path.clone(), field2.clone());
168                    }
169                    std::cmp::Ordering::Equal => {
170                        // Tie-breaking
171                        if field2.timestamp.client_id > field1.timestamp.client_id {
172                            merged_fields.insert(field_path.clone(), field2.clone());
173                        }
174                    }
175                    std::cmp::Ordering::Less => {} // field1 is newer, keep it
176                }
177            }
178            None => {
179                // New field from delta2
180                merged_fields.insert(field_path.clone(), field2.clone());
181            }
182        }
183    }
184
185    // Merge vector clocks
186    let mut merged_version = delta1.version.clone();
187    merged_version.merge(&delta2.version);
188
189    Delta::new(delta1.document_id.clone(), merged_fields, merged_version)
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::sync::Timestamp;
196    use serde_json::json;
197
198    #[test]
199    fn test_empty_delta() {
200        let doc = Document::new("doc1".to_string());
201        let delta = compute_delta(&doc, &doc);
202
203        assert!(delta.is_empty());
204        assert_eq!(delta.len(), 0);
205    }
206
207    #[test]
208    fn test_compute_delta_new_field() {
209        let old = Document::new("doc1".to_string());
210        let mut new = old.clone();
211
212        new.set_field(
213            "title".to_string(),
214            json!("Hello World"),
215            1,
216            "client1".to_string(),
217        );
218
219        let delta = compute_delta(&old, &new);
220
221        assert_eq!(delta.len(), 1);
222        assert!(delta.fields.contains_key("title"));
223        assert_eq!(delta.fields["title"].value, json!("Hello World"));
224    }
225
226    #[test]
227    fn test_compute_delta_modified_field() {
228        let mut old = Document::new("doc1".to_string());
229        old.set_field(
230            "title".to_string(),
231            json!("Old Title"),
232            1,
233            "client1".to_string(),
234        );
235
236        let mut new = old.clone();
237        new.set_field(
238            "title".to_string(),
239            json!("New Title"),
240            2,
241            "client1".to_string(),
242        );
243
244        let delta = compute_delta(&old, &new);
245
246        assert_eq!(delta.len(), 1);
247        assert_eq!(delta.fields["title"].value, json!("New Title"));
248        assert_eq!(delta.fields["title"].timestamp.clock, 2);
249    }
250
251    #[test]
252    fn test_compute_delta_multiple_changes() {
253        let old = Document::new("doc1".to_string());
254        let mut new = old.clone();
255
256        new.set_field(
257            "title".to_string(),
258            json!("Title"),
259            1,
260            "client1".to_string(),
261        );
262        new.set_field("body".to_string(), json!("Body"), 2, "client1".to_string());
263        new.set_field(
264            "author".to_string(),
265            json!("Alice"),
266            3,
267            "client1".to_string(),
268        );
269
270        let delta = compute_delta(&old, &new);
271
272        assert_eq!(delta.len(), 3);
273        assert!(delta.fields.contains_key("title"));
274        assert!(delta.fields.contains_key("body"));
275        assert!(delta.fields.contains_key("author"));
276    }
277
278    #[test]
279    fn test_apply_delta_new_field() {
280        let mut doc = Document::new("doc1".to_string());
281
282        let mut delta_fields = HashMap::new();
283        delta_fields.insert(
284            "title".to_string(),
285            Field {
286                value: json!("Hello"),
287                timestamp: Timestamp::new(1, "client1".to_string()),
288            },
289        );
290
291        let delta = Delta::new("doc1".to_string(), delta_fields, VectorClock::new());
292
293        apply_delta(&mut doc, &delta);
294
295        assert!(doc.fields.contains_key("title"));
296        assert_eq!(doc.fields["title"].value, json!("Hello"));
297    }
298
299    #[test]
300    fn test_apply_delta_lww_merge() {
301        let mut doc = Document::new("doc1".to_string());
302        doc.set_field("title".to_string(), json!("Old"), 1, "client1".to_string());
303
304        // Delta with newer timestamp
305        let mut delta_fields = HashMap::new();
306        delta_fields.insert(
307            "title".to_string(),
308            Field {
309                value: json!("New"),
310                timestamp: Timestamp::new(2, "client1".to_string()),
311            },
312        );
313
314        let delta = Delta::new("doc1".to_string(), delta_fields, VectorClock::new());
315
316        apply_delta(&mut doc, &delta);
317
318        assert_eq!(doc.fields["title"].value, json!("New"));
319        assert_eq!(doc.fields["title"].timestamp.clock, 2);
320    }
321
322    #[test]
323    fn test_apply_delta_keeps_local_if_newer() {
324        let mut doc = Document::new("doc1".to_string());
325        doc.set_field("title".to_string(), json!("New"), 2, "client1".to_string());
326
327        // Delta with older timestamp
328        let mut delta_fields = HashMap::new();
329        delta_fields.insert(
330            "title".to_string(),
331            Field {
332                value: json!("Old"),
333                timestamp: Timestamp::new(1, "client1".to_string()),
334            },
335        );
336
337        let delta = Delta::new("doc1".to_string(), delta_fields, VectorClock::new());
338
339        apply_delta(&mut doc, &delta);
340
341        // Local field is newer, should be kept
342        assert_eq!(doc.fields["title"].value, json!("New"));
343        assert_eq!(doc.fields["title"].timestamp.clock, 2);
344    }
345
346    #[test]
347    fn test_merge_deltas_non_overlapping() {
348        let mut fields1 = HashMap::new();
349        fields1.insert(
350            "title".to_string(),
351            Field {
352                value: json!("Title"),
353                timestamp: Timestamp::new(1, "client1".to_string()),
354            },
355        );
356
357        let mut fields2 = HashMap::new();
358        fields2.insert(
359            "body".to_string(),
360            Field {
361                value: json!("Body"),
362                timestamp: Timestamp::new(2, "client1".to_string()),
363            },
364        );
365
366        let delta1 = Delta::new("doc1".to_string(), fields1, VectorClock::new());
367        let delta2 = Delta::new("doc1".to_string(), fields2, VectorClock::new());
368
369        let merged = merge_deltas(&delta1, &delta2);
370
371        assert_eq!(merged.len(), 2);
372        assert!(merged.fields.contains_key("title"));
373        assert!(merged.fields.contains_key("body"));
374    }
375
376    #[test]
377    fn test_merge_deltas_overlapping_field() {
378        let mut fields1 = HashMap::new();
379        fields1.insert(
380            "title".to_string(),
381            Field {
382                value: json!("Old"),
383                timestamp: Timestamp::new(1, "client1".to_string()),
384            },
385        );
386
387        let mut fields2 = HashMap::new();
388        fields2.insert(
389            "title".to_string(),
390            Field {
391                value: json!("New"),
392                timestamp: Timestamp::new(2, "client1".to_string()),
393            },
394        );
395
396        let delta1 = Delta::new("doc1".to_string(), fields1, VectorClock::new());
397        let delta2 = Delta::new("doc1".to_string(), fields2, VectorClock::new());
398
399        let merged = merge_deltas(&delta1, &delta2);
400
401        assert_eq!(merged.len(), 1);
402        assert_eq!(merged.fields["title"].value, json!("New"));
403        assert_eq!(merged.fields["title"].timestamp.clock, 2);
404    }
405
406    #[test]
407    fn test_delta_roundtrip() {
408        // Test that computing and applying a delta results in identical document
409        let old = Document::new("doc1".to_string());
410        let mut new = old.clone();
411
412        new.set_field(
413            "title".to_string(),
414            json!("Hello"),
415            1,
416            "client1".to_string(),
417        );
418        new.set_field("body".to_string(), json!("World"), 2, "client1".to_string());
419
420        let delta = compute_delta(&old, &new);
421        let mut reconstructed = old.clone();
422        apply_delta(&mut reconstructed, &delta);
423
424        // Reconstructed should match new
425        assert_eq!(reconstructed.fields["title"], new.fields["title"]);
426        assert_eq!(reconstructed.fields["body"], new.fields["body"]);
427    }
428}