diff --git a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/small/DataSourceStorage.java b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/small/DataSourceStorage.java index 3cc4fa897a..16fae2704b 100644 --- a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/small/DataSourceStorage.java +++ b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/small/DataSourceStorage.java @@ -238,7 +238,7 @@ protected synchronized void createDataSourceNode(Long datasourceId, Long namespa Node node = new Node(); node.setId(datasourceId); node.setType(NodeTypeEnum.DATA_SOURCE.name()); - TreeNodeStorage.INSTANCE.updatePosition(dropToNode, node, 2); + TreeNodeStorage.INSTANCE.insertNode(dropToNode, node); } public void delete(Long id) { diff --git a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/small/NamespaceStorage.java b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/small/NamespaceStorage.java index 62715bc21b..5e254c237b 100644 --- a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/small/NamespaceStorage.java +++ b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/small/NamespaceStorage.java @@ -65,7 +65,7 @@ public Long save(Namespace namespace){ Node node = new Node(); node.setId(namespace.getId()); node.setType(NodeTypeEnum.NAMESPACE.name()); - TreeNodeStorage.INSTANCE.updatePosition(dropToNode, node, 2); + TreeNodeStorage.INSTANCE.insertNode(dropToNode, node); return id; } public void delete(Long id) { diff --git a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/small/TreeNodeStorage.java b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/small/TreeNodeStorage.java index 3d6e897a6f..3ac073e075 100644 --- a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/small/TreeNodeStorage.java +++ b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/small/TreeNodeStorage.java @@ -6,25 +6,21 @@ import ai.chat2db.community.tools.wrapper.result.ActionResult; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.filter.PropertyFilter; -import com.google.common.collect.Lists; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.collections4.MapUtils; import java.io.File; import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; -@Slf4j public class TreeNodeStorage extends SmallDataStorage { public static final TreeNodeStorage INSTANCE = new TreeNodeStorage(); protected TreeNodeStorage() { super("tree", TreeNode.class); - if (MapUtils.isEmpty(dataMap)) { - - } } TreeNodeStorage(File storageFile) { @@ -33,32 +29,41 @@ protected TreeNodeStorage() { public synchronized List getNodes() { List treeNodes = getDataList(); - if (treeNodes == null) { - return null; - } - if (CollectionUtils.isEmpty(treeNodes)) { - return Lists.newArrayList(); - } - return treeNodes.get(0).getChildren(); + return treeNodes.isEmpty() ? new ArrayList<>() : treeNodes.get(0).getChildren(); } public synchronized void createTree(List nodes) { - if (nodes == null) { - return; + if (nodes != null) { + persistTree(copyNodes(nodes)); } - PropertyFilter filter = (object, name, value) -> !"data".equals(name); - String json = JSON.toJSONString(nodes, filter); - List newNodes = JSON.parseArray(json, Node.class); + } + private void persistTree(List nodes) { List treeNodes = getDataList(); - if (CollectionUtils.isEmpty(treeNodes)) { - TreeNode treeNode = new TreeNode(); - treeNode.setChildren(newNodes); - save(treeNode); - } else { - treeNodes.get(0).setChildren(newNodes); - update(treeNodes.get(0)); + TreeNode replacement = new TreeNode(); + replacement.setId(treeNodes.isEmpty() ? generateId() : treeNodes.get(0).getId()); + replacement.setChildren(nodes); + Map persistedData = new TreeMap<>(dataMap); + persistedData.put(replacement.getId(), replacement); + saveDataList(new ArrayList<>(persistedData.values())); + dataMap.put(replacement.getId(), replacement); + } + + synchronized void insertNode(Node parentNode, Node newNode) { + if (newNode == null) { + return; } + List nodes = getNodes(); + List updatedNodes = nodes == null ? new ArrayList<>() : copyNodes(nodes); + if (findNode(updatedNodes, newNode) != null) { + return; + } + if (parentNode == null) { + updatedNodes.add(newNode); + } else if (!addNode(updatedNodes, parentNode, newNode, 2)) { + return; + } + createTree(updatedNodes); } @@ -66,97 +71,116 @@ public synchronized ActionResult updatePosition(Node dropToNode, Node dragNode, if (dragNode == null) { return ActionResult.isSuccess(); } - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } List nodes = getNodes(); if (nodes == null) { return ActionResult.isSuccess(); } - if (dropToNode == null) { - removeNode(nodes, dragNode, false); - nodes.add(dragNode); - createTree(nodes); + List updatedNodes = copyNodes(nodes); + Node sourceNode = findNode(updatedNodes, dragNode); + if (sourceNode == null) { + return ActionResult.isSuccess(); + } + Node targetNode = findNode(updatedNodes, dropToNode); + if (dropToNode != null && (targetNode == null || sameNode(sourceNode, targetNode) + || findNode(sourceNode.getChildren(), targetNode) != null)) { return ActionResult.isSuccess(); - } else { - removeNode(nodes, dragNode, false); - addNode(nodes, dropToNode, dragNode, dropPosition); - createTree(nodes); } + removeNode(updatedNodes, sourceNode, false); + if (targetNode == null) { + updatedNodes.add(sourceNode); + } else if (!addNode(updatedNodes, targetNode, sourceNode, dropPosition)) { + return ActionResult.isSuccess(); + } + persistTree(updatedNodes); return ActionResult.isSuccess(); } - private synchronized void removeNode(List nodes, Node dragNode, boolean deleteChildren) { + private List copyNodes(List nodes) { + PropertyFilter filter = (object, name, value) -> !"data".equals(name); + String json = JSON.toJSONString(nodes, filter); + return JSON.parseArray(json, Node.class); + } + + private Node findNode(List nodes, Node expected) { + if (CollectionUtils.isEmpty(nodes) || expected == null) { + return null; + } + for (Node node : nodes) { + if (sameNode(node, expected)) { + return node; + } + Node child = findNode(node.getChildren(), expected); + if (child != null) { + return child; + } + } + return null; + } + + private boolean sameNode(Node left, Node right) { + return left != null && right != null + && Objects.equals(left.getId(), right.getId()) + && Objects.equals(left.getType(), right.getType()); + } + + private boolean removeNode(List nodes, Node dragNode, boolean deleteChildren) { if (CollectionUtils.isEmpty(nodes)) { - return; + return false; } Iterator iterator = nodes.iterator(); - List tempList = new ArrayList<>(); while (iterator.hasNext()) { Node node = iterator.next(); - if (node.getId().equals(dragNode.getId()) && node.getType().equals(dragNode.getType())) { - if (NodeTypeEnum.NAMESPACE.name().equals(node.getType())) { - List c = node.getChildren(); - if (CollectionUtils.isNotEmpty(c)) { - tempList.addAll(c); - dragNode.setChildren(c); - } - } + if (sameNode(node, dragNode)) { iterator.remove(); - if (CollectionUtils.isNotEmpty(tempList) && deleteChildren) { - nodes.addAll(tempList); + if (deleteChildren && NodeTypeEnum.NAMESPACE.name().equals(node.getType()) + && CollectionUtils.isNotEmpty(node.getChildren())) { + nodes.addAll(node.getChildren()); } - return; + return true; + } + if (removeNode(node.getChildren(), dragNode, deleteChildren)) { + return true; } - removeNode(node.getChildren(), dragNode, deleteChildren); } + return false; } public synchronized ActionResult deleteNode(Node dragNode) { List nodes = getNodes(); - removeNode(nodes, dragNode, true); - createTree(nodes); + if (nodes == null || dragNode == null) { + return ActionResult.isSuccess(); + } + List updatedNodes = copyNodes(nodes); + if (!removeNode(updatedNodes, dragNode, true)) { + return ActionResult.isSuccess(); + } + persistTree(updatedNodes); return ActionResult.isSuccess(); } - private synchronized void addNode(List nodes, Node dropToNode, Node dragNode, Integer dropToGap) { + private boolean addNode(List nodes, Node dropToNode, Node dragNode, Integer dropToGap) { if (CollectionUtils.isEmpty(nodes)) { - return; - } - if (dropToNode.getId().equals(dragNode.getId()) && dropToNode.getType().equals(dragNode.getType())) { - return; + return false; } - int index = 0; - for (Node node : nodes) { - index++; - if (node.getId().equals(dropToNode.getId()) && node.getType().equals(dropToNode.getType())) { - if (dropToGap == 0) { - List children = node.getChildren(); - if (children == null) { - children = new ArrayList<>(); - } - children.add(0, dragNode); - node.setChildren(children); - return; - }else if (dropToGap == 2) { + for (int index = 0; index < nodes.size(); index++) { + Node node = nodes.get(index); + if (sameNode(node, dropToNode)) { + if (dropToGap == 0 || dropToGap == 2) { List children = node.getChildren(); if (children == null) { children = new ArrayList<>(); + node.setChildren(children); } - children.add(dragNode); - node.setChildren(children); - return; - }else if (dropToGap == 1) { - nodes.add(index, dragNode); - return; + children.add(dropToGap == 0 ? 0 : children.size(), dragNode); } else { - nodes.add(index - 1, dragNode); - return; + nodes.add(dropToGap == 1 ? index + 1 : index, dragNode); } + return true; + } + if (addNode(node.getChildren(), dropToNode, dragNode, dropToGap)) { + return true; } - addNode(node.getChildren(), dropToNode, dragNode, dropToGap); } + return false; } } diff --git a/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/small/TreeNodeCreationTest.java b/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/small/TreeNodeCreationTest.java new file mode 100644 index 0000000000..20e97034b3 --- /dev/null +++ b/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/small/TreeNodeCreationTest.java @@ -0,0 +1,77 @@ +package ai.chat2db.community.storage.small; + +import ai.chat2db.community.domain.api.enums.NodeTypeEnum; +import ai.chat2db.community.domain.api.model.storage.WorkspaceDataSource; +import ai.chat2db.community.domain.api.model.workspace.Namespace; +import ai.chat2db.community.domain.api.model.workspace.Node; +import ai.chat2db.community.storage.LocalWorkspaceStorage; +import ai.chat2db.community.storage.TestHome; +import ai.chat2db.community.storage.converter.StorageConverterImpl; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class TreeNodeCreationTest { + private final LocalWorkspaceStorage storage = new LocalWorkspaceStorage(new StorageConverterImpl()); + + @BeforeAll + static void isolatedHome() { TestHome.init(); } + + @BeforeEach + @AfterEach + void clearFixtures() { + TreeNodeStorage.INSTANCE.dataMap.clear(); + TreeNodeStorage.INSTANCE.saveDataList(); + NamespaceStorage.INSTANCE.dataMap.clear(); + NamespaceStorage.INSTANCE.saveDataList(); + DataSourceStorage.INSTANCE.dataMap.clear(); + DataSourceStorage.INSTANCE.saveDataList(); + } + + @Test + void newRootNamespaceIsVisibleInAlreadyPopulatedTree() { + long parentId = rootGroup(); + Namespace sibling = new Namespace(); + sibling.setName("review sibling"); + long siblingId = storage.createNamespace(sibling); + assertEquals(List.of(parentId, siblingId), storage.getTree().stream().map(Node::getId).toList()); + } + + @Test + void newChildNamespaceKeepsRequestedParent() { + long parentId = rootGroup(); + Namespace child = new Namespace(); + child.setName("review child"); + child.setParentId(parentId); + long childId = storage.createNamespace(child); + Node parent = storage.getTree().get(0); + assertEquals(parentId, parent.getId()); + assertNotNull(parent.getChildren(), "new child must be inserted into the persisted tree"); + assertEquals(List.of(childId), parent.getChildren().stream().map(Node::getId).toList()); + } + + @Test + void newDatasourceIsVisibleInRequestedGroup() { + long parentId = rootGroup(); + WorkspaceDataSource datasource = new WorkspaceDataSource(); + datasource.setAlias("review datasource"); + datasource.setSpaceId(parentId); + long id = storage.createDataSource(datasource); + assertNotNull(storage.queryDataSourceById(id, false), "datasource record was persisted"); + Node parent = storage.getTree().get(0); + assertNotNull(parent.getChildren(), "new datasource must be inserted into the persisted tree"); + assertEquals(List.of(id), parent.getChildren().stream().map(Node::getId).toList()); + assertEquals(NodeTypeEnum.DATA_SOURCE.name(), parent.getChildren().get(0).getType()); + } + + private long rootGroup() { + Namespace parent = new Namespace(); + parent.setName("review parent"); + long id = storage.createNamespace(parent); + assertEquals(List.of(id), storage.getTree().stream().map(Node::getId).toList()); + return id; + } +} diff --git a/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/small/TreeNodeStorageTest.java b/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/small/TreeNodeStorageTest.java index 3954a77d05..ea62001776 100644 --- a/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/small/TreeNodeStorageTest.java +++ b/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/small/TreeNodeStorageTest.java @@ -1,6 +1,9 @@ package ai.chat2db.community.storage.small; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -11,6 +14,7 @@ import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class TreeNodeStorageTest { @@ -65,6 +69,214 @@ void deleteLastNodePersistsAnEmptyTree() { assertTrue(newStorage("delete-last.json").getNodes().isEmpty()); } + @Test + void missingDropTargetPreservesSourceInMemoryAndOnReload() { + assertInvalidDropPreservesTree( + "missing-target.json", + dataSourceNode(999L), + dataSourceNode(3L)); + } + + @Test + void selfDropTargetPreservesSourceInMemoryAndOnReload() { + assertInvalidDropPreservesTree( + "self-target.json", + dataSourceNode(3L), + dataSourceNode(3L)); + } + + @Test + void descendantDropTargetPreservesSourceInMemoryAndOnReload() { + assertInvalidDropPreservesTree( + "descendant-target.json", + dataSourceNode(2L), + namespaceNode(1L, dataSourceNode(2L))); + } + + @Test + void validDropStillMovesSourceInMemoryAndOnReload() { + String fileName = "valid-target.json"; + TreeNodeStorage storage = newStorage(fileName); + storage.createTree(new ArrayList<>(List.of( + namespaceNode(1L, dataSourceNode(2L)), + dataSourceNode(3L)))); + List expected = List.of(namespaceNode(1L, dataSourceNode(2L), dataSourceNode(3L))); + + storage.updatePosition(namespaceNode(1L), dataSourceNode(3L), 2); + + assertEquals(expected, storage.getNodes()); + assertEquals(expected, newStorage(fileName).getNodes()); + } + + @Test + void failedMovePersistenceKeepsInMemoryAndPersistedTree() throws Exception { + String fileName = "failed-move.json"; + File storageFile = new File(tempDir, fileName); + List expected = List.of( + namespaceNode(1L, dataSourceNode(2L)), + dataSourceNode(3L)); + TreeNodeStorage initial = new TreeNodeStorage(storageFile); + initial.createTree(new ArrayList<>(expected)); + String originalFile = Files.readString(storageFile.toPath()); + TreeNodeStorage failing = new FailingTreeNodeStorage(storageFile); + + assertThrows(RuntimeException.class, + () -> failing.updatePosition(namespaceNode(1L), dataSourceNode(3L), 2)); + + assertEquals(expected, failing.getNodes(), "failed replace must not publish the candidate tree in memory"); + assertEquals(originalFile, Files.readString(storageFile.toPath()), + "failed replace must leave the persisted file untouched"); + assertEquals(expected, new TreeNodeStorage(storageFile).getNodes()); + } + + @Test + void failedDeletePersistenceKeepsInMemoryAndPersistedTree() throws Exception { + String fileName = "failed-delete.json"; + File storageFile = new File(tempDir, fileName); + List expected = List.of( + namespaceNode(1L, dataSourceNode(2L)), + dataSourceNode(3L)); + TreeNodeStorage initial = new TreeNodeStorage(storageFile); + initial.createTree(new ArrayList<>(expected)); + String originalFile = Files.readString(storageFile.toPath()); + TreeNodeStorage failing = new FailingTreeNodeStorage(storageFile); + + assertThrows(RuntimeException.class, () -> failing.deleteNode(namespaceNode(1L))); + + assertEquals(expected, failing.getNodes(), "failed replace must not publish the candidate deletion in memory"); + assertEquals(originalFile, Files.readString(storageFile.toPath()), + "failed replace must leave the persisted file untouched"); + assertEquals(expected, new TreeNodeStorage(storageFile).getNodes()); + } + + @Test + void firstInsertionPersistsWithoutRetainingCallerOrHydratedData() { + TreeNodeStorage storage = newStorage("first-insert.json"); + Node node = dataSourceNode(1L); + node.setData("hydrated metadata"); + + storage.insertNode(null, node); + node.setId(99L); + + List expected = List.of(dataSourceNode(1L)); + assertEquals(expected, storage.getNodes()); + assertEquals(expected, newStorage("first-insert.json").getNodes()); + } + + @Test + void duplicateInsertionDoesNotDuplicateOrRelocateExistingNode() { + TreeNodeStorage storage = newStorage("duplicate-insert.json"); + List expected = List.of(namespaceNode(1L, dataSourceNode(2L))); + storage.createTree(expected); + + storage.insertNode(null, dataSourceNode(2L)); + + assertEquals(expected, storage.getNodes()); + assertEquals(expected, newStorage("duplicate-insert.json").getNodes()); + } + + @Test + void movingMissingSourceDoesNotInsertIt() throws Exception { + String fileName = "missing-source.json"; + TreeNodeStorage storage = newStorage(fileName); + List expected = List.of(dataSourceNode(1L)); + storage.createTree(expected); + Path path = new File(tempDir, fileName).toPath(); + String originalFile = Files.readString(path); + + storage.updatePosition(null, dataSourceNode(99L), 2); + + assertEquals(expected, storage.getNodes()); + assertEquals(originalFile, Files.readString(path)); + } + + @Test + void failedFirstInsertionDoesNotPublishTree() throws Exception { + File storageFile = new File(tempDir, "failed-first-insert.json"); + TreeNodeStorage failing = new FailingTreeNodeStorage(storageFile); + String originalFile = Files.readString(storageFile.toPath()); + + assertThrows(RuntimeException.class, () -> failing.insertNode(null, dataSourceNode(1L))); + + assertTrue(failing.getNodes().isEmpty()); + assertEquals(originalFile, Files.readString(storageFile.toPath())); + assertTrue(new TreeNodeStorage(storageFile).getNodes().isEmpty()); + } + + @Test + void failedNestedInsertionKeepsMemoryAndPersistedTree() throws Exception { + File storageFile = new File(tempDir, "failed-nested-insert.json"); + List expected = List.of(namespaceNode(1L, dataSourceNode(2L))); + new TreeNodeStorage(storageFile).createTree(expected); + String originalFile = Files.readString(storageFile.toPath()); + TreeNodeStorage failing = new FailingTreeNodeStorage(storageFile); + + assertThrows(RuntimeException.class, + () -> failing.insertNode(namespaceNode(1L), dataSourceNode(3L))); + + assertEquals(expected, failing.getNodes()); + assertEquals(originalFile, Files.readString(storageFile.toPath())); + assertEquals(expected, new TreeNodeStorage(storageFile).getNodes()); + } + + @Test + void moveKeepsEachSupportedDropPosition() { + List> expected = List.of( + List.of(dataSourceNode(3L), namespaceNode(1L, dataSourceNode(2L))), + List.of(namespaceNode(1L, dataSourceNode(3L), dataSourceNode(2L))), + List.of(namespaceNode(1L, dataSourceNode(2L)), dataSourceNode(3L)), + List.of(namespaceNode(1L, dataSourceNode(2L), dataSourceNode(3L)))); + for (int position = -1; position <= 2; position++) { + String fileName = "position-" + position + ".json"; + TreeNodeStorage storage = newStorage(fileName); + storage.createTree(List.of(namespaceNode(1L, dataSourceNode(2L)), dataSourceNode(3L))); + + storage.updatePosition(namespaceNode(1L), dataSourceNode(3L), position); + + assertEquals(expected.get(position + 1), storage.getNodes()); + assertEquals(expected.get(position + 1), newStorage(fileName).getNodes()); + } + } + + @Test + void movingNamespacePreservesStoredChildrenWithAnIdOnlyRequest() { + TreeNodeStorage storage = newStorage("namespace-move.json"); + storage.createTree(List.of(namespaceNode(1L, dataSourceNode(2L)), namespaceNode(3L))); + + storage.updatePosition(namespaceNode(3L), namespaceNode(1L), 0); + + List expected = List.of(namespaceNode(3L, namespaceNode(1L, dataSourceNode(2L)))); + assertEquals(expected, storage.getNodes()); + assertEquals(expected, newStorage("namespace-move.json").getNodes()); + } + + @Test + void deletingNamespacePromotesCompleteChildrenAfterRemainingSiblings() { + TreeNodeStorage storage = newStorage("promote-children.json"); + storage.createTree(List.of(namespaceNode(1L, namespaceNode(2L, dataSourceNode(3L)), + dataSourceNode(5L)), namespaceNode(4L))); + + storage.deleteNode(namespaceNode(1L)); + + List expected = List.of(namespaceNode(4L), namespaceNode(2L, dataSourceNode(3L)), + dataSourceNode(5L)); + assertEquals(expected, storage.getNodes()); + assertEquals(expected, newStorage("promote-children.json").getNodes()); + } + + private void assertInvalidDropPreservesTree(String fileName, Node dropToNode, Node dragNode) { + TreeNodeStorage storage = newStorage(fileName); + List expected = List.of( + namespaceNode(1L, dataSourceNode(2L)), + dataSourceNode(3L)); + storage.createTree(new ArrayList<>(expected)); + + storage.updatePosition(dropToNode, dragNode, 0); + + assertEquals(expected, storage.getNodes(), "invalid drop must preserve the in-memory tree"); + assertEquals(expected, newStorage(fileName).getNodes(), "invalid drop must preserve the persisted tree"); + } + private TreeNodeStorage newStorage(String fileName) { return new TreeNodeStorage(new File(tempDir, fileName)); } @@ -72,4 +284,24 @@ private TreeNodeStorage newStorage(String fileName) { private static Node dataSourceNode(Long id) { return Node.builder().id(id).type(NodeTypeEnum.DATA_SOURCE.name()).build(); } + + private static Node namespaceNode(Long id, Node... children) { + return Node.builder() + .id(id) + .type(NodeTypeEnum.NAMESPACE.name()) + .children(new ArrayList<>(List.of(children))) + .build(); + } + + private static final class FailingTreeNodeStorage extends TreeNodeStorage { + + private FailingTreeNodeStorage(File storageFile) { + super(storageFile); + } + + @Override + protected void replaceStorageFile(Path temp, Path target) throws IOException { + throw new IOException("forced replace failure"); + } + } }