Skip to main content

gonzalo_core/
tombstone.rs

1//! Tombstone semantics shared by every [`Store`](crate::Store) implementation
2//! (ADR 0021). Stores read the current record inside their own OCC critical
3//! section, ask a planner here what to do, and carry out the returned plan, so
4//! every substrate reaches identical decisions by construction.
5
6use crate::{
7    Body, ContentHash, CoreError, Identity, Meta, Record, RecordKind, Result, Revision,
8    store::Conflict,
9};
10
11/// Ancestor list length used when a store is not configured otherwise.
12/// About 90 bytes per serialized entry: ~2.9 KB at the cap.
13pub const DEFAULT_ANCESTOR_CAP: usize = 32;
14
15/// Domain string hashed to form every tombstone's revision hash. Not the empty
16/// body's hash: that would let a tombstone collide with a live record edited to
17/// an empty body at the same counter, and sync would miss the divergence.
18pub const TOMBSTONE_DOMAIN: &[u8] = b"gonzalo:tombstone:v1";
19
20/// The revision hash shared by every tombstone.
21pub fn tombstone_hash() -> ContentHash {
22    ContentHash::of(TOMBSTONE_DOMAIN)
23}
24
25/// Wall-clock milliseconds since the Unix epoch. A clock set before the epoch
26/// yields `i64::MAX`: collection treats a future stamp as too young, so a
27/// broken clock can only delay purging a tombstone, never make it early
28/// (spec §3.8).
29pub fn now_ms() -> i64 {
30    std::time::SystemTime::now()
31        .duration_since(std::time::UNIX_EPOCH)
32        .map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
33        .unwrap_or(i64::MAX)
34}
35
36/// Reject an ancestor cap of zero: with no ancestors, sync could never
37/// fast-forward and every divergence would surface as a conflict.
38pub fn validate_ancestor_cap(cap: usize) -> Result<usize> {
39    if cap == 0 {
40        return Err(CoreError::Backend("ancestor cap must be at least 1".into()));
41    }
42    Ok(cap)
43}
44
45/// The ancestor list to persist for a record whose revision is `stored`,
46/// replacing `current` (if any), given the ancestors the writer supplied.
47///
48/// `dedup(incoming ∪ {current.revision} ∪ current.ancestors) − {stored}`,
49/// sorted by `(counter desc, hash desc)` and truncated to `cap`. The order is
50/// total, so every substrate persists byte-identical lists.
51pub fn fold_ancestors(
52    stored: &Revision,
53    incoming: &[Revision],
54    current: Option<&Record>,
55    cap: usize,
56) -> Vec<Revision> {
57    let from_current = current
58        .into_iter()
59        .flat_map(|c| std::iter::once(&c.revision).chain(c.ancestors.iter()));
60    let mut folded: Vec<Revision> = incoming.iter().chain(from_current).cloned().collect();
61    // `incoming` is untrusted and this runs inside a store's critical section,
62    // so dedup by sorting first (equal revisions land adjacent) rather than
63    // `Vec::contains`, which is O(n²).
64    folded.sort_by(|a, b| b.counter.cmp(&a.counter).then_with(|| b.hash.cmp(&a.hash)));
65    folded.dedup();
66    folded.retain(|r| r != stored);
67    folded.truncate(cap);
68    folded
69}
70
71/// The tombstone that deleting the live record `current` produces. `author`,
72/// when given, is the deleter and replaces `meta.author`; otherwise the
73/// tombstone keeps the last live writer's metadata.
74///
75/// A delete is a write, so `meta.updated` becomes the delete's time while
76/// `meta.created` stays the deleted record's (gonzalo#293). `deleted_at` is the
77/// same instant, and is what collection reads.
78pub fn tombstone_of(
79    current: &Record,
80    now_ms: i64,
81    cap: usize,
82    author: Option<&Identity>,
83) -> Record {
84    let revision = Revision {
85        counter: current.revision.counter + 1,
86        hash: tombstone_hash(),
87    };
88    let ancestors = fold_ancestors(&revision, &[], Some(current), cap);
89    let mut meta = current.meta.clone();
90    if let Some(author) = author {
91        meta.author = author.clone();
92    }
93    meta.updated = now_ms;
94    // A manifest's body names its blobs out of line, so it is the only record
95    // of which blob was which shard. Discarding it would drop the hashes out of
96    // the GC mark set AND lose the mapping, leaving any pinned bytes
97    // unnameable — so these kinds keep their body (ADR 0029).
98    let retains_body = matches!(
99        current.kind,
100        RecordKind::GraphManifest | RecordKind::VectorManifest
101    );
102    Record {
103        key: current.key.clone(),
104        kind: RecordKind::Tombstone,
105        revision,
106        parent: Some(current.revision.clone()),
107        body: if retains_body {
108            current.body.clone()
109        } else {
110            Body::Inline(Vec::new())
111        },
112        meta,
113        links: Vec::new(),
114        ancestors,
115        deleted_at: Some(now_ms),
116        // Pin the deleted record's blob, if it had one: the tombstone's own
117        // body is empty, so this is the only remaining reference, and blob
118        // collection keeps the bytes until the tombstone itself is collected
119        // (gonzalo#292). Two peers deleting the same revision still produce
120        // identical tombstones, since they pin the same hash.
121        deleted_blob: match &current.body {
122            Body::Blob { hash, .. } => Some(hash.clone()),
123            Body::Inline(_) => None,
124        },
125        deleted_kind: retains_body.then_some(current.kind),
126    }
127}
128
129/// What a store must do for a `put`. Decided with the store's current record
130/// in hand, inside its OCC critical section.
131#[derive(Clone, Debug, PartialEq, Eq)]
132// The plan is a short-lived return value, never stored in a collection, so the
133// size difference between `Write(Record)` and the other variants doesn't matter.
134#[allow(clippy::large_enum_variant)]
135pub enum PutPlan {
136    /// Persist exactly this record, then return
137    /// `PutResult::Committed(record.revision)`. On recreation the revision was
138    /// re-stamped; ancestors are always already folded.
139    Write(Record),
140    /// Persist nothing; return `PutResult::Conflict`.
141    Conflict(Box<Conflict>),
142    /// Persist nothing; return `Err(CoreError::NotFound(key))`.
143    NotFound,
144    /// The record may not be written through this path; return
145    /// `Err(CoreError::Invalid(reason.to_string()))` (the daemon answers 400,
146    /// not 500; gonzalo#299). Only consumer `plan_put` produces this: for a
147    /// `RecordKind::Tombstone` record (deletes go through `Store::delete_as`,
148    /// and replication writes tombstones through `put_raw`), and for a create
149    /// over a manifest tombstone, which would discard its restore window.
150    Rejected(&'static str),
151}
152
153/// The reason a consumer `put` of a `RecordKind::Tombstone` record is
154/// rejected (see [`PutPlan::Rejected`]).
155pub const CONSUMER_TOMBSTONE_REJECTED: &str =
156    "consumer put cannot write a tombstone; use delete_as";
157
158/// The reason a consumer `put` that would create over a *manifest* tombstone is
159/// rejected (see [`PutPlan::Rejected`]). A manifest tombstone retains the
160/// deleted body, which is the restore window ADR 0029 promises; overwriting it
161/// would unpin blobs that may be unregenerable (ADR 0030).
162pub const MANIFEST_TOMBSTONE_RECREATE_REJECTED: &str = concat!(
163    "a deleted manifest is at this key; restore it with `gonzalo undelete`, ",
164    "or discard the tombstone with `gonzalo purge`",
165);
166
167/// Decide a consumer `put` (spec §3.2). A `RecordKind::Tombstone` record is
168/// always `Rejected`: deletes go through `Store::delete_as`, and replication
169/// writes tombstones through `put_raw`. Otherwise, a tombstone counts as
170/// absent: a create (`expected == None`) recreates the key past the
171/// tombstone, and any `Some(_)` is `NotFound`. The one exception is a
172/// *manifest* tombstone (`deleted_kind` of `GraphManifest` or `VectorManifest`),
173/// whose retained body is a restore window: a create over it is `Rejected`
174/// with [`MANIFEST_TOMBSTONE_RECREATE_REJECTED`]. Replication writes use
175/// [`plan_put_raw`].
176///
177/// The store stamps `meta.created` and `meta.updated` on every consumer write,
178/// inside the same critical section that decides the write, exactly as it
179/// stamps a tombstone's `deleted_at` (gonzalo#293). A client's values are
180/// overwritten: times a caller can set are times a caller can lie about, and
181/// two substrates would disagree about what a record's history means.
182///
183/// `updated` is always the write's time. `created` is the live record's
184/// `created` when one is being replaced, so it survives every edit, and the
185/// write's time otherwise — including a recreation over a tombstone, which is a
186/// new record at an old key, not a continuation of the deleted one.
187pub fn plan_put(
188    current: Option<&Record>,
189    mut record: Record,
190    expected: Option<Revision>,
191    now_ms: i64,
192    cap: usize,
193) -> PutPlan {
194    if record.is_tombstone() {
195        return PutPlan::Rejected(CONSUMER_TOMBSTONE_REJECTED);
196    }
197    match current {
198        None => {
199            if expected.is_some() {
200                return PutPlan::NotFound;
201            }
202            record.meta.created = now_ms;
203            record.meta.updated = now_ms;
204            record.ancestors = fold_ancestors(&record.revision, &record.ancestors, None, cap);
205            PutPlan::Write(record)
206        }
207        Some(t) if t.is_tombstone() => match expected {
208            // A manifest tombstone is a restore window: its retained body is
209            // the only record of which blob was which shard, and GC marks
210            // through it (ADR 0029). A create would overwrite the tombstone and
211            // discard that body, so refuse and make the caller choose between
212            // restoring and discarding (ADR 0030). Keyed on the STORED
213            // tombstone, never the incoming record: an incoming `deleted_kind`
214            // is a client smuggling a field, handled by the recreation arm.
215            None if matches!(
216                t.deleted_kind,
217                Some(RecordKind::GraphManifest | RecordKind::VectorManifest)
218            ) =>
219            {
220                PutPlan::Rejected(MANIFEST_TOMBSTONE_RECREATE_REJECTED)
221            }
222            None => {
223                // Recreation: continue the chain past the tombstone so the new
224                // record is never ordered before the delete it follows. The
225                // incoming record is never a tombstone here: that case is
226                // rejected above, before `current` is even consulted.
227                record.revision = Revision {
228                    counter: t.revision.counter + 1,
229                    hash: ContentHash::of(record.body.bytes()),
230                };
231                record.parent = Some(t.revision.clone());
232                record.deleted_at = None;
233                // The recreated record is live and carries its own body, so it
234                // pins nothing on the tombstone's behalf (gonzalo#292).
235                record.deleted_blob = None;
236                // `deleted_kind` is set on tombstones only; a client-supplied
237                // record must not smuggle one onto a live record.
238                record.deleted_kind = None;
239                // A recreation starts a new life at this key: the deleted
240                // record's `created` does not carry over.
241                record.meta.created = now_ms;
242                record.meta.updated = now_ms;
243                record.ancestors =
244                    fold_ancestors(&record.revision, &record.ancestors, Some(t), cap);
245                PutPlan::Write(record)
246            }
247            // Consumers never learn a tombstone's revision, so any `Some` here
248            // is stale; replication writes over tombstones use `plan_put_raw`.
249            Some(_) => PutPlan::NotFound,
250        },
251        Some(c) => {
252            if expected.as_ref() == Some(&c.revision) {
253                record.meta.created = c.meta.created;
254                record.meta.updated = now_ms;
255                record.ancestors =
256                    fold_ancestors(&record.revision, &record.ancestors, Some(c), cap);
257                PutPlan::Write(record)
258            } else {
259                PutPlan::Conflict(Box::new(Conflict {
260                    key: record.key.clone(),
261                    expected,
262                    current: c.clone(),
263                }))
264            }
265        }
266    }
267}
268
269/// Decide a replication write (`Store::put_raw`, spec §3.2). Never re-stamps:
270/// a tombstone is an ordinary record here. A create that finds anything stored
271/// (live or tombstone) is a `Conflict` carrying it, so sync re-reads instead of
272/// turning a copy into a recreation that would resurrect a deleted record.
273///
274/// `now_ms` is deliberately unused: a replication write copies a record that
275/// already happened elsewhere, so it keeps the source's `created` and `updated`
276/// (gonzalo#293). Stamping them here would make every sync look like an edit
277/// and lose when the record was really written. The parameter is kept so both
278/// planners share one signature, which is what lets a store hand either to its
279/// locked read→plan→write path.
280pub fn plan_put_raw(
281    current: Option<&Record>,
282    mut record: Record,
283    expected: Option<Revision>,
284    _now_ms: i64,
285    cap: usize,
286) -> PutPlan {
287    match (current, expected) {
288        (None, None) => {
289            record.ancestors = fold_ancestors(&record.revision, &record.ancestors, None, cap);
290            PutPlan::Write(record)
291        }
292        (None, Some(_)) => PutPlan::NotFound,
293        (Some(c), Some(e)) if e == c.revision => {
294            record.ancestors = fold_ancestors(&record.revision, &record.ancestors, Some(c), cap);
295            PutPlan::Write(record)
296        }
297        (Some(c), expected) => PutPlan::Conflict(Box::new(Conflict {
298            key: record.key.clone(),
299            expected,
300            current: c.clone(),
301        })),
302    }
303}
304
305/// What a store must do for a `delete`.
306#[derive(Clone, Debug, PartialEq, Eq)]
307// The plan is a short-lived return value, never stored in a collection, so the
308// size difference between `Write(Record)` and the other variants doesn't matter.
309#[allow(clippy::large_enum_variant)]
310pub enum DeletePlan {
311    /// Persist this tombstone, then return `DeleteResult::Deleted`.
312    Write(Record),
313    /// Persist nothing; return `DeleteResult::Deleted`.
314    Noop,
315    /// Persist nothing; return `DeleteResult::Conflict`.
316    Conflict(Box<Conflict>),
317}
318
319/// Decide a conditional `delete` (spec §3.2). Deleting an absent key writes
320/// nothing: there is no revision to order a tombstone against a peer's copy.
321/// Deleting a tombstone writes nothing: the chain does not advance.
322pub fn plan_delete(
323    current: Option<&Record>,
324    expected: Option<Revision>,
325    now_ms: i64,
326    cap: usize,
327    author: Option<&Identity>,
328) -> DeletePlan {
329    match current {
330        None => DeletePlan::Noop,
331        Some(c) if c.is_tombstone() => DeletePlan::Noop,
332        Some(c) if expected.is_none() || expected.as_ref() == Some(&c.revision) => {
333            DeletePlan::Write(tombstone_of(c, now_ms, cap, author))
334        }
335        Some(c) => DeletePlan::Conflict(Box::new(Conflict {
336            key: c.key.clone(),
337            expected,
338            current: c.clone(),
339        })),
340    }
341}
342
343/// What a store must do for a `purge`.
344#[derive(Clone, Debug, PartialEq, Eq)]
345pub enum PurgePlan {
346    /// Physically remove the stored record; return `DeleteResult::Deleted`.
347    Remove,
348    /// Nothing is stored; return `DeleteResult::Deleted`.
349    Noop,
350    /// Remove nothing; return `DeleteResult::Conflict`.
351    Conflict(Box<Conflict>),
352}
353
354/// Decide a `purge`: remove only the exact revision named, so a key recreated
355/// after its tombstone was listed survives collection.
356pub fn plan_purge(current: Option<&Record>, expected: &Revision) -> PurgePlan {
357    match current {
358        None => PurgePlan::Noop,
359        Some(c) if &c.revision == expected => PurgePlan::Remove,
360        Some(c) => PurgePlan::Conflict(Box::new(Conflict {
361            key: c.key.clone(),
362            expected: Some(expected.clone()),
363            current: c.clone(),
364        })),
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::{Body, Identity, Meta, RecordKey, RecordKind};
372    use std::collections::BTreeMap;
373
374    /// A fixed clock for the tests that are about a plan's shape rather than
375    /// its stamps (gonzalo#293).
376    const NOW: i64 = 1_700_000_000_000;
377
378    /// [`super::plan_put`] at [`NOW`]. The stamping itself is covered by the
379    /// `records_are_stamped_*` tests below, which call the planner directly.
380    fn plan_put(
381        current: Option<&Record>,
382        record: Record,
383        expected: Option<Revision>,
384        cap: usize,
385    ) -> PutPlan {
386        super::plan_put(current, record, expected, NOW, cap)
387    }
388
389    /// [`super::plan_put_raw`] at [`NOW`], which it ignores.
390    fn plan_put_raw(
391        current: Option<&Record>,
392        record: Record,
393        expected: Option<Revision>,
394        cap: usize,
395    ) -> PutPlan {
396        super::plan_put_raw(current, record, expected, NOW, cap)
397    }
398
399    fn rev(counter: u64, body: &[u8]) -> Revision {
400        Revision {
401            counter,
402            hash: ContentHash::of(body),
403        }
404    }
405
406    fn live(counter: u64, body: &[u8], ancestors: Vec<Revision>) -> Record {
407        Record {
408            key: RecordKey::new("ns", "col", "id"),
409            kind: RecordKind::Topic,
410            revision: rev(counter, body),
411            parent: None,
412            body: Body::Inline(body.to_vec()),
413            meta: Meta {
414                author: Identity::new("t"),
415                origin_system: "test".into(),
416                created: 0,
417                updated: 0,
418                labels: BTreeMap::new(),
419            },
420            links: Vec::new(),
421            ancestors,
422            deleted_at: None,
423            deleted_blob: None,
424            deleted_kind: None,
425        }
426    }
427
428    // ---- record times (#293) ----
429
430    /// The `PutPlan::Write` a plan produced, or a panic naming what it was.
431    fn written(plan: PutPlan) -> Record {
432        match plan {
433            PutPlan::Write(r) => r,
434            other => panic!("expected a write, got {other:?}"),
435        }
436    }
437
438    #[test]
439    fn a_create_is_stamped_with_the_write_time() {
440        let mut incoming = live(0, b"v0", vec![]);
441        // A client's values carry no authority; the store decides.
442        incoming.meta.created = 999;
443        incoming.meta.updated = 999;
444
445        let stored = written(super::plan_put(None, incoming, None, NOW, 32));
446
447        assert_eq!(stored.meta.created, NOW);
448        assert_eq!(stored.meta.updated, NOW);
449    }
450
451    #[test]
452    fn an_update_keeps_created_and_advances_updated() {
453        let mut current = live(0, b"v0", vec![]);
454        current.meta.created = NOW;
455        current.meta.updated = NOW;
456        let later = NOW + 5_000;
457
458        let stored = written(super::plan_put(
459            Some(&current),
460            live(1, b"v1", vec![]),
461            Some(current.revision.clone()),
462            later,
463            32,
464        ));
465
466        assert_eq!(stored.meta.created, NOW, "created survives every edit");
467        assert_eq!(stored.meta.updated, later);
468    }
469
470    #[test]
471    fn a_recreation_over_a_tombstone_starts_a_new_created() {
472        // The key is reused, but the record is a new one: carrying the deleted
473        // record's `created` across would claim a history it doesn't have.
474        let mut deleted = live(0, b"v0", vec![]);
475        deleted.meta.created = NOW;
476        let tomb = tombstone_of(&deleted, NOW + 1_000, 32, None);
477        let later = NOW + 9_000;
478
479        let stored = written(super::plan_put(
480            Some(&tomb),
481            live(0, b"fresh", vec![]),
482            None,
483            later,
484            32,
485        ));
486
487        assert_eq!(stored.meta.created, later);
488        assert_eq!(stored.meta.updated, later);
489    }
490
491    #[test]
492    fn a_tombstone_records_the_delete_as_its_update() {
493        let mut current = live(0, b"v0", vec![]);
494        current.meta.created = NOW;
495        current.meta.updated = NOW;
496
497        let tomb = tombstone_of(&current, NOW + 250, 32, None);
498
499        assert_eq!(tomb.meta.created, NOW, "the record was created when it was");
500        assert_eq!(tomb.meta.updated, NOW + 250);
501        assert_eq!(tomb.deleted_at, Some(NOW + 250));
502    }
503
504    #[test]
505    fn a_replication_write_keeps_the_source_times() {
506        // What sync copies happened elsewhere, at the time the source says.
507        let mut incoming = live(0, b"v0", vec![]);
508        incoming.meta.created = 111;
509        incoming.meta.updated = 222;
510
511        let stored = written(super::plan_put_raw(None, incoming, None, NOW, 32));
512
513        assert_eq!((stored.meta.created, stored.meta.updated), (111, 222));
514    }
515
516    #[test]
517    fn tombstone_hash_is_domain_separated() {
518        assert_eq!(tombstone_hash(), ContentHash::of(b"gonzalo:tombstone:v1"));
519        assert_ne!(tombstone_hash(), ContentHash::of(b""));
520    }
521
522    #[test]
523    fn now_ms_is_after_2020() {
524        assert!(now_ms() > 1_577_836_800_000);
525    }
526
527    #[test]
528    fn zero_cap_is_rejected() {
529        assert!(validate_ancestor_cap(0).is_err());
530        assert_eq!(validate_ancestor_cap(1).unwrap(), 1);
531        assert_eq!(validate_ancestor_cap(DEFAULT_ANCESTOR_CAP).unwrap(), 32);
532    }
533
534    #[test]
535    fn fold_with_nothing_is_empty() {
536        assert!(fold_ancestors(&rev(0, b"a"), &[], None, 32).is_empty());
537    }
538
539    #[test]
540    fn fold_adds_current_and_its_ancestors_newest_first() {
541        let cur = live(2, b"c", vec![rev(1, b"b"), rev(0, b"a")]);
542        let got = fold_ancestors(&rev(3, b"d"), &[], Some(&cur), 32);
543        assert_eq!(got, vec![rev(2, b"c"), rev(1, b"b"), rev(0, b"a")]);
544    }
545
546    #[test]
547    fn fold_dedups_incoming_and_current() {
548        let cur = live(1, b"b", vec![rev(0, b"a")]);
549        let incoming = [rev(1, b"b"), rev(0, b"a")];
550        let got = fold_ancestors(&rev(2, b"c"), &incoming, Some(&cur), 32);
551        assert_eq!(got, vec![rev(1, b"b"), rev(0, b"a")]);
552    }
553
554    #[test]
555    fn fold_excludes_the_stored_revision() {
556        let cur = live(4, b"t", vec![rev(3, b"x")]);
557        // Writing the same revision back (sync winner onto the side holding it).
558        let got = fold_ancestors(&rev(4, b"t"), &[rev(3, b"y")], Some(&cur), 32);
559        assert!(!got.contains(&rev(4, b"t")));
560        let mut expected = vec![rev(3, b"x"), rev(3, b"y")];
561        expected.sort_by(|a, b| b.hash.cmp(&a.hash));
562        assert_eq!(got, expected);
563    }
564
565    #[test]
566    fn fold_truncates_to_cap_keeping_newest() {
567        let ancestors: Vec<Revision> = (0..10).rev().map(|i| rev(i, &[i as u8])).collect();
568        let cur = live(10, b"z", ancestors);
569        let got = fold_ancestors(&rev(11, b"n"), &[], Some(&cur), 3);
570        assert_eq!(got, vec![rev(10, b"z"), rev(9, &[9]), rev(8, &[8])]);
571    }
572
573    #[test]
574    fn tombstone_of_follows_the_spec_table() {
575        let cur = live(5, b"body", vec![rev(4, b"prev")]);
576        let t = tombstone_of(&cur, 1_234, 32, None);
577        assert_eq!(t.key, cur.key);
578        assert_eq!(t.kind, RecordKind::Tombstone);
579        assert_eq!(
580            t.revision,
581            Revision {
582                counter: 6,
583                hash: tombstone_hash()
584            }
585        );
586        assert_eq!(t.parent, Some(cur.revision.clone()));
587        assert_eq!(t.body, Body::Inline(Vec::new()));
588        assert_eq!(t.ancestors, vec![cur.revision.clone(), rev(4, b"prev")]);
589        assert_eq!(t.deleted_at, Some(1_234));
590        assert_eq!(t.meta.author, cur.meta.author);
591        assert_eq!(t.meta.origin_system, cur.meta.origin_system);
592        assert_eq!(t.meta.created, cur.meta.created);
593        // The delete is the record's last update (#293).
594        assert_eq!(t.meta.updated, 1_234);
595        assert!(t.links.is_empty());
596        // An inline body has no blob to pin.
597        assert_eq!(t.deleted_blob, None);
598    }
599
600    #[test]
601    fn tombstone_of_pins_the_deleted_records_blob() {
602        // The tombstone's own body is empty, so this pin is the only remaining
603        // reference to the content — without it, blob GC would sweep bytes a
604        // peer can still resurrect the record from (gonzalo#292).
605        let mut cur = live(5, b"body", vec![]);
606        cur.body = Body::blob(b"the real content");
607
608        let t = tombstone_of(&cur, 1_234, 32, None);
609
610        assert_eq!(t.body, Body::Inline(Vec::new()));
611        assert_eq!(t.deleted_blob, Some(ContentHash::of(b"the real content")));
612    }
613
614    #[test]
615    fn independent_tombstones_of_a_blob_backed_record_are_identical() {
616        // The pin is a function of the deleted record, so two peers deleting
617        // the same revision still converge on byte-identical tombstones.
618        let mut cur = live(5, b"body", vec![]);
619        cur.body = Body::blob(b"content");
620        assert_eq!(
621            tombstone_of(&cur, 1, 32, None).deleted_blob,
622            tombstone_of(&cur, 999, 32, None).deleted_blob
623        );
624    }
625
626    #[test]
627    fn tombstone_of_stamps_the_deleter_when_given() {
628        let cur = live(5, b"body", vec![]);
629        let deleter = Identity::new("deleter");
630        let t = tombstone_of(&cur, 1, 32, Some(&deleter));
631        assert_eq!(t.meta.author, deleter);
632        assert_eq!(t.meta.origin_system, cur.meta.origin_system);
633    }
634
635    #[test]
636    fn independent_tombstones_of_the_same_revision_are_identical() {
637        let cur = live(5, b"body", vec![]);
638        assert_eq!(
639            tombstone_of(&cur, 1, 32, None).revision,
640            tombstone_of(&cur, 999, 32, None).revision
641        );
642    }
643
644    #[test]
645    fn tombstone_never_equals_an_empty_body_edit() {
646        let cur = live(5, b"body", vec![]);
647        assert_ne!(
648            tombstone_of(&cur, 1, 32, None).revision,
649            cur.revision.next(b"")
650        );
651    }
652
653    fn vector_manifest_record() -> Record {
654        let mut m = crate::VectorManifest::new("space-a", 3, 256);
655        m.entries.insert(0, ContentHash::of(b"shard-zero"));
656        m.entries.insert(9, ContentHash::of(b"shard-nine"));
657        Record::create(
658            crate::VectorManifest::key("ns", "memories"),
659            RecordKind::VectorManifest,
660            m.to_body(),
661            Meta::new(Identity::new("tester"), "test"),
662        )
663    }
664
665    // The whole point: a manifest's body names its blobs out of line, so
666    // discarding it loses both the pin and the shard-to-blob mapping.
667    #[test]
668    fn a_vector_manifest_tombstone_keeps_its_body_and_kind() {
669        let live = vector_manifest_record();
670        let t = tombstone_of(&live, 1_000, 8, None);
671
672        assert!(t.is_tombstone());
673        assert_eq!(t.body, live.body, "the manifest body is retained verbatim");
674        assert_eq!(t.deleted_kind, Some(RecordKind::VectorManifest));
675        assert_eq!(t.deleted_blob, None, "an inline body pins nothing this way");
676        assert_eq!(t.deleted_at, Some(1_000));
677    }
678
679    #[test]
680    fn a_graph_manifest_tombstone_keeps_its_body_and_kind() {
681        let mut gm = crate::Manifest::new();
682        gm.insert("src/lib.rs", ContentHash::of(b"slice"));
683        let live = Record::create(
684            crate::Manifest::key("repo", "main"),
685            RecordKind::GraphManifest,
686            gm.to_body(),
687            Meta::new(Identity::new("tester"), "test"),
688        );
689        let t = tombstone_of(&live, 1_000, 8, None);
690        assert_eq!(t.body, live.body);
691        assert_eq!(t.deleted_kind, Some(RecordKind::GraphManifest));
692    }
693
694    // Nothing else moves. A blob-bodied record still pins through deleted_blob
695    // and still gets an empty body.
696    #[test]
697    fn a_blob_bodied_tombstone_is_unchanged() {
698        let live = Record::create(
699            RecordKey::new("ns", "coll", "doc"),
700            RecordKind::Topic,
701            Body::blob(b"out of line"),
702            Meta::new(Identity::new("tester"), "test"),
703        );
704
705        let t = tombstone_of(&live, 1_000, 8, None);
706        assert_eq!(t.body, Body::Inline(Vec::new()));
707        assert_eq!(t.deleted_blob, Some(ContentHash::of(b"out of line")));
708        assert_eq!(t.deleted_kind, None);
709    }
710
711    #[test]
712    fn an_inline_non_manifest_tombstone_is_unchanged() {
713        let live = Record::create(
714            RecordKey::new("ns", "coll", "topic"),
715            RecordKind::Topic,
716            Body::Inline(b"{}".to_vec()),
717            Meta::new(Identity::new("tester"), "test"),
718        );
719        let t = tombstone_of(&live, 1_000, 8, None);
720        assert_eq!(t.body, Body::Inline(Vec::new()));
721        assert_eq!(t.deleted_kind, None);
722        assert_eq!(t.deleted_blob, None);
723    }
724
725    // Replication depends on this: two peers deleting the same revision must
726    // produce the same bytes, or sync sees a divergence that isn't one.
727    #[test]
728    fn two_peers_deleting_one_manifest_revision_agree_byte_for_byte() {
729        let live = vector_manifest_record();
730        let a = tombstone_of(&live, 1_000, 8, None);
731        let b = tombstone_of(&live, 1_000, 8, None);
732        assert_eq!(
733            serde_json::to_vec(&a).unwrap(),
734            serde_json::to_vec(&b).unwrap()
735        );
736    }
737
738    // Review Focus 2: a second delete must not rewrite the tombstone and throw
739    // the retained body away, which would silently unpin every shard. The
740    // store's own guard is `plan_delete`, pinned by the existing conformance
741    // case `delete_of_tombstone_is_noop`; this test documents what the
742    // function itself does if a future caller reaches it directly.
743    #[test]
744    fn tombstoning_a_tombstone_retains_nothing() {
745        let live = vector_manifest_record();
746        let first = tombstone_of(&live, 1_000, 8, None);
747        let again = tombstone_of(&first, 2_000, 8, None);
748        assert_eq!(
749            again.body,
750            Body::Inline(Vec::new()),
751            "a tombstone is not a manifest kind, so this path retains nothing"
752        );
753        assert_eq!(again.deleted_kind, None);
754    }
755
756    // Every tombstone already on disk predates the field.
757    #[test]
758    fn a_tombstone_without_deleted_kind_deserialises() {
759        let json = r#"{"key":{"namespace":"ns","collection":"coll","id":"x"},
760            "kind":"Tombstone","revision":{"counter":1,"hash":"b90c7f9ba262c46461403ddcc4aa254d6b28dd54b2c95f783fbb9562c754ed65"},
761            "body":{"Inline":[]},"meta":{"author":{"id":"t","display":null},
762            "origin_system":"test","created":0,"updated":0,"labels":{}},
763            "links":[],"deleted_at":1}"#;
764        let r: Record = serde_json::from_str(json).unwrap();
765        assert_eq!(r.deleted_kind, None);
766    }
767
768    fn tomb(counter: u64) -> Record {
769        let prior = live(counter - 1, b"was", vec![]);
770        tombstone_of(&prior, 42, 32, None)
771    }
772
773    // ---- plan_put ----
774
775    #[test]
776    fn put_create_on_absent_writes() {
777        let rec = live(0, b"new", vec![]);
778        let mut stamped = rec.clone();
779        // The only thing the planner changes is the times (#293).
780        stamped.meta.created = NOW;
781        stamped.meta.updated = NOW;
782        assert_eq!(plan_put(None, rec, None, 32), PutPlan::Write(stamped));
783    }
784
785    #[test]
786    fn put_expected_on_absent_is_not_found() {
787        let rec = live(0, b"new", vec![]);
788        assert_eq!(
789            plan_put(None, rec, Some(rev(0, b"x")), 32),
790            PutPlan::NotFound
791        );
792    }
793
794    #[test]
795    fn put_update_with_matching_expected_folds_ancestors() {
796        let cur = live(0, b"v0", vec![]);
797        let mut next = live(1, b"v1", vec![]);
798        next.parent = Some(cur.revision.clone());
799        let PutPlan::Write(stored) =
800            plan_put(Some(&cur), next.clone(), Some(cur.revision.clone()), 32)
801        else {
802            panic!("expected Write");
803        };
804        assert_eq!(stored.revision, next.revision);
805        assert_eq!(stored.ancestors, vec![cur.revision.clone()]);
806    }
807
808    #[test]
809    fn put_over_live_with_wrong_or_no_expected_conflicts() {
810        let cur = live(0, b"v0", vec![]);
811        for expected in [None, Some(rev(9, b"nope"))] {
812            match plan_put(Some(&cur), live(1, b"v1", vec![]), expected.clone(), 32) {
813                PutPlan::Conflict(c) => {
814                    assert_eq!(c.current, cur);
815                    assert_eq!(c.expected, expected);
816                }
817                other => panic!("expected Conflict, got {other:?}"),
818            }
819        }
820    }
821
822    #[test]
823    fn put_none_over_tombstone_recreates_continuing_the_chain() {
824        let t = tomb(3);
825        let fresh = live(0, b"again", vec![]);
826        let PutPlan::Write(stored) = plan_put(Some(&t), fresh, None, 32) else {
827            panic!("expected Write");
828        };
829        assert_eq!(stored.revision, rev(t.revision.counter + 1, b"again"));
830        assert_eq!(stored.parent, Some(t.revision.clone()));
831        assert_eq!(stored.deleted_at, None);
832        assert_eq!(stored.ancestors.first(), Some(&t.revision));
833        assert_eq!(stored.kind, RecordKind::Topic);
834        // The key is live again, so the old content stops being pinned: the
835        // recreated record's own body is what keeps a blob alive (gonzalo#292).
836        assert_eq!(stored.deleted_blob, None);
837    }
838
839    #[test]
840    fn recreating_over_a_tombstone_clears_deleted_kind() {
841        // `deleted_kind` is documented as set on tombstones only. Records cross
842        // the daemon as full JSON, so an incoming live record can carry one;
843        // the recreation arm must not persist it.
844        let t = tomb(3);
845        let mut fresh = live(0, b"again", vec![]);
846        fresh.deleted_kind = Some(RecordKind::VectorManifest);
847        let PutPlan::Write(stored) = plan_put(Some(&t), fresh, None, 32) else {
848            panic!("expected Write");
849        };
850        assert!(!stored.is_tombstone());
851        assert_eq!(stored.deleted_kind, None);
852    }
853
854    #[test]
855    fn consumer_put_of_a_tombstone_is_rejected() {
856        let tombstone_record = tomb(1);
857
858        // Absent key.
859        assert_eq!(
860            plan_put(None, tombstone_record.clone(), None, 32),
861            PutPlan::Rejected(CONSUMER_TOMBSTONE_REJECTED)
862        );
863
864        // Live key, matching expected.
865        let cur = live(0, b"v", vec![]);
866        assert_eq!(
867            plan_put(
868                Some(&cur),
869                tombstone_record.clone(),
870                Some(cur.revision.clone()),
871                32
872            ),
873            PutPlan::Rejected(CONSUMER_TOMBSTONE_REJECTED)
874        );
875
876        // Tombstoned key.
877        let t = tomb(3);
878        assert_eq!(
879            plan_put(Some(&t), tombstone_record, None, 32),
880            PutPlan::Rejected(CONSUMER_TOMBSTONE_REJECTED)
881        );
882    }
883
884    #[test]
885    fn consumer_put_with_any_expected_over_tombstone_is_not_found() {
886        let t = tomb(3);
887        // Even the tombstone's own revision: replication uses plan_put_raw.
888        for expected in [rev(1, b"stale"), t.revision.clone()] {
889            assert_eq!(
890                plan_put(Some(&t), live(0, b"x", vec![]), Some(expected), 32),
891                PutPlan::NotFound
892            );
893        }
894    }
895
896    /// A tombstone of a manifest kind: the shape that carries a restore window.
897    fn manifest_tomb(counter: u64, kind: RecordKind) -> Record {
898        let mut t = tomb(counter);
899        t.deleted_kind = Some(kind);
900        t
901    }
902
903    // The bug. A create over a manifest tombstone would overwrite it and
904    // discard the retained body, unpinning blobs that may be unregenerable.
905    #[test]
906    fn create_over_a_vector_manifest_tombstone_is_rejected() {
907        let t = manifest_tomb(3, RecordKind::VectorManifest);
908        assert_eq!(
909            plan_put(Some(&t), live(0, b"again", vec![]), None, 32),
910            PutPlan::Rejected(MANIFEST_TOMBSTONE_RECREATE_REJECTED)
911        );
912    }
913
914    #[test]
915    fn create_over_a_graph_manifest_tombstone_is_rejected() {
916        let t = manifest_tomb(3, RecordKind::GraphManifest);
917        assert_eq!(
918            plan_put(Some(&t), live(0, b"again", vec![]), None, 32),
919            PutPlan::Rejected(MANIFEST_TOMBSTONE_RECREATE_REJECTED)
920        );
921    }
922
923    // The guard is surgical: where no restore window exists, ADR 0021's
924    // recreation rule still applies unchanged.
925    #[test]
926    fn create_over_a_non_manifest_tombstone_still_recreates() {
927        let t = tomb(3);
928        assert_eq!(
929            t.deleted_kind, None,
930            "tomb() must stay a non-manifest tombstone"
931        );
932        let PutPlan::Write(stored) = plan_put(Some(&t), live(0, b"again", vec![]), None, 32) else {
933            panic!("a non-manifest tombstone must still be recreatable");
934        };
935        assert!(!stored.is_tombstone());
936        assert_eq!(stored.revision.counter, t.revision.counter + 1);
937    }
938
939    // A tombstone can carry a non-manifest `deleted_kind` (hand-written or
940    // replicated verbatim by `put_raw`); `tombstone_of` never produces one. GC
941    // pins nothing for it, so there is no restore window and no reason to refuse.
942    #[test]
943    fn create_over_a_tombstone_with_a_non_manifest_deleted_kind_still_recreates() {
944        let mut t = tomb(3);
945        t.deleted_kind = Some(RecordKind::Topic);
946        let PutPlan::Write(stored) = plan_put(Some(&t), live(0, b"again", vec![]), None, 32) else {
947            panic!("a non-manifest deleted_kind has no restore window to protect");
948        };
949        assert!(!stored.is_tombstone());
950        assert_eq!(stored.deleted_kind, None);
951    }
952
953    // The new arm must not swallow the stale-revision case.
954    #[test]
955    fn put_with_expected_over_a_manifest_tombstone_is_still_not_found() {
956        let t = manifest_tomb(3, RecordKind::VectorManifest);
957        let expected = t.revision.clone();
958        assert_eq!(
959            plan_put(Some(&t), live(0, b"x", vec![]), Some(expected), 32),
960            PutPlan::NotFound
961        );
962    }
963
964    // Review Focus 5: the guard reads `deleted_kind` and must never parse the
965    // body, so a corrupt window still produces a clean refusal.
966    #[test]
967    fn a_manifest_tombstone_with_an_unparseable_body_still_refuses_cleanly() {
968        let mut t = manifest_tomb(3, RecordKind::VectorManifest);
969        t.body = Body::Inline(b"not a manifest at all".to_vec());
970        assert_eq!(
971            plan_put(Some(&t), live(0, b"again", vec![]), None, 32),
972            PutPlan::Rejected(MANIFEST_TOMBSTONE_RECREATE_REJECTED)
973        );
974    }
975
976    // ---- plan_put_raw ----
977
978    // Review Focus 1: `undelete` is the recovery path this ticket protects, and
979    // it rides `plan_put_raw`. A guard in the wrong planner would break it while
980    // every other test here still passed.
981    #[test]
982    fn put_raw_over_a_manifest_tombstone_is_unaffected_by_the_guard() {
983        let t = manifest_tomb(3, RecordKind::VectorManifest);
984        let mut incoming = live(0, b"restored", vec![]);
985        incoming.revision = Revision {
986            counter: t.revision.counter + 1,
987            hash: ContentHash::of(b"restored"),
988        };
989        incoming.parent = Some(t.revision.clone());
990
991        // The restore shape: put_raw with the tombstone's own revision.
992        let PutPlan::Write(stored) = plan_put_raw(Some(&t), incoming, Some(t.revision.clone()), 32)
993        else {
994            panic!("put_raw must still write a restore over a manifest tombstone");
995        };
996        assert!(!stored.is_tombstone());
997        assert_eq!(stored.ancestors.first(), Some(&t.revision));
998    }
999
1000    #[test]
1001    fn put_raw_create_on_absent_writes_verbatim_including_tombstones() {
1002        let t = tomb(3);
1003        assert_eq!(plan_put_raw(None, t.clone(), None, 32), PutPlan::Write(t));
1004    }
1005
1006    #[test]
1007    fn put_raw_expected_on_absent_is_not_found() {
1008        assert_eq!(
1009            plan_put_raw(None, live(0, b"x", vec![]), Some(rev(0, b"x")), 32),
1010            PutPlan::NotFound
1011        );
1012    }
1013
1014    #[test]
1015    fn put_raw_create_over_tombstone_conflicts_carrying_it() {
1016        let t = tomb(3);
1017        match plan_put_raw(Some(&t), live(0, b"copy", vec![]), None, 32) {
1018            PutPlan::Conflict(c) => {
1019                assert!(c.current.is_tombstone());
1020                assert_eq!(c.current, t);
1021            }
1022            other => panic!("expected Conflict, got {other:?}"),
1023        }
1024    }
1025
1026    #[test]
1027    fn put_raw_overwrite_of_tombstone_never_restamps() {
1028        let t = tomb(3);
1029        let mut incoming = live(9, b"peer", vec![t.revision.clone()]);
1030        incoming.parent = Some(t.revision.clone());
1031        let PutPlan::Write(stored) =
1032            plan_put_raw(Some(&t), incoming.clone(), Some(t.revision.clone()), 32)
1033        else {
1034            panic!("expected Write");
1035        };
1036        assert_eq!(
1037            stored.revision, incoming.revision,
1038            "revision stored unchanged"
1039        );
1040        assert_eq!(stored.ancestors.first(), Some(&t.revision));
1041    }
1042
1043    #[test]
1044    fn put_raw_over_live_with_wrong_expected_conflicts() {
1045        let cur = live(0, b"v0", vec![]);
1046        assert!(matches!(
1047            plan_put_raw(Some(&cur), live(1, b"v1", vec![]), Some(rev(7, b"no")), 32),
1048            PutPlan::Conflict(_)
1049        ));
1050    }
1051
1052    // ---- plan_delete ----
1053
1054    #[test]
1055    fn delete_absent_is_noop() {
1056        assert_eq!(plan_delete(None, None, 1, 32, None), DeletePlan::Noop);
1057        assert_eq!(
1058            plan_delete(None, Some(rev(0, b"x")), 1, 32, None),
1059            DeletePlan::Noop
1060        );
1061    }
1062
1063    #[test]
1064    fn delete_tombstone_is_noop() {
1065        let t = tomb(2);
1066        assert_eq!(plan_delete(Some(&t), None, 1, 32, None), DeletePlan::Noop);
1067        assert_eq!(
1068            plan_delete(Some(&t), Some(rev(7, b"x")), 1, 32, None),
1069            DeletePlan::Noop
1070        );
1071    }
1072
1073    #[test]
1074    fn delete_live_writes_its_tombstone() {
1075        let cur = live(4, b"v", vec![]);
1076        let expected_tomb = tombstone_of(&cur, 77, 32, None);
1077        assert_eq!(
1078            plan_delete(Some(&cur), None, 77, 32, None),
1079            DeletePlan::Write(expected_tomb.clone())
1080        );
1081        assert_eq!(
1082            plan_delete(Some(&cur), Some(cur.revision.clone()), 77, 32, None),
1083            DeletePlan::Write(expected_tomb)
1084        );
1085    }
1086
1087    #[test]
1088    fn delete_live_with_stale_expected_conflicts() {
1089        let cur = live(4, b"v", vec![]);
1090        match plan_delete(Some(&cur), Some(rev(1, b"old")), 77, 32, None) {
1091            DeletePlan::Conflict(c) => assert_eq!(c.current, cur),
1092            other => panic!("expected Conflict, got {other:?}"),
1093        }
1094    }
1095
1096    // ---- plan_purge ----
1097
1098    #[test]
1099    fn purge_absent_is_noop() {
1100        assert_eq!(plan_purge(None, &rev(0, b"x")), PurgePlan::Noop);
1101    }
1102
1103    #[test]
1104    fn purge_matching_revision_removes() {
1105        let t = tomb(2);
1106        assert_eq!(plan_purge(Some(&t), &t.revision), PurgePlan::Remove);
1107    }
1108
1109    #[test]
1110    fn purge_after_recreation_conflicts() {
1111        let t = tomb(2);
1112        let PutPlan::Write(recreated) = plan_put(Some(&t), live(0, b"back", vec![]), None, 32)
1113        else {
1114            panic!("expected Write");
1115        };
1116        match plan_purge(Some(&recreated), &t.revision) {
1117            PurgePlan::Conflict(c) => {
1118                assert_eq!(c.current, recreated);
1119                assert_eq!(c.expected, Some(t.revision.clone()));
1120            }
1121            other => panic!("expected Conflict, got {other:?}"),
1122        }
1123    }
1124}
1125
1126/// Ancestors for a record that reconciles two diverged records `a` and `b`: a
1127/// sync or pull merge result, or a tombstone winner. Both revisions and both
1128/// ancestor lists are folded (spec §3.4), minus `stored`, sorted by
1129/// `(counter desc, hash desc)` and truncated to `cap`.
1130///
1131/// Sync passes a lossless cap (`a.ancestors.len() + b.ancestors.len() + 2`)
1132/// and lets each destination store's `plan_put_raw` truncate to its own cap on
1133/// write. Git pull writes the index directly and passes the store's cap.
1134pub fn reconciled_ancestors(
1135    stored: &Revision,
1136    a: &Record,
1137    b: &Record,
1138    cap: usize,
1139) -> Vec<Revision> {
1140    let mut incoming = Vec::with_capacity(a.ancestors.len() + b.ancestors.len() + 2);
1141    incoming.push(a.revision.clone());
1142    incoming.extend(a.ancestors.iter().cloned());
1143    incoming.push(b.revision.clone());
1144    incoming.extend(b.ancestors.iter().cloned());
1145    fold_ancestors(stored, &incoming, None, cap)
1146}
1147
1148/// Resolve two diverged tombstones for one key (spec §3.4, §3.5): the one with
1149/// the higher `(counter, hash)` wins and carries ancestors reconciled from both.
1150///
1151/// Symmetric whenever `a.revision != b.revision`. Callers skip equal revisions
1152/// before reaching this, because independent deletes of the same revision are
1153/// already in sync. Both arguments must be tombstones.
1154pub fn tombstone_winner(a: &Record, b: &Record, cap: usize) -> Record {
1155    debug_assert!(
1156        a.is_tombstone() && b.is_tombstone(),
1157        "tombstone_winner needs two tombstones"
1158    );
1159    let a_wins = (a.revision.counter, &a.revision.hash) >= (b.revision.counter, &b.revision.hash);
1160    let mut winner = if a_wins { a.clone() } else { b.clone() };
1161    winner.ancestors = reconciled_ancestors(&winner.revision, a, b, cap);
1162    winner
1163}
1164
1165/// The record reconciling two diverged live records `a` and `b` into merged
1166/// `body` (spec §3.4, §3.5): revision `max(counter) + 1` over `body`, parent =
1167/// the higher-counter side, labels and links unioned, both chains folded. On a
1168/// label-key collision `b` wins, and on a counter tie the parent is `a`. Sync
1169/// calls this with `(a, b)`; pull calls it with `(local, remote)`.
1170pub fn reconciled_record(
1171    a: &Record,
1172    b: &Record,
1173    body: Body,
1174    author: Identity,
1175    origin_system: &str,
1176    cap: usize,
1177) -> Record {
1178    let revision = Revision {
1179        counter: a.revision.counter.max(b.revision.counter) + 1,
1180        hash: ContentHash::of(body.bytes()),
1181    };
1182    let ancestors = reconciled_ancestors(&revision, a, b, cap);
1183    let mut labels = a.meta.labels.clone();
1184    labels.extend(b.meta.labels.clone());
1185    let mut links = a.links.clone();
1186    for l in &b.links {
1187        if !links.contains(l) {
1188            links.push(l.clone());
1189        }
1190    }
1191    let parent = if a.revision.counter >= b.revision.counter {
1192        a.revision.clone()
1193    } else {
1194        b.revision.clone()
1195    };
1196    Record {
1197        key: a.key.clone(),
1198        kind: a.kind,
1199        revision,
1200        parent: Some(parent),
1201        body,
1202        meta: Meta {
1203            author,
1204            origin_system: origin_system.into(),
1205            created: a.meta.created.min(b.meta.created),
1206            updated: a.meta.updated.max(b.meta.updated),
1207            labels,
1208        },
1209        links,
1210        ancestors,
1211        deleted_at: None,
1212        // A reconciled record is live, so it pins nothing: its own body is the
1213        // reference that keeps a blob alive (gonzalo#292).
1214        deleted_blob: None,
1215        deleted_kind: None,
1216    }
1217}
1218
1219#[cfg(test)]
1220mod reconcile_tests {
1221    use super::*;
1222    use crate::RecordKey;
1223    use std::collections::BTreeMap;
1224
1225    fn rev(counter: u64, tag: &str) -> Revision {
1226        Revision {
1227            counter,
1228            hash: ContentHash::of(tag.as_bytes()),
1229        }
1230    }
1231
1232    fn record(kind: RecordKind, revision: Revision, ancestors: Vec<Revision>) -> Record {
1233        Record {
1234            key: RecordKey::new("ns", "col", "k"),
1235            kind,
1236            revision,
1237            parent: None,
1238            body: Body::Inline(Vec::new()),
1239            meta: Meta {
1240                author: Identity::new("t"),
1241                origin_system: "test".into(),
1242                created: 0,
1243                updated: 0,
1244                labels: BTreeMap::new(),
1245            },
1246            links: Vec::new(),
1247            ancestors,
1248            deleted_at: None,
1249            deleted_blob: None,
1250            deleted_kind: None,
1251        }
1252    }
1253
1254    fn tomb(counter: u64, ancestors: Vec<Revision>) -> Record {
1255        let mut r = record(
1256            RecordKind::Tombstone,
1257            Revision {
1258                counter,
1259                hash: tombstone_hash(),
1260            },
1261            ancestors,
1262        );
1263        r.deleted_at = Some(1_000);
1264        r
1265    }
1266
1267    #[test]
1268    fn reconciled_ancestors_unions_both_revisions_and_both_chains() {
1269        let base = rev(0, "base");
1270        let ra = rev(1, "a");
1271        let rb = rev(1, "b");
1272        let a = record(RecordKind::Topic, ra.clone(), vec![base.clone()]);
1273        let b = record(RecordKind::Topic, rb.clone(), vec![base.clone()]);
1274        let merged = rev(2, "merged");
1275
1276        let got = reconciled_ancestors(&merged, &a, &b, DEFAULT_ANCESTOR_CAP);
1277
1278        // Same result as folding the four inputs by hand: same order rule.
1279        let expected = fold_ancestors(
1280            &merged,
1281            &[ra.clone(), base.clone(), rb.clone(), base.clone()],
1282            None,
1283            DEFAULT_ANCESTOR_CAP,
1284        );
1285        assert_eq!(got, expected);
1286        assert_eq!(got.len(), 3, "base is deduplicated");
1287        assert!(got.contains(&ra) && got.contains(&rb) && got.contains(&base));
1288    }
1289
1290    #[test]
1291    fn reconciled_ancestors_excludes_stored_and_truncates_to_cap() {
1292        let a = record(
1293            RecordKind::Topic,
1294            rev(3, "a3"),
1295            vec![rev(2, "a2"), rev(1, "a1")],
1296        );
1297        let b = record(RecordKind::Topic, rev(3, "b3"), vec![rev(2, "b2")]);
1298
1299        // Stored revision == a's revision (a sync writing a's winner back onto a).
1300        let got = reconciled_ancestors(&a.revision, &a, &b, 2);
1301
1302        assert_eq!(got.len(), 2);
1303        assert!(
1304            !got.contains(&a.revision),
1305            "own revision is never an ancestor"
1306        );
1307        assert_eq!(got[0], b.revision, "newest remaining entry comes first");
1308    }
1309
1310    #[test]
1311    fn tombstone_winner_takes_higher_counter_and_folds_both_chains() {
1312        let base = rev(0, "base");
1313        let r1 = rev(1, "r1");
1314        let low = tomb(1, vec![base.clone()]);
1315        let high = tomb(2, vec![r1.clone(), base.clone()]);
1316
1317        let w = tombstone_winner(&low, &high, DEFAULT_ANCESTOR_CAP);
1318
1319        assert!(w.is_tombstone());
1320        assert_eq!(w.revision, high.revision);
1321        assert_eq!(w.deleted_at, high.deleted_at);
1322        assert_eq!(w.ancestors.len(), 3);
1323        assert!(w.ancestors.contains(&low.revision));
1324        assert!(w.ancestors.contains(&r1));
1325        assert!(w.ancestors.contains(&base));
1326    }
1327
1328    #[test]
1329    fn tombstone_winner_is_symmetric() {
1330        let base = rev(0, "base");
1331        let low = tomb(1, vec![base.clone()]);
1332        let high = tomb(2, vec![rev(1, "r1"), base]);
1333        assert_eq!(
1334            tombstone_winner(&low, &high, DEFAULT_ANCESTOR_CAP),
1335            tombstone_winner(&high, &low, DEFAULT_ANCESTOR_CAP)
1336        );
1337    }
1338
1339    #[test]
1340    fn reconciled_record_folds_both_chains_and_picks_parent() {
1341        let mut a = record(RecordKind::Topic, rev(3, "a3"), vec![rev(2, "a2")]);
1342        a.meta.labels.insert("color".into(), "red".into());
1343        let mut b = record(RecordKind::Topic, rev(5, "b5"), vec![rev(4, "b4")]);
1344        b.meta.labels.insert("size".into(), "large".into());
1345
1346        let merged = reconciled_record(
1347            &a,
1348            &b,
1349            Body::Inline(b"merged-body".to_vec()),
1350            Identity::new("merger"),
1351            "test-origin",
1352            DEFAULT_ANCESTOR_CAP,
1353        );
1354
1355        // Revision counter is max(3, 5) + 1.
1356        assert_eq!(merged.revision.counter, 6);
1357        // b has the higher counter, so b's revision is the parent.
1358        assert_eq!(merged.parent, Some(b.revision.clone()));
1359        // Both chains are folded: a's and b's revisions and ancestors all appear.
1360        assert!(merged.ancestors.contains(&a.revision));
1361        assert!(merged.ancestors.contains(&rev(2, "a2")));
1362        assert!(merged.ancestors.contains(&b.revision));
1363        assert!(merged.ancestors.contains(&rev(4, "b4")));
1364        // Labels from both sides are unioned.
1365        assert_eq!(merged.meta.labels.get("color"), Some(&"red".to_string()));
1366        assert_eq!(merged.meta.labels.get("size"), Some(&"large".to_string()));
1367        assert_eq!(merged.meta.author, Identity::new("merger"));
1368        assert_eq!(merged.meta.origin_system, "test-origin");
1369        assert_eq!(merged.deleted_at, None);
1370    }
1371}