Skip to main content

nedb_engine/
diff.rs

1// SPDX-FileCopyrightText: 2026 INTERCHAINED LLC
2// SPDX-License-Identifier: BUSL-1.1
3// NEDB · © 2026 INTERCHAINED LLC × Eth-Interchained × Vex (Claude Opus 5)
4
5//! Logical difference between two points in history.
6//!
7//! # What this is not
8//!
9//! It is not a log slice. A log answers "what happened between A and B"; this
10//! answers "how does the state at B differ from the state at A", and those are
11//! different questions with different answers. A document written five times
12//! and then restored to its original contents produces five log entries and
13//! zero diff entries. A document created and deleted inside the range produces
14//! two log entries and, again, zero diff entries — it is absent on both sides,
15//! so the state did not change.
16//!
17//! It is also not textual. The unit is a document and a collection, not a line.
18//!
19//! # How a side is materialised
20//!
21//! Exactly the way [`crate::db::Db::state_root_as_of`] materialises one:
22//!
23//! ```text
24//! state(S) = { (coll, id) -> node
25//!              | coll in collections_as_of(S), not reserved,
26//!                id  in list_ids_including_deleted(coll),
27//!                get_as_of(coll, id, S) == Some(node) }
28//! ```
29//!
30//! Sharing the definition with the state root is deliberate and load-bearing:
31//! if `diff(a, b)` were empty, the two roots MUST be equal, and a diff computed
32//! from a different notion of "the state at S" could not promise that.
33//!
34//! One consequence worth stating out loud: dropping a collection removes its
35//! documents from the state even though `drop_collection` never tombstones them
36//! individually. So a drop shows up twice in a diff — once as a removed
37//! collection, once as a removed document per row. That is not double counting,
38//! it is the namespace fact and the row facts, and a consumer restoring state
39//! from the diff needs both.
40//!
41//! # Cost
42//!
43//! Per candidate id, two `get_as_of` calls, and each one walks `prev` backward
44//! from the current head until it reaches a version at or before the target
45//! sequence. The cost is therefore one object read per version stepped over,
46//! not a scan of history: a document untouched since seq 3 costs a single read
47//! no matter how far apart `from` and `to` are, and only hot documents cost
48//! more. The candidate set itself is the full id list of every collection live
49//! at either end, which is the same enumeration an `AS OF` query already pays.
50
51use crate::db::Db;
52use serde::{Deserialize, Serialize};
53use serde_json::Value;
54use std::collections::BTreeSet;
55use std::sync::atomic::Ordering;
56
57/// Which direction a thing moved between the two sequence points.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum ChangeKind {
61    /// Absent at `from_seq`, present at `to_seq`.
62    Added,
63    /// Present at `from_seq`, absent at `to_seq`.
64    Removed,
65    /// Present at both, and not identical.
66    Modified,
67}
68
69/// Which keys of a document object moved. Only ever describes `data`.
70#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub struct FieldDelta {
72    /// Keys present only on the `to` side.
73    pub added: Vec<String>,
74    /// Keys present only on the `from` side.
75    pub removed: Vec<String>,
76    /// Keys present on both sides with different values.
77    pub changed: Vec<String>,
78}
79
80impl FieldDelta {
81    /// True when no key moved. A `Modified` change can legitimately carry an
82    /// empty delta: bi-temporal validity is not a field, so a record whose
83    /// `valid_from` moved while its payload stood still changes with no key
84    /// changing. Reading emptiness as "nothing happened" would lose exactly
85    /// that case, which is why `temporal` exists alongside this.
86    pub fn is_empty(&self) -> bool {
87        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
88    }
89}
90
91/// The bi-temporal validity window, before and after.
92///
93/// Present on a `Modified` change only when the window actually moved. The
94/// alternative — folding validity into `FieldDelta` under pseudo-keys like
95/// `"valid_from"` — would collide with a user document that genuinely has a
96/// field by that name, and the collision would be silent.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct TemporalDelta {
99    pub valid_from_before: Option<String>,
100    pub valid_from_after: Option<String>,
101    pub valid_to_before: Option<String>,
102    pub valid_to_after: Option<String>,
103}
104
105/// One document whose visible state differs between the two sequence points.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct DocChange {
108    pub coll: String,
109    pub id: String,
110    pub kind: ChangeKind,
111    /// The payload as of `from_seq`. `None` for `Added`.
112    pub before: Option<Value>,
113    /// The payload as of `to_seq`. `None` for `Removed`.
114    pub after: Option<Value>,
115    /// Key-level detail, for `Modified` only, and only when BOTH payloads are
116    /// JSON objects. A scalar or an array has no keys, and inventing field
117    /// names for one (`"0"`, `"1"`, ...) would report a structure the document
118    /// does not have — `before`/`after` already say everything true there.
119    pub fields: Option<FieldDelta>,
120    /// Validity-window movement, for `Modified` only, and only when the window
121    /// moved.
122    pub temporal: Option<TemporalDelta>,
123}
124
125/// One collection that came into or went out of existence.
126///
127/// `ChangeKind::Modified` never appears here: a collection has no content of
128/// its own in the registry beyond whether it is live, so "changed" is not a
129/// state it can be in. Its documents are reported as documents.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct CollChange {
132    pub name: String,
133    pub kind: ChangeKind,
134}
135
136/// The complete difference in logical state between two sequence points.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct StateDiff {
139    pub from_seq: u64,
140    pub to_seq: u64,
141    /// Sorted by name, byte-wise.
142    pub collections: Vec<CollChange>,
143    /// Sorted by `(coll, id)`, byte-wise.
144    pub documents: Vec<DocChange>,
145}
146
147impl StateDiff {
148    /// True when the two sequence points describe the same logical state.
149    pub fn is_empty(&self) -> bool {
150        self.collections.is_empty() && self.documents.is_empty()
151    }
152}
153
154/// Error prefix for a range that reaches below the history floor. Exposed as a
155/// constant because callers (and `nesql diff`) need to branch on the reason
156/// without string-matching a sentence that may be reworded.
157pub const HISTORY_PRUNED: &str = "HISTORY_PRUNED";
158
159/// Error prefix for `from_seq > to_seq`.
160pub const REVERSED_RANGE: &str = "REVERSED_RANGE";
161
162/// Difference in logical state between `from_seq` and `to_seq`.
163///
164/// # Refusals
165///
166/// **Below the history floor.** `compact` discards superseded versions, and
167/// below [`Db::history_floor`] the `prev` chain no longer reaches. A diff
168/// computed there would be indistinguishable from a diff over a range where
169/// nothing happened: every pruned document would silently read as unchanged.
170/// An empty answer that means "I cannot see" is worse than no answer, so this
171/// refuses with [`HISTORY_PRUNED`] rather than returning a partial result.
172///
173/// **`from_seq > to_seq`.** Refused rather than interpreted as a reverse diff.
174/// A reversed range is already expressible — it is `diff(to, from)` — so
175/// accepting it here would buy nothing and cost the invariant that
176/// `kind == Added` means "exists at `to_seq`". Silently swapping the arguments
177/// would invert the meaning of every `kind` in the result relative to the
178/// argument order the caller wrote, and nothing in the returned value would
179/// tell them that happened.
180///
181/// A `to_seq` beyond the current tip is NOT refused: sequences are monotonic,
182/// so any sequence at or past the tip denotes the current state unambiguously.
183/// There is nothing to be wrong about, and refusing would make
184/// `diff(x, u64::MAX)` — a reasonable spelling of "up to now" — an error.
185pub fn diff(db: &Db, from_seq: u64, to_seq: u64) -> Result<StateDiff, String> {
186    if from_seq > to_seq {
187        return Err(format!(
188            "{}: from_seq {} is after to_seq {}; a diff's argument order fixes the \
189             sign of every change in it. Ask for diff({}, {}) if you want the \
190             reverse.",
191            REVERSED_RANGE, from_seq, to_seq, to_seq, from_seq
192        ));
193    }
194
195    let floor = db.history_floor();
196    if from_seq < floor || to_seq < floor {
197        let which = if from_seq < floor { "from_seq" } else { "to_seq" };
198        let bad = if from_seq < floor { from_seq } else { to_seq };
199        return Err(format!(
200            "{}: {} {} is below the history floor {}. compact() discarded the \
201             superseded versions needed to reconstruct that state, so a diff there \
202             could not distinguish an unchanged document from an unreadable one. \
203             The oldest diffable sequence is {}.",
204            HISTORY_PRUNED, which, bad, floor, floor
205        ));
206    }
207
208    // Equal endpoints short-circuit. Not just an optimisation: it makes the
209    // empty result a fact about the arguments rather than a claim about
210    // storage, which holds even if the engine underneath is mid-write.
211    if from_seq == to_seq {
212        return Ok(StateDiff { from_seq, to_seq, collections: vec![], documents: vec![] });
213    }
214
215    // `BTreeSet` rather than `HashSet` so every set operation below emits
216    // byte-wise sorted names with no sort step to forget.
217    let before_colls: BTreeSet<String> = live_collections(db, from_seq);
218    let after_colls: BTreeSet<String> = live_collections(db, to_seq);
219
220    let mut collections = Vec::new();
221    for name in after_colls.difference(&before_colls) {
222        collections.push(CollChange { name: name.clone(), kind: ChangeKind::Added });
223    }
224    for name in before_colls.difference(&after_colls) {
225        collections.push(CollChange { name: name.clone(), kind: ChangeKind::Removed });
226    }
227    // Interleave Added and Removed by name rather than grouping by kind: a
228    // reader scanning a diff is looking for a collection, not for a kind.
229    collections.sort_by(|a, b| a.name.cmp(&b.name));
230
231    let mut documents = Vec::new();
232    // Union, because a document can be added into a collection that is new at
233    // `to_seq`, or removed along with one that died before it.
234    for coll in before_colls.union(&after_colls) {
235        // Collection liveness gates document visibility, and has to be checked
236        // per endpoint rather than assumed from membership in the union.
237        // `drop_collection` is a namespace tombstone that deliberately leaves
238        // the rows alone, so `get_as_of` on a dropped collection still returns
239        // documents — they are simply no longer part of the state, exactly as
240        // `state_root_as_of` treats them. Without this gate a drop would show
241        // up as a removed collection whose rows all read "unchanged", which is
242        // not a state any query can return.
243        let live_before = before_colls.contains(coll);
244        let live_after = after_colls.contains(coll);
245
246        // `list_ids_including_deleted` returns sorted, deduplicated ids, and is
247        // the same candidate enumeration `AS OF` uses. It is a superset of what
248        // was live at either endpoint — ids that are absent at both are
249        // filtered out below, which is also what makes a create-then-delete
250        // entirely inside the range correctly produce nothing.
251        for id in db.list_ids_including_deleted(coll) {
252            let before = live_before.then(|| db.get_as_of(coll, &id, from_seq)).flatten();
253            let after = live_after.then(|| db.get_as_of(coll, &id, to_seq)).flatten();
254            if let Some(change) = classify(coll, &id, before, after) {
255                documents.push(change);
256            }
257        }
258    }
259    documents.sort_by(|a, b| a.coll.cmp(&b.coll).then_with(|| a.id.cmp(&b.id)));
260
261    Ok(StateDiff { from_seq, to_seq, collections, documents })
262}
263
264/// Collections live at `seq`, with the engine's own namespace removed.
265///
266/// `_nedb.*` is bookkeeping: the collection registry, persisted state roots,
267/// the history floor. Those records change as a consequence of user writes (and
268/// of taking a root, which is not a state change at all), so surfacing them
269/// would report the engine's own paperwork as part of the user's diff.
270fn live_collections(db: &Db, seq: u64) -> BTreeSet<String> {
271    db.collections_as_of(seq)
272        .into_iter()
273        .filter(|c| !crate::namespace::is_reserved(c))
274        .collect()
275}
276
277/// Turn a pair of visible versions into a change, or `None` when there is none.
278fn classify(
279    coll: &str,
280    id: &str,
281    before: Option<crate::store::Node>,
282    after: Option<crate::store::Node>,
283) -> Option<DocChange> {
284    match (before, after) {
285        (None, None) => None,
286        (None, Some(a)) => Some(DocChange {
287            coll: coll.to_string(),
288            id: id.to_string(),
289            kind: ChangeKind::Added,
290            before: None,
291            after: Some(a.data),
292            fields: None,
293            temporal: None,
294        }),
295        (Some(b), None) => Some(DocChange {
296            coll: coll.to_string(),
297            id: id.to_string(),
298            kind: ChangeKind::Removed,
299            before: Some(b.data),
300            after: None,
301            fields: None,
302            temporal: None,
303        }),
304        (Some(b), Some(a)) => {
305            let temporal_moved =
306                b.valid_from != a.valid_from || b.valid_to != a.valid_to;
307            // Payload equality only — NOT node equality. `seq`, `hash`, `prev`,
308            // `ts` and `caused_by` differ on every rewrite, including a rewrite
309            // that stored byte-identical content, and reporting that as a state
310            // change would make the diff a log again.
311            //
312            // Bi-temporal validity is the other half: the state root commits to
313            // `valid_from`/`valid_to`, so a document whose window moved IS a
314            // different logical state even with identical payload, and missing
315            // it here would let `diff` say "no change" about two provably
316            // different roots.
317            if b.data == a.data && !temporal_moved {
318                return None;
319            }
320            let fields = field_delta(&b.data, &a.data);
321            Some(DocChange {
322                coll: coll.to_string(),
323                id: id.to_string(),
324                kind: ChangeKind::Modified,
325                before: Some(b.data),
326                after: Some(a.data),
327                fields,
328                temporal: temporal_moved.then(|| TemporalDelta {
329                    valid_from_before: b.valid_from,
330                    valid_from_after: a.valid_from,
331                    valid_to_before: b.valid_to,
332                    valid_to_after: a.valid_to,
333                }),
334            })
335        }
336    }
337}
338
339/// Key-level delta, or `None` when either side is not a JSON object.
340///
341/// Output order is the byte-wise key order, not document order: `serde_json` is
342/// built here with `preserve_order`, so a document's key order is whatever the
343/// writer happened to use, and inheriting it would make the diff of two
344/// equivalent documents depend on how they were typed.
345fn field_delta(before: &Value, after: &Value) -> Option<FieldDelta> {
346    let (b, a) = match (before.as_object(), after.as_object()) {
347        (Some(b), Some(a)) => (b, a),
348        _ => return None,
349    };
350    let mut delta = FieldDelta::default();
351    let keys: BTreeSet<&String> = b.keys().chain(a.keys()).collect();
352    for k in keys {
353        match (b.get(k), a.get(k)) {
354            (None, Some(_)) => delta.added.push(k.clone()),
355            (Some(_), None) => delta.removed.push(k.clone()),
356            (Some(bv), Some(av)) if bv != av => delta.changed.push(k.clone()),
357            _ => {}
358        }
359    }
360    Some(delta)
361}
362
363/// The last assigned sequence — the newest point `diff` can be asked about and
364/// get a full answer for. Convenience for callers that want "since X, up to
365/// now" without reaching into `Db::seq` and getting the off-by-one wrong.
366pub fn tip(db: &Db) -> u64 {
367    db.seq.load(Ordering::SeqCst).saturating_sub(1)
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use serde_json::json;
374
375    fn db() -> Db {
376        Db::in_memory()
377    }
378
379    fn put(db: &Db, coll: &str, id: &str, data: Value) -> u64 {
380        db.put(coll, id, data, vec![], None, None)
381            .expect("put should succeed")
382            .seq
383    }
384
385    fn find<'a>(d: &'a StateDiff, coll: &str, id: &str) -> Option<&'a DocChange> {
386        d.documents.iter().find(|c| c.coll == coll && c.id == id)
387    }
388
389    #[test]
390    fn added_document_is_detected() {
391        let db = db();
392        let from = put(&db, "users", "a", json!({"n": 1}));
393        put(&db, "users", "b", json!({"n": 2}));
394        let d = diff(&db, from, tip(&db)).unwrap();
395        let c = find(&d, "users", "b").expect("b should appear");
396        assert_eq!(c.kind, ChangeKind::Added);
397        assert_eq!(c.before, None);
398        assert_eq!(c.after, Some(json!({"n": 2})));
399        assert!(c.fields.is_none(), "an add has no field delta; `after` is the whole story");
400    }
401
402    #[test]
403    fn removed_document_is_detected() {
404        let db = db();
405        put(&db, "users", "a", json!({"n": 1}));
406        let from = tip(&db);
407        db.delete("users", "a").unwrap();
408        let d = diff(&db, from, tip(&db)).unwrap();
409        let c = find(&d, "users", "a").expect("a should appear");
410        assert_eq!(c.kind, ChangeKind::Removed);
411        assert_eq!(c.before, Some(json!({"n": 1})));
412        assert_eq!(c.after, None);
413    }
414
415    #[test]
416    fn modified_document_is_detected() {
417        let db = db();
418        put(&db, "users", "a", json!({"n": 1}));
419        let from = tip(&db);
420        put(&db, "users", "a", json!({"n": 2}));
421        let d = diff(&db, from, tip(&db)).unwrap();
422        let c = find(&d, "users", "a").expect("a should appear");
423        assert_eq!(c.kind, ChangeKind::Modified);
424        assert_eq!(c.before, Some(json!({"n": 1})));
425        assert_eq!(c.after, Some(json!({"n": 2})));
426    }
427
428    #[test]
429    fn document_created_and_deleted_inside_the_range_does_not_appear() {
430        let db = db();
431        put(&db, "users", "keep", json!({"n": 0}));
432        let from = tip(&db);
433        put(&db, "users", "ghost", json!({"n": 1}));
434        db.delete("users", "ghost").unwrap();
435        let d = diff(&db, from, tip(&db)).unwrap();
436        assert!(
437            find(&d, "users", "ghost").is_none(),
438            "absent on both sides is not a state change: {:?}",
439            d.documents
440        );
441        assert!(d.documents.is_empty(), "{:?}", d.documents);
442    }
443
444    #[test]
445    fn unchanged_document_does_not_appear() {
446        let db = db();
447        put(&db, "users", "a", json!({"n": 1}));
448        let from = tip(&db);
449        put(&db, "users", "b", json!({"n": 2}));
450        let d = diff(&db, from, tip(&db)).unwrap();
451        assert!(find(&d, "users", "a").is_none());
452        assert_eq!(d.documents.len(), 1);
453    }
454
455    #[test]
456    fn rewriting_identical_content_is_not_a_change() {
457        // The line between a diff and a log: five writes, zero state change.
458        let db = db();
459        put(&db, "users", "a", json!({"n": 1}));
460        let from = tip(&db);
461        for _ in 0..5 {
462            put(&db, "users", "a", json!({"n": 1}));
463        }
464        let d = diff(&db, from, tip(&db)).unwrap();
465        assert!(d.is_empty(), "{:?}", d);
466    }
467
468    #[test]
469    fn value_restored_to_its_original_is_not_a_change() {
470        let db = db();
471        put(&db, "users", "a", json!({"n": 1}));
472        let from = tip(&db);
473        put(&db, "users", "a", json!({"n": 99}));
474        put(&db, "users", "a", json!({"n": 1}));
475        let d = diff(&db, from, tip(&db)).unwrap();
476        assert!(d.is_empty(), "net state is identical: {:?}", d);
477    }
478
479    #[test]
480    fn created_collection_appears_as_added() {
481        let db = db();
482        put(&db, "users", "a", json!({"n": 1}));
483        let from = tip(&db);
484        put(&db, "orders", "o1", json!({"total": 10}));
485        let d = diff(&db, from, tip(&db)).unwrap();
486        assert_eq!(
487            d.collections,
488            vec![CollChange { name: "orders".into(), kind: ChangeKind::Added }]
489        );
490        assert_eq!(find(&d, "orders", "o1").unwrap().kind, ChangeKind::Added);
491    }
492
493    #[test]
494    fn emptied_but_live_collection_is_not_a_removed_collection() {
495        let db = db();
496        put(&db, "orders", "o1", json!({"total": 10}));
497        let from = tip(&db);
498        db.delete("orders", "o1").unwrap();
499        let d = diff(&db, from, tip(&db)).unwrap();
500        assert!(
501            d.collections.is_empty(),
502            "an empty collection still exists; only a drop removes it: {:?}",
503            d.collections
504        );
505        assert_eq!(find(&d, "orders", "o1").unwrap().kind, ChangeKind::Removed);
506    }
507
508    #[test]
509    fn dropped_collection_appears_as_removed_with_its_rows() {
510        let db = db();
511        put(&db, "orders", "o1", json!({"total": 10}));
512        put(&db, "orders", "o2", json!({"total": 20}));
513        let from = tip(&db);
514        assert!(db.drop_collection("orders").unwrap());
515        let d = diff(&db, from, tip(&db)).unwrap();
516        assert_eq!(
517            d.collections,
518            vec![CollChange { name: "orders".into(), kind: ChangeKind::Removed }]
519        );
520        // The namespace fact AND the row facts: a consumer rebuilding state
521        // from this diff needs both.
522        assert_eq!(d.documents.len(), 2);
523        assert!(d.documents.iter().all(|c| c.kind == ChangeKind::Removed));
524    }
525
526    #[test]
527    fn reserved_collections_never_appear() {
528        let db = db();
529        // Seed first: on an empty database `tip` saturates to 0, which is also
530        // the sequence the very first registry record gets, so a range starting
531        // at the tip of an empty db already contains that record.
532        put(&db, "seed", "s", json!({}));
533        let from = tip(&db);
534        // Every user write touches the registry, and create_root writes a root
535        // record — both land in `_nedb.*`.
536        put(&db, "users", "a", json!({"n": 1}));
537        db.create_root().unwrap();
538        let d = diff(&db, from, tip(&db)).unwrap();
539        assert!(
540            d.collections.iter().all(|c| !c.name.starts_with("_nedb")),
541            "{:?}",
542            d.collections
543        );
544        assert!(
545            d.documents.iter().all(|c| !c.coll.starts_with("_nedb")),
546            "{:?}",
547            d.documents
548        );
549        assert_eq!(
550            d.collections,
551            vec![CollChange { name: "users".into(), kind: ChangeKind::Added }]
552        );
553    }
554
555    #[test]
556    fn below_the_history_floor_is_refused_not_silently_empty() {
557        let db = db();
558        put(&db, "users", "a", json!({"n": 1}));
559        put(&db, "users", "a", json!({"n": 2}));
560        // Set the floor directly rather than via compact(). Compaction only
561        // raises it when it ACTUALLY pruned something, and the only substrate
562        // that prunes is selected by the process-global NEDB_DAG_V3 — which a
563        // threaded test run cannot set without changing the substrate under
564        // every other database being opened at that moment.
565        db.set_history_floor(tip(&db)).unwrap();
566        let floor = db.history_floor();
567        assert!(floor > 0, "precondition: the database is in the pruned state");
568
569        let err = diff(&db, 0, tip(&db)).unwrap_err();
570        assert!(err.starts_with(HISTORY_PRUNED), "{}", err);
571        assert!(err.contains(&floor.to_string()), "the reason must name the floor: {}", err);
572
573        // At or above the floor is still answerable.
574        assert!(diff(&db, floor, tip(&db)).is_ok());
575    }
576
577    #[test]
578    fn history_floor_refusal_also_covers_to_seq() {
579        let db = db();
580        put(&db, "users", "a", json!({"n": 1}));
581        put(&db, "users", "a", json!({"n": 2}));
582        db.set_history_floor(tip(&db)).unwrap();
583        let floor = db.history_floor();
584        // from == to == 0 would short-circuit to empty if the floor check ran
585        // second; it must run first.
586        let err = diff(&db, 0, 0).unwrap_err();
587        assert!(err.starts_with(HISTORY_PRUNED), "{}", err);
588        assert!(err.contains("from_seq"), "{}", err);
589        let err = diff(&db, floor, floor - 1).unwrap_err();
590        assert!(err.starts_with(REVERSED_RANGE), "{}", err);
591    }
592
593    #[test]
594    fn reversed_range_is_refused() {
595        let db = db();
596        put(&db, "users", "a", json!({"n": 1}));
597        let t = tip(&db);
598        let err = diff(&db, t, 0).unwrap_err();
599        assert!(err.starts_with(REVERSED_RANGE), "{}", err);
600        assert!(err.contains(&format!("diff({}, {})", 0, t)), "must name the fix: {}", err);
601    }
602
603    #[test]
604    fn field_delta_names_added_removed_and_changed_keys() {
605        let db = db();
606        put(&db, "users", "a", json!({"keep": 1, "drop": 2, "move": 3}));
607        let from = tip(&db);
608        put(&db, "users", "a", json!({"keep": 1, "move": 4, "new": 5}));
609        let d = diff(&db, from, tip(&db)).unwrap();
610        let f = find(&d, "users", "a").unwrap().fields.as_ref().expect("objects get a delta");
611        assert_eq!(f.added, vec!["new".to_string()]);
612        assert_eq!(f.removed, vec!["drop".to_string()]);
613        assert_eq!(f.changed, vec!["move".to_string()]);
614    }
615
616    #[test]
617    fn no_field_delta_when_either_side_is_not_an_object() {
618        let db = db();
619        put(&db, "vals", "scalar", json!(1));
620        put(&db, "vals", "arr", json!([1, 2]));
621        let from = tip(&db);
622        put(&db, "vals", "scalar", json!({"n": 1}));
623        put(&db, "vals", "arr", json!([1, 2, 3]));
624        let d = diff(&db, from, tip(&db)).unwrap();
625        assert!(find(&d, "vals", "scalar").unwrap().fields.is_none());
626        assert!(find(&d, "vals", "arr").unwrap().fields.is_none());
627    }
628
629    #[test]
630    fn valid_from_change_alone_counts_as_modified() {
631        let db = db();
632        db.put("users", "a", json!({"n": 1}), vec![], Some("2020-01-01".into()), None)
633            .unwrap();
634        let from = tip(&db);
635        db.put("users", "a", json!({"n": 1}), vec![], Some("2021-01-01".into()), None)
636            .unwrap();
637        let d = diff(&db, from, tip(&db)).unwrap();
638        let c = find(&d, "users", "a").expect("a temporal move is a state change");
639        assert_eq!(c.kind, ChangeKind::Modified);
640        assert_eq!(c.before, c.after, "payload identical; only the window moved");
641        let t = c.temporal.as_ref().expect("the reason must be reported");
642        assert_eq!(t.valid_from_before.as_deref(), Some("2020-01-01"));
643        assert_eq!(t.valid_from_after.as_deref(), Some("2021-01-01"));
644        assert!(
645            c.fields.as_ref().unwrap().is_empty(),
646            "validity is not a field, so no key moved"
647        );
648    }
649
650    #[test]
651    fn valid_to_change_alone_counts_as_modified() {
652        let db = db();
653        db.put("users", "a", json!({"n": 1}), vec![], None, None).unwrap();
654        let from = tip(&db);
655        db.put("users", "a", json!({"n": 1}), vec![], None, Some("2030-01-01".into()))
656            .unwrap();
657        let d = diff(&db, from, tip(&db)).unwrap();
658        let c = find(&d, "users", "a").unwrap();
659        assert_eq!(c.kind, ChangeKind::Modified);
660        let t = c.temporal.as_ref().unwrap();
661        assert_eq!(t.valid_to_before, None);
662        assert_eq!(t.valid_to_after.as_deref(), Some("2030-01-01"));
663    }
664
665    #[test]
666    fn no_temporal_delta_when_the_window_did_not_move() {
667        let db = db();
668        db.put("users", "a", json!({"n": 1}), vec![], Some("2020-01-01".into()), None)
669            .unwrap();
670        let from = tip(&db);
671        db.put("users", "a", json!({"n": 2}), vec![], Some("2020-01-01".into()), None)
672            .unwrap();
673        let d = diff(&db, from, tip(&db)).unwrap();
674        assert!(find(&d, "users", "a").unwrap().temporal.is_none());
675    }
676
677    #[test]
678    fn diff_of_a_point_with_itself_is_empty() {
679        let db = db();
680        put(&db, "users", "a", json!({"n": 1}));
681        put(&db, "orders", "o1", json!({"n": 2}));
682        let t = tip(&db);
683        let d = diff(&db, t, t).unwrap();
684        assert!(d.is_empty(), "{:?}", d);
685        assert_eq!((d.from_seq, d.to_seq), (t, t));
686        // And at a sequence in the middle of history, not just the tip.
687        assert!(diff(&db, 1, 1).unwrap().is_empty());
688    }
689
690    #[test]
691    fn output_ordering_is_deterministic() {
692        let db = db();
693        put(&db, "seed", "s", json!({}));
694        let from = tip(&db);
695        // Insert in an order that is neither sorted nor reverse-sorted, across
696        // several collections, so a stable answer cannot be an accident of
697        // insertion order.
698        for (coll, id) in [
699            ("zeta", "m"), ("alpha", "z"), ("zeta", "a"),
700            ("alpha", "b"), ("mid", "q"), ("alpha", "a"),
701        ] {
702            put(&db, coll, id, json!({"v": id}));
703        }
704        let to = tip(&db);
705        let first = diff(&db, from, to).unwrap();
706        let second = diff(&db, from, to).unwrap();
707        assert_eq!(first, second, "two runs over the same range must agree");
708
709        let colls: Vec<&str> = first.collections.iter().map(|c| c.name.as_str()).collect();
710        assert_eq!(colls, vec!["alpha", "mid", "zeta"]);
711        let docs: Vec<(&str, &str)> = first
712            .documents
713            .iter()
714            .map(|c| (c.coll.as_str(), c.id.as_str()))
715            .collect();
716        assert_eq!(
717            docs,
718            vec![
719                ("alpha", "a"), ("alpha", "b"), ("alpha", "z"),
720                ("mid", "q"),
721                ("zeta", "a"), ("zeta", "m"),
722            ]
723        );
724    }
725
726    #[test]
727    fn recreated_document_is_modified_not_added() {
728        // Delete then re-put starts a fresh version chain. The endpoints are
729        // what matter: present both sides, different content.
730        let db = db();
731        put(&db, "users", "a", json!({"n": 1}));
732        let from = tip(&db);
733        db.delete("users", "a").unwrap();
734        put(&db, "users", "a", json!({"n": 2}));
735        let d = diff(&db, from, tip(&db)).unwrap();
736        let c = find(&d, "users", "a").unwrap();
737        assert_eq!(c.kind, ChangeKind::Modified);
738        assert_eq!(c.before, Some(json!({"n": 1})));
739        assert_eq!(c.after, Some(json!({"n": 2})));
740    }
741
742    #[test]
743    fn an_empty_diff_implies_equal_state_roots() {
744        // The contract that ties this module to `root`: if diff says nothing
745        // changed, the two roots must agree, and vice versa.
746        let db = db();
747        put(&db, "users", "a", json!({"n": 1}));
748        let from = tip(&db);
749        put(&db, "users", "a", json!({"n": 99}));
750        put(&db, "users", "a", json!({"n": 1}));
751        let to = tip(&db);
752        assert!(diff(&db, from, to).unwrap().is_empty());
753        assert_eq!(
754            db.state_root_as_of(from).unwrap().state_root,
755            db.state_root_as_of(to).unwrap().state_root
756        );
757    }
758}