1use crate::{
7 Body, ContentHash, CoreError, Identity, Meta, Record, RecordKind, Result, Revision,
8 store::Conflict,
9};
10
11pub const DEFAULT_ANCESTOR_CAP: usize = 32;
14
15pub const TOMBSTONE_DOMAIN: &[u8] = b"gonzalo:tombstone:v1";
19
20pub fn tombstone_hash() -> ContentHash {
22 ContentHash::of(TOMBSTONE_DOMAIN)
23}
24
25pub 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
36pub 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
45pub 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 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
71pub 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 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 deleted_blob: match ¤t.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#[derive(Clone, Debug, PartialEq, Eq)]
132#[allow(clippy::large_enum_variant)]
135pub enum PutPlan {
136 Write(Record),
140 Conflict(Box<Conflict>),
142 NotFound,
144 Rejected(&'static str),
151}
152
153pub const CONSUMER_TOMBSTONE_REJECTED: &str =
156 "consumer put cannot write a tombstone; use delete_as";
157
158pub 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
167pub 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 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 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 record.deleted_blob = None;
236 record.deleted_kind = None;
239 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 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
269pub 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#[derive(Clone, Debug, PartialEq, Eq)]
307#[allow(clippy::large_enum_variant)]
310pub enum DeletePlan {
311 Write(Record),
313 Noop,
315 Conflict(Box<Conflict>),
317}
318
319pub 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#[derive(Clone, Debug, PartialEq, Eq)]
345pub enum PurgePlan {
346 Remove,
348 Noop,
350 Conflict(Box<Conflict>),
352}
353
354pub 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 const NOW: i64 = 1_700_000_000_000;
377
378 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 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 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 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(¤t),
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 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(¤t, 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 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 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 assert_eq!(t.meta.updated, 1_234);
595 assert!(t.links.is_empty());
596 assert_eq!(t.deleted_blob, None);
598 }
599
600 #[test]
601 fn tombstone_of_pins_the_deleted_records_blob() {
602 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 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 #[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 #[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 #[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 #[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 #[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 #[test]
776 fn put_create_on_absent_writes() {
777 let rec = live(0, b"new", vec![]);
778 let mut stamped = rec.clone();
779 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 assert_eq!(stored.deleted_blob, None);
837 }
838
839 #[test]
840 fn recreating_over_a_tombstone_clears_deleted_kind() {
841 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 assert_eq!(
860 plan_put(None, tombstone_record.clone(), None, 32),
861 PutPlan::Rejected(CONSUMER_TOMBSTONE_REJECTED)
862 );
863
864 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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
1126pub 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
1148pub 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
1165pub 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 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 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 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 assert_eq!(merged.revision.counter, 6);
1357 assert_eq!(merged.parent, Some(b.revision.clone()));
1359 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 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}