diff --git a/src/internal/m365/collection/drive/delta_tree.go b/src/internal/m365/collection/drive/delta_tree.go index 9c553f0c2..3d4acf97c 100644 --- a/src/internal/m365/collection/drive/delta_tree.go +++ b/src/internal/m365/collection/drive/delta_tree.go @@ -1,8 +1,13 @@ package drive import ( + "context" "time" + "github.com/alcionai/clues" + + odConsts "github.com/alcionai/corso/src/internal/m365/service/onedrive/consts" + "github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/path" ) @@ -15,8 +20,8 @@ type folderyMcFolderFace struct { prefix path.Path // the root of the tree; - // new, moved, and notMoved collections - collections *nodeyMcNodeFace + // new, moved, and notMoved root + root *nodeyMcNodeFace // the majority of operations we perform can be handled with // a folder ID lookup instead of re-walking the entire tree. @@ -24,8 +29,9 @@ type folderyMcFolderFace struct { folderIDToNode map[string]*nodeyMcNodeFace // tombstones don't need to form a tree. - // we only need the folder ID and their previous path. - tombstones map[string]path.Path + // We maintain the node data in case we swap back + // and forth between live and tombstoned states. + tombstones map[string]*nodeyMcNodeFace // it's just a sensible place to store the data, since we're // already pushing file additions through the api. @@ -38,7 +44,7 @@ func newFolderyMcFolderFace( return &folderyMcFolderFace{ prefix: prefix, folderIDToNode: map[string]*nodeyMcNodeFace{}, - tombstones: map[string]path.Path{}, + tombstones: map[string]*nodeyMcNodeFace{}, excludeFileIDs: map[string]struct{}{}, } } @@ -53,9 +59,9 @@ type nodeyMcNodeFace struct { // single directory name, not a path name string // only contains the folders starting at and including '/root:' - prev path.Path + prev path.Elements // map folderID -> node - childDirs map[string]*nodeyMcNodeFace + children map[string]*nodeyMcNodeFace // items are keyed by item ID items map[string]time.Time // for special handling protocols around packages @@ -65,7 +71,7 @@ type nodeyMcNodeFace struct { func newNodeyMcNodeFace( parent *nodeyMcNodeFace, id, name string, - prev path.Path, + prev path.Elements, isPackage bool, ) *nodeyMcNodeFace { return &nodeyMcNodeFace{ @@ -73,8 +79,176 @@ func newNodeyMcNodeFace( id: id, name: name, prev: prev, - childDirs: map[string]*nodeyMcNodeFace{}, + children: map[string]*nodeyMcNodeFace{}, items: map[string]time.Time{}, isPackage: isPackage, } } + +// --------------------------------------------------------------------------- +// folder handling +// --------------------------------------------------------------------------- + +// SetFolder adds a node with the following details to the tree. +// If the node already exists with the given ID, the name and parent +// values are updated to match (isPackage is assumed not to change). +func (face *folderyMcFolderFace) SetFolder( + ctx context.Context, + parentID, id, name string, + isPackage bool, +) error { + // need to ensure we have the minimum requirements met for adding a node. + if len(id) == 0 { + return clues.NewWC(ctx, "missing folder ID") + } + + if len(name) == 0 { + return clues.NewWC(ctx, "missing folder name") + } + + // drive doesn't normally allow the `:` character in folder names. + // so `root:` is, by default, the only folder that can match this + // name. That makes this check a little bit brittle, but generally + // reliable, since we should always see the root first and can rely + // on the naming structure. + if len(parentID) == 0 && name != odConsts.RootPathDir { + return clues.NewWC(ctx, "non-root folder missing parent id") + } + + // only set the root node once. + if name == odConsts.RootPathDir { + if face.root == nil { + root := newNodeyMcNodeFace(nil, id, name, nil, isPackage) + face.root = root + face.folderIDToNode[id] = root + } + + return nil + } + + // There are four possible changes that can happen at this point. + // 1. new folder addition. + // 2. duplicate folder addition. + // 3. existing folder migrated to new location. + // 4. tombstoned folder restored. + + parent, ok := face.folderIDToNode[parentID] + if !ok { + return clues.NewWC(ctx, "folder added before parent") + } + + // Handling case 4 is exclusive to 1-3. IE: we ensure tree state such + // that a node's previous appearance can be either a tombstone or + // a live node, but not both. So if we find a tombstone, we assume + // there is not also a node in the live tree for this id. + + // if a folder is deleted and restored, we'll get both the deletion marker + // (which should be first in enumeration, since all deletion markers are first, + // or it would have happened in one of the prior enumerations), followed by + // the restoration of the folder. + if zombey, tombstoned := face.tombstones[id]; tombstoned { + delete(face.tombstones, id) + + zombey.parent = parent + zombey.name = name + parent.children[id] = zombey + face.folderIDToNode[id] = zombey + + return nil + } + + // if not previously a tombstone, handle change cases 1-3 + var ( + nodey *nodeyMcNodeFace + visited bool + ) + + // change type 2 & 3. Update the existing node details to match current data. + if nodey, visited = face.folderIDToNode[id]; visited { + if nodey.parent == nil { + // technically shouldn't be possible but better to keep the problem tracked + // just in case. + logger.Ctx(ctx).Info("non-root folder already exists with no parent ref") + } else if nodey.parent != parent { + // change type 3. we need to ensure the old parent stops pointing to this node. + delete(nodey.parent.children, id) + } + + nodey.name = name + nodey.parent = parent + } else { + // change type 1: new addition + // the previous location is always nil, since previous path additions get their + // own setter func. + nodey = newNodeyMcNodeFace(parent, id, name, nil, isPackage) + } + + // ensure the parent points to this node, and that the node is registered + // in the map of all nodes in the tree. + parent.children[id] = nodey + face.folderIDToNode[id] = nodey + + return nil +} + +func (face *folderyMcFolderFace) SetTombstone( + ctx context.Context, + id string, + loc path.Elements, +) error { + if len(id) == 0 { + return clues.NewWC(ctx, "missing tombstone folder ID") + } + + if len(loc) == 0 { + return clues.NewWC(ctx, "missing tombstone location") + } + + // since we run mutiple enumerations, it's possible to see a folder added on the + // first enumeration that then gets deleted on the next. This means that the folder + // was deleted while the first enumeration was in flight, and will show up even if + // the folder gets restored after deletion. + // When this happens, we have to adjust the original tree accordingly. + if zombey, stillKicking := face.folderIDToNode[id]; stillKicking { + if zombey.parent != nil { + delete(zombey.parent.children, id) + } + + delete(face.folderIDToNode, id) + + zombey.parent = nil + face.tombstones[id] = zombey + + // this handling is exclusive to updating an already-existing tombstone. + // ie: if we find a living node in the tree, we assume there is no tombstone + // entry with the same ID. + return nil + } + + zombey, alreadyBuried := face.tombstones[id] + if alreadyBuried { + if zombey.prev.String() != loc.String() { + // logging for sanity + logger.Ctx(ctx).Infow( + "attempted to tombstone two paths with the same ID", + "first_tombstone_path", zombey.prev, + "second_tombstone_path", loc) + } + + // since we're storing drive data by folder name in kopia, not id, we need + // to make sure to preserve the original tombstone location. If we get a + // conflicting set of locations in the same delta enumeration, we can always + // treat the original one as the canonical one. IE: what we're deleting is + // the original location as it exists in kopia. So even if we get a newer + // location in the drive enumeration, the original location is the one that + // kopia uses, and the one we need to tombstone. + // + // this should also be asserted in the second step, where we compare the delta + // changes to the backup previous paths metadata. + face.tombstones[id] = zombey + } else { + face.tombstones[id] = newNodeyMcNodeFace(nil, id, "", loc, false) + } + + return nil +} diff --git a/src/internal/m365/collection/drive/delta_tree_test.go b/src/internal/m365/collection/drive/delta_tree_test.go index 0a0931ec6..2197f3763 100644 --- a/src/internal/m365/collection/drive/delta_tree_test.go +++ b/src/internal/m365/collection/drive/delta_tree_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + odConsts "github.com/alcionai/corso/src/internal/m365/service/onedrive/consts" "github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/pkg/path" ) @@ -30,7 +31,7 @@ func (suite *DeltaTreeUnitSuite) TestNewFolderyMcFolderFace() { folderFace := newFolderyMcFolderFace(p) assert.Equal(t, p, folderFace.prefix) - assert.Nil(t, folderFace.collections) + assert.Nil(t, folderFace.root) assert.NotNil(t, folderFace.folderIDToNode) assert.NotNil(t, folderFace.tombstones) assert.NotNil(t, folderFace.excludeFileIDs) @@ -40,17 +41,657 @@ func (suite *DeltaTreeUnitSuite) TestNewNodeyMcNodeFace() { var ( t = suite.T() parent = &nodeyMcNodeFace{} - p, err = path.Build("t", "r", path.SharePointService, path.LibrariesCategory, false, "drive-id", "root:") + loc = path.NewElements("root:/foo/bar/baz/qux/fnords/smarf/voi/zumba/bangles/howdyhowdyhowdy") ) - require.NoError(t, err, clues.ToCore(err)) - - nodeFace := newNodeyMcNodeFace(parent, "id", "name", p, true) + nodeFace := newNodeyMcNodeFace(parent, "id", "name", loc, true) assert.Equal(t, parent, nodeFace.parent) assert.Equal(t, "id", nodeFace.id) assert.Equal(t, "name", nodeFace.name) - assert.Equal(t, p, nodeFace.prev) + assert.Equal(t, loc, nodeFace.prev) assert.True(t, nodeFace.isPackage) - assert.NotNil(t, nodeFace.childDirs) + assert.NotNil(t, nodeFace.children) assert.NotNil(t, nodeFace.items) } + +// note that this test is focused on the SetFolder function, +// and intentionally does not verify the resulting node tree +func (suite *DeltaTreeUnitSuite) TestFolderyMcFolderFace_SetFolder() { + treeWithRoot := func() *folderyMcFolderFace { + rootey := newNodeyMcNodeFace(nil, odConsts.RootID, odConsts.RootPathDir, nil, false) + tree := newFolderyMcFolderFace(nil) + tree.root = rootey + tree.folderIDToNode[odConsts.RootID] = rootey + + return tree + } + + treeWithTombstone := func() *folderyMcFolderFace { + tree := treeWithRoot() + tree.tombstones["folder"] = newNodeyMcNodeFace(nil, "folder", "", path.NewElements(""), false) + + return tree + } + + treeWithFolders := func() *folderyMcFolderFace { + tree := treeWithRoot() + + o := newNodeyMcNodeFace(tree.root, "other", "o", nil, true) + tree.folderIDToNode[o.id] = o + + f := newNodeyMcNodeFace(o, "folder", "f", nil, false) + tree.folderIDToNode[f.id] = f + o.children[f.id] = f + + return tree + } + + table := []struct { + tname string + tree *folderyMcFolderFace + parentID string + id string + name string + isPackage bool + expectErr assert.ErrorAssertionFunc + expectPrev assert.ValueAssertionFunc + }{ + { + tname: "add root", + tree: &folderyMcFolderFace{ + folderIDToNode: map[string]*nodeyMcNodeFace{}, + }, + id: odConsts.RootID, + name: odConsts.RootPathDir, + isPackage: true, + expectErr: assert.NoError, + expectPrev: assert.Nil, + }, + { + tname: "root already exists", + tree: treeWithRoot(), + id: odConsts.RootID, + name: odConsts.RootPathDir, + expectErr: assert.NoError, + expectPrev: assert.Nil, + }, + { + tname: "add folder", + tree: treeWithRoot(), + parentID: odConsts.RootID, + id: "folder", + name: "nameyMcNameFace", + expectErr: assert.NoError, + expectPrev: assert.Nil, + }, + { + tname: "add package", + tree: treeWithRoot(), + parentID: odConsts.RootID, + id: "folder", + name: "nameyMcNameFace", + isPackage: true, + expectErr: assert.NoError, + expectPrev: assert.Nil, + }, + { + tname: "missing ID", + tree: treeWithRoot(), + parentID: odConsts.RootID, + name: "nameyMcNameFace", + isPackage: true, + expectErr: assert.Error, + expectPrev: assert.Nil, + }, + { + tname: "missing name", + tree: treeWithRoot(), + parentID: odConsts.RootID, + id: "folder", + isPackage: true, + expectErr: assert.Error, + expectPrev: assert.Nil, + }, + { + tname: "missing parentID", + tree: treeWithRoot(), + id: "folder", + name: "nameyMcNameFace", + isPackage: true, + expectErr: assert.Error, + expectPrev: assert.Nil, + }, + { + tname: "already tombstoned", + tree: treeWithTombstone(), + parentID: odConsts.RootID, + id: "folder", + name: "nameyMcNameFace", + expectErr: assert.NoError, + expectPrev: assert.NotNil, + }, + { + tname: "add folder before parent", + tree: &folderyMcFolderFace{ + folderIDToNode: map[string]*nodeyMcNodeFace{}, + }, + parentID: odConsts.RootID, + id: "folder", + name: "nameyMcNameFace", + isPackage: true, + expectErr: assert.Error, + expectPrev: assert.Nil, + }, + { + tname: "folder already exists", + tree: treeWithFolders(), + parentID: "other", + id: "folder", + name: "nameyMcNameFace", + expectErr: assert.NoError, + expectPrev: assert.Nil, + }, + } + for _, test := range table { + suite.Run(test.tname, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + err := test.tree.SetFolder( + ctx, + test.parentID, + test.id, + test.name, + test.isPackage) + test.expectErr(t, err, clues.ToCore(err)) + + if err != nil { + return + } + + result := test.tree.folderIDToNode[test.id] + require.NotNil(t, result) + test.expectPrev(t, result.prev) + assert.Equal(t, test.id, result.id) + assert.Equal(t, test.name, result.name) + assert.Equal(t, test.isPackage, result.isPackage) + + _, ded := test.tree.tombstones[test.id] + assert.False(t, ded) + + if len(test.parentID) > 0 { + parent := test.tree.folderIDToNode[test.parentID] + assert.Equal(t, parent, result.parent) + } + }) + } +} + +func (suite *DeltaTreeUnitSuite) TestFolderyMcFolderFace_AddTombstone() { + loc := path.NewElements("root:/foo/bar/baz/qux/fnords/smarf/voi/zumba/bangles/howdyhowdyhowdy") + treeWithTombstone := func() *folderyMcFolderFace { + tree := newFolderyMcFolderFace(nil) + tree.tombstones["id"] = newNodeyMcNodeFace(nil, "id", "", loc, false) + + return tree + } + + table := []struct { + name string + id string + loc path.Elements + tree *folderyMcFolderFace + expectErr assert.ErrorAssertionFunc + }{ + { + name: "add tombstone", + id: "id", + loc: loc, + tree: newFolderyMcFolderFace(nil), + expectErr: assert.NoError, + }, + { + name: "duplicate tombstone", + id: "id", + loc: loc, + tree: treeWithTombstone(), + expectErr: assert.NoError, + }, + { + name: "missing ID", + loc: loc, + tree: newFolderyMcFolderFace(nil), + expectErr: assert.Error, + }, + { + name: "missing loc", + id: "id", + tree: newFolderyMcFolderFace(nil), + expectErr: assert.Error, + }, + { + name: "empty loc", + id: "id", + loc: path.Elements{}, + tree: newFolderyMcFolderFace(nil), + expectErr: assert.Error, + }, + { + name: "conflict: folder alive", + id: "id", + loc: loc, + tree: treeWithTombstone(), + expectErr: assert.NoError, + }, + { + name: "already tombstoned with different path", + id: "id", + loc: append(path.Elements{"foo"}, loc...), + tree: treeWithTombstone(), + expectErr: assert.NoError, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + err := test.tree.SetTombstone(ctx, test.id, test.loc) + test.expectErr(t, err, clues.ToCore(err)) + + if err != nil { + return + } + + result := test.tree.tombstones[test.id] + require.NotNil(t, result) + require.NotEmpty(t, result.prev) + assert.Equal(t, loc, result.prev) + }) + } +} + +// --------------------------------------------------------------------------- +// tree structure assertions tests +// --------------------------------------------------------------------------- + +type assertNode struct { + self *nodeyMcNodeFace + children []assertNode +} + +func an( + self *nodeyMcNodeFace, + children ...assertNode, +) assertNode { + return assertNode{ + self: self, + children: children, + } +} + +func (an assertNode) compare( + t *testing.T, + tree *folderyMcFolderFace, + checkLiveNodeCount bool, +) { + var nodeCount int + + t.Run("assert_tree_shape/root", func(_t *testing.T) { + nodeCount = compareNodes(_t, tree.root, an) + }) + + if checkLiveNodeCount { + require.Len(t, tree.folderIDToNode, nodeCount, "total count of live nodes") + } +} + +func compareNodes( + t *testing.T, + node *nodeyMcNodeFace, + expect assertNode, +) int { + // ensure the nodes match + require.NotNil(t, node, "node does not exist in tree") + require.Equal( + t, + expect.self, + node, + "non-matching node") + + // ensure the node has the expected number of children + assert.Len( + t, + node.children, + len(expect.children), + "node has expected number of children") + + var nodeCount int + + for _, expectChild := range expect.children { + t.Run(expectChild.self.id, func(_t *testing.T) { + nodeChild := node.children[expectChild.self.id] + require.NotNilf( + _t, + nodeChild, + "child must exist with id %q", + expectChild.self.id) + + // ensure each child points to the current node as its parent + assert.Equal( + _t, + node, + nodeChild.parent, + "should point to correct parent") + + // recurse + nodeCount += compareNodes(_t, nodeChild, expectChild) + }) + } + + return nodeCount + 1 +} + +type tombs []assertNode + +func entomb(nodes ...assertNode) tombs { + if len(nodes) == 0 { + return tombs{} + } + + return append(tombs{}, nodes...) +} + +func (ts tombs) compare( + t *testing.T, + tombstones map[string]*nodeyMcNodeFace, +) { + require.Len(t, tombstones, len(ts), "count of tombstoned nodes") + + for _, entombed := range ts { + zombey := tombstones[entombed.self.id] + require.NotNil(t, zombey, "tombstone must exist") + assert.Nil(t, zombey.parent, "tombstoned node should not have a parent reference") + + t.Run("assert_tombstones/"+zombey.id, func(_t *testing.T) { + compareNodes(_t, zombey, entombed) + }) + } +} + +// unlike the prior test, this focuses entirely on whether or not the +// tree produced by folder additions is correct. +func (suite *DeltaTreeUnitSuite) TestFolderyMcFolderFace_SetFolder_correctTree() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + tree := newFolderyMcFolderFace(nil) + rootID := odConsts.RootID + + set := func( + parentID, id, name string, + isPackage bool, + ) { + err := tree.SetFolder(ctx, parentID, id, name, isPackage) + require.NoError(t, err, clues.ToCore(err)) + } + + assert.Nil(t, tree.root) + assert.Empty(t, tree.folderIDToNode) + + // add the root + set("", rootID, odConsts.RootPathDir, false) + + assert.NotNil(t, tree.root) + assert.Equal(t, rootID, tree.root.id) + assert.Equal(t, rootID, tree.folderIDToNode[rootID].id) + + an(tree.root).compare(t, tree, true) + + // add a child at the root + set(tree.root.id, "lefty", "l", false) + + lefty := tree.folderIDToNode["lefty"] + an( + tree.root, + an(lefty)). + compare(t, tree, true) + + // add another child at the root + set(tree.root.id, "righty", "r", false) + + righty := tree.folderIDToNode["righty"] + an( + tree.root, + an(lefty), + an(righty)). + compare(t, tree, true) + + // add a child to lefty + set(lefty.id, "bloaty", "bl", false) + + bloaty := tree.folderIDToNode["bloaty"] + an( + tree.root, + an(lefty, an(bloaty)), + an(righty)). + compare(t, tree, true) + + // add another child to lefty + set(lefty.id, "brightly", "br", false) + + brightly := tree.folderIDToNode["brightly"] + an( + tree.root, + an(lefty, an(bloaty), an(brightly)), + an(righty)). + compare(t, tree, true) + + // relocate brightly underneath righty + set(righty.id, brightly.id, brightly.name, false) + + an( + tree.root, + an(lefty, an(bloaty)), + an(righty, an(brightly))). + compare(t, tree, true) + + // relocate righty and subtree beneath lefty + set(lefty.id, righty.id, righty.name, false) + + an( + tree.root, + an( + lefty, + an(bloaty), + an(righty, an(brightly)))). + compare(t, tree, true) +} + +// this test focuses on whether the tree is correct when bouncing back and forth +// between live and tombstoned states on the same folder +func (suite *DeltaTreeUnitSuite) TestFolderyMcFolderFace_SetFolder_correctTombstones() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + tree := newFolderyMcFolderFace(nil) + rootID := odConsts.RootID + + set := func( + parentID, id, name string, + isPackage bool, + ) { + err := tree.SetFolder(ctx, parentID, id, name, isPackage) + require.NoError(t, err, clues.ToCore(err)) + } + + tomb := func( + id string, + loc path.Elements, + ) { + err := tree.SetTombstone(ctx, id, loc) + require.NoError(t, err, clues.ToCore(err)) + } + + assert.Nil(t, tree.root) + assert.Empty(t, tree.folderIDToNode) + + // create a simple tree + // root > branchy > [leafy, bob] + set("", rootID, odConsts.RootPathDir, false) + + set(tree.root.id, "branchy", "br", false) + branchy := tree.folderIDToNode["branchy"] + + set(branchy.id, "leafy", "l", false) + set(branchy.id, "bob", "bobbers", false) + + leafy := tree.folderIDToNode["leafy"] + bob := tree.folderIDToNode["bob"] + + an( + tree.root, + an( + branchy, + an(leafy), + an(bob))). + compare(t, tree, true) + + entomb().compare(t, tree.tombstones) + + var ( + branchyLoc = path.NewElements("root/branchy") + leafyLoc = path.NewElements("root/branchy/leafy") + bobLoc = path.NewElements("root/branchy/bob") + ) + + // tombstone bob + tomb(bob.id, bobLoc) + an( + tree.root, + an(branchy, an(leafy))). + compare(t, tree, true) + + entomb(an(bob)).compare(t, tree.tombstones) + + // tombstone leafy + tomb(leafy.id, leafyLoc) + an( + tree.root, + an(branchy)). + compare(t, tree, true) + + entomb(an(bob), an(leafy)).compare(t, tree.tombstones) + + // resurrect leafy + set(branchy.id, leafy.id, leafy.name, false) + + an( + tree.root, + an(branchy, an(leafy))). + compare(t, tree, true) + + entomb(an(bob)).compare(t, tree.tombstones) + + // resurrect bob + set(branchy.id, bob.id, bob.name, false) + + an( + tree.root, + an( + branchy, + an(leafy), + an(bob))). + compare(t, tree, true) + + entomb().compare(t, tree.tombstones) + + // tombstone branchy + tomb(branchy.id, branchyLoc) + + an(tree.root).compare(t, tree, false) + // note: the folder count here *will be wrong*. + // since we've only tombstoned branchy, both leafy + // and bob will remain in the folderIDToNode map. + // If this were real graph behavior, the delete would + // necessarily cascade and those children would get + // tombstoned next. + // So we skip the check here, just to minimize code. + // It's safe to do so, for the scope of this test. + // This should be part of the consideration for prev- + // path iteration that could create improper state in + // the post-processing stage if we're nott careful. + + entomb( + an( + branchy, + an(leafy), + an(bob))). + compare(t, tree.tombstones) + + // resurrect branchy + set(tree.root.id, branchy.id, branchy.name, false) + + an( + tree.root, + an( + branchy, + an(leafy), + an(bob))). + compare(t, tree, true) + + entomb().compare(t, tree.tombstones) + + // tombstone branchy + tomb(branchy.id, branchyLoc) + + an(tree.root).compare(t, tree, false) + + entomb( + an( + branchy, + an(leafy), + an(bob))). + compare(t, tree.tombstones) + + // tombstone bob + tomb(bob.id, bobLoc) + + an(tree.root).compare(t, tree, false) + + entomb( + an(branchy, an(leafy)), + an(bob)). + compare(t, tree.tombstones) + + // resurrect branchy + set(tree.root.id, branchy.id, branchy.name, false) + + an( + tree.root, + an(branchy, an(leafy))). + compare(t, tree, false) + + entomb(an(bob)).compare(t, tree.tombstones) + + // resurrect bob + set(branchy.id, bob.id, bob.name, false) + + an( + tree.root, + an( + branchy, + an(leafy), + an(bob))). + compare(t, tree, true) + + entomb().compare(t, tree.tombstones) +}