Repository: yorkie-team/yorkie-js-sdk
Pull request #982: Implement Split, Merge in json layer of Tree
Author: login: JOOHOJANG
Created: 2025-04-24T08:44:03Z | Merged: 2025-04-30T01:06:40Z

<!--  Thanks for sending a pull request! -->


#### What this PR does / why we need it?

Created a Split/Merge interface for editor support.

This is not an implementation of internal CRDT behavior yet—it's the stage where split/merge is applied by separating it into insert/delete operations.


#### Any background context you want to provide?


#### What are the relevant tickets?

<!--

*Automatically closes linked issue when PR is merged.

Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.

-->

Fixes #

### Checklist

- [x] Added relevant tests or not required
- [x] Addressed and resolved all CodeRabbit review comments
- [x] Didn't break anything

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added the ability to split and merge nodes in the tree structure at specified paths.
  - Introduced a method to retrieve the concatenated text of all text-type children of a node.
  - Provided a way to convert a path to a tree position in the CRDT-based tree.

- **Tests**
  - Added integration tests to verify the correct behavior of split and merge operations on tree nodes, including synchronization across clients.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/packages/sdk/src/document/crdt/tree.ts b/packages/sdk/src/document/crdt/tree.ts
index 3f5734e31..a7e097a58 100644
--- a/packages/sdk/src/document/crdt/tree.ts
+++ b/packages/sdk/src/document/crdt/tree.ts
@@ -1321,6 +1321,15 @@ export class CRDTTree extends CRDTElement implements GCParent {
     );
   }
 
+  /**
+   * `pathToTreePos` converts the given path of the node to the TreePos.
+   */
+  public pathToTreePos(
+    path: Array<number>,
+  ): ReturnType<typeof this.indexTree.pathToTreePos> {
+    return this.indexTree.pathToTreePos(path);
+  }
+
   /**
    * `purge` physically purges the given node.
    */
diff --git a/packages/sdk/src/document/json/tree.ts b/packages/sdk/src/document/json/tree.ts
index 393398313..418d566b2 100644
--- a/packages/sdk/src/document/json/tree.ts
+++ b/packages/sdk/src/document/json/tree.ts
@@ -27,9 +27,14 @@ import {
   IndexTree,
   DefaultRootType,
   DefaultTextType,
+  TreePos,
 } from '@yorkie-js/sdk/src/util/index_tree';
 import { TreeEditOperation } from '@yorkie-js/sdk/src/document/operation/tree_edit_operation';
-import { isEmpty, stringifyObjectValues } from '@yorkie-js/sdk/src/util/object';
+import {
+  isEmpty,
+  parseObjectValues,
+  stringifyObjectValues,
+} from '@yorkie-js/sdk/src/util/object';
 import { RHT } from '../crdt/rht';
 import { TreeStyleOperation } from '../operation/tree_style_operation';
 import type {
@@ -59,6 +64,135 @@ export {
   CRDTTreeNodeIDStruct,
 };
 
+/**
+ * `toTreeNode` returns tree node from CRDTTreeNode.
+ */
+function toTreeNode(node: CRDTTreeNode): TreeNode {
+  if (node.isText) {
+    return {
+      type: node.type,
+      value: node.value,
+    } as TextNode;
+  }
+  const newNode = {
+    type: node.type,
+    children: node.children.map((child) => toTreeNode(child)),
+  } as ElementNode;
+
+  if (node.attrs) {
+    newNode.attributes = parseObjectValues(node.attrs.toObject());
+  }
+
+  return newNode;
+}
+
+/**
+ * `createSplitNode` returns new node which is split from the given node.
+ */
+function createSplitNode(
+  node: CRDTTreeNode,
+  offset: number,
+): ElementNode | undefined {
+  const newNode: ElementNode = {
+    type: node.isText ? node.parent!.type : node.type,
+    children: node.isText
+      ? node.parent!.getChildrenText().length === offset
+        ? []
+        : [
+            {
+              type: DefaultTextType,
+              value: node.parent!.getChildrenText().slice(offset),
+            },
+          ]
+      : node.children.slice(offset).map((child) => toTreeNode(child)),
+  };
+
+  if (node.attrs) {
+    newNode.attributes = parseObjectValues(node.attrs.toObject());
+  } else if (node.isText && node.parent!.attrs) {
+    newNode.attributes = parseObjectValues(node.parent!.attrs.toObject());
+  }
+
+  return newNode;
+}
+
+/**
+ * `separateSplit` separates the split operation into insert and delete operations.
+ */
+function separateSplit(
+  treePos: TreePos<CRDTTreeNode>,
+  path: Array<number>,
+): Array<{
+  fromPath: Array<number>;
+  toPath: Array<number>;
+  content?: TreeNode;
+}> {
+  const { node } = treePos;
+  const parentPath = [...path].slice(0, -1);
+  const last = node.isText
+    ? node.parent!.getChildrenText().length
+    : node.children.length;
+  const toPath = [...parentPath, last];
+  const insertPath = [...parentPath];
+  const res = [];
+
+  insertPath[insertPath.length - 1] += 1;
+
+  if (!path.every((v, i) => v === toPath[i])) {
+    res.push({ fromPath: [...path], toPath });
+  }
+
+  const newNode = createSplitNode(node, path[path.length - 1]);
+
+  if (newNode) {
+    res.push({ fromPath: insertPath, toPath: insertPath, content: newNode });
+  }
+
+  return res;
+}
+
+/**
+ * `separateMerge` separates the merge operation into insert and delete operations.
+ */
+function separateMerge(
+  treePos: TreePos<CRDTTreeNode>,
+  path: Array<number>,
+): Array<{
+  fromPath: Array<number>;
+  toPath: Array<number>;
+  content?: Array<TreeNode>;
+}> {
+  const { node: parentNode, offset } = treePos;
+  const node = parentNode.children[offset];
+  const leftSiblingNode = parentNode.children[offset - 1];
+  const { children } = node;
+  const parentPath = [...path].slice(0, -1);
+  const res: ReturnType<typeof separateMerge> = [
+    { fromPath: [...path], toPath: [...parentPath, offset + 1] },
+  ];
+
+  if (!node.children.length) {
+    return res;
+  }
+
+  const insertPath = [
+    ...parentPath,
+    offset - 1,
+    leftSiblingNode.hasTextChild()
+      ? leftSiblingNode.getChildrenText().length
+      : leftSiblingNode.children.length,
+  ];
+  const nodes = children.map((child) => toTreeNode(child));
+
+  res.push({
+    fromPath: insertPath,
+    toPath: insertPath,
+    content: nodes,
+  });
+
+  return res;
+}
+
 /**
  * `buildDescendants` builds descendants of the given tree node.
  */
@@ -293,6 +427,74 @@ export class Tree {
     return this.tree.getIndexTree();
   }
 
+  /**
+   * `splitByPath` splits the tree by the given path.
+   */
+  public splitByPath(path: Array<number>) {
+    if (!this.context || !this.tree) {
+      throw new YorkieError(
+        Code.ErrNotInitialized,
+        'Tree is not initialized yet',
+      );
+    }
+
+    if (!path.length) {
+      throw new YorkieError(
+        Code.ErrInvalidArgument,
+        'path should not be empty',
+      );
+    }
+
+    const treePos = this.tree.pathToTreePos(path);
+    const commands = separateSplit(treePos, path);
+
+    for (const command of commands) {
+      const { fromPath, toPath, content } = command;
+      const fromPos = this.tree!.pathToPos(fromPath);
+      const toPos = this.tree!.pathToPos(toPath);
+
+      this.editInternal(fromPos, toPos, content ? [content] : [], 0);
+    }
+  }
+
+  /**
+   * `mergeByPath` merges the tree by the given path.
+   */
+  public mergeByPath(path: Array<number>) {
+    if (!this.context || !this.tree) {
+      throw new YorkieError(
+        Code.ErrNotInitialized,
+        'Tree is not initialized yet',
+      );
+    }
+
+    if (!path.length) {
+      throw new YorkieError(
+        Code.ErrInvalidArgument,
+        'path should not be empty',
+      );
+    }
+
+    const treePos = this.tree.pathToTreePos(path);
+
+    if (treePos.node.isText) {
+      throw new YorkieError(
+        Code.ErrInvalidArgument,
+        'text node cannot be merged',
+      );
+    }
+
+    const commands = separateMerge(treePos, path);
+
+    for (const command of commands) {
+      const { fromPath, toPath, content } = command;
+      const fromPos = this.tree!.pathToPos(fromPath);
+      const toPos = this.tree!.pathToPos(toPath);
+
+      this.editInternal(fromPos, toPos, content ?? [], 0);
+    }
+  }
+
   /**
    * `styleByPath` sets the attributes to the elements of the given path.
    */
diff --git a/packages/sdk/src/util/index_tree.ts b/packages/sdk/src/util/index_tree.ts
index 97c960615..4c4ea194d 100644
--- a/packages/sdk/src/util/index_tree.ts
+++ b/packages/sdk/src/util/index_tree.ts
@@ -299,6 +299,19 @@ export abstract class IndexTreeNode<T extends IndexTreeNode<T>> {
     );
   }
 
+  /**
+   * `getChildrenText` returns text value of all text type children.
+   */
+  getChildrenText(): string {
+    if (this.isText) {
+      return this.value;
+    }
+    if (this.hasTextChild()) {
+      return this.children.map((child) => child.value).join('');
+    }
+    return '';
+  }
+
   /**
    * `append` appends the given nodes to the children.
    */
diff --git a/packages/sdk/test/integration/tree_test.ts b/packages/sdk/test/integration/tree_test.ts
index 978ba9804..bb92857ed 100644
--- a/packages/sdk/test/integration/tree_test.ts
+++ b/packages/sdk/test/integration/tree_test.ts
@@ -509,6 +509,278 @@ describe('Tree', () => {
     });
   });
 
+  it('Can edit its content by split', function ({ task }) {
+    const key = toDocKey(`${task.name}-${new Date().getTime()}`);
+    const doc = new yorkie.Document<{ t: Tree }>(key);
+
+    doc.update((root) => {
+      root.t = new Tree({
+        type: 'doc',
+        children: [
+          {
+            type: 'tc',
+            children: [
+              {
+                type: 'p',
+                children: [
+                  {
+                    type: 'tn',
+                    children: [{ type: 'text', value: '1234' }],
+                  },
+                ],
+              },
+              {
+                type: 'p',
+                children: [
+                  {
+                    type: 'tn',
+                    children: [{ type: 'text', value: '5678' }],
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+      });
+
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>1234</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+
+      root.t.splitByPath([0, 0, 0, 2]);
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12</tn><tn>34</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+
+      root.t.splitByPath([0, 0, 1]);
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12</tn></p><p><tn>34</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+
+      root.t.splitByPath([0, 2, 0, 4]);
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12</tn></p><p><tn>34</tn></p><p><tn>5678</tn><tn></tn></p></tc></doc>`,
+      );
+
+      root.t.splitByPath([0, 2, 1]);
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12</tn></p><p><tn>34</tn></p><p><tn>5678</tn></p><p><tn></tn></p></tc></doc>`,
+      );
+    });
+  });
+
+  it('Can edit its content by merge', function ({ task }) {
+    const key = toDocKey(`${task.name}-${new Date().getTime()}`);
+    const doc = new yorkie.Document<{ t: Tree }>(key);
+
+    doc.update((root) => {
+      root.t = new Tree({
+        type: 'doc',
+        children: [
+          {
+            type: 'tc',
+            children: [
+              {
+                type: 'p',
+                children: [
+                  {
+                    type: 'tn',
+                    children: [{ type: 'text', value: '1234' }],
+                  },
+                ],
+              },
+              {
+                type: 'p',
+                children: [
+                  {
+                    type: 'tn',
+                    children: [{ type: 'text', value: '5678' }],
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+      });
+
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>1234</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+
+      root.t.mergeByPath([0, 1]);
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>1234</tn><tn>5678</tn></p></tc></doc>`,
+      );
+
+      root.t.mergeByPath([0, 0, 1]);
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12345678</tn></p></tc></doc>`,
+      );
+    });
+  });
+
+  it('Can sync its split with other clients', async function ({ task }) {
+    await withTwoClientsAndDocuments<{ t: Tree }>(async (c1, d1, c2, d2) => {
+      d1.update((root) => {
+        root.t = new Tree({
+          type: 'doc',
+          children: [
+            {
+              type: 'tc',
+              children: [
+                {
+                  type: 'p',
+                  children: [
+                    {
+                      type: 'tn',
+                      children: [{ type: 'text', value: '1234' }],
+                    },
+                  ],
+                },
+                {
+                  type: 'p',
+                  children: [
+                    {
+                      type: 'tn',
+                      children: [{ type: 'text', value: '5678' }],
+                    },
+                  ],
+                },
+              ],
+            },
+          ],
+        });
+      });
+      await c1.sync();
+      await c2.sync();
+      assert.equal(
+        d1.getRoot().t.toXML(),
+        /*html*/ `<doc><tc><p><tn>1234</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+      assert.equal(
+        d2.getRoot().t.toXML(),
+        /*html*/ `<doc><tc><p><tn>1234</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+
+      d1.update((root) => {
+        root.t.splitByPath([0, 0, 0, 2]);
+      });
+
+      await c1.sync();
+      await c2.sync();
+
+      assert.equal(
+        d1.getRoot().t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12</tn><tn>34</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+      assert.equal(
+        d2.getRoot().t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12</tn><tn>34</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+
+      d1.update((root) => {
+        root.t.splitByPath([0, 0, 1]);
+      });
+
+      await c1.sync();
+      await c2.sync();
+
+      assert.equal(
+        d1.getRoot().t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12</tn></p><p><tn>34</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+      assert.equal(
+        d2.getRoot().t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12</tn></p><p><tn>34</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+    }, task.name);
+  });
+
+  it('Can sync its merge with other clients', async function ({ task }) {
+    await withTwoClientsAndDocuments<{ t: Tree }>(async (c1, d1, c2, d2) => {
+      d1.update((root) => {
+        root.t = new Tree({
+          type: 'doc',
+          children: [
+            {
+              type: 'tc',
+              children: [
+                {
+                  type: 'p',
+                  children: [
+                    {
+                      type: 'tn',
+                      children: [{ type: 'text', value: '1234' }],
+                    },
+                  ],
+                },
+                {
+                  type: 'p',
+                  children: [
+                    {
+                      type: 'tn',
+                      children: [{ type: 'text', value: '5678' }],
+                    },
+                  ],
+                },
+              ],
+            },
+          ],
+        });
+      });
+      await c1.sync();
+      await c2.sync();
+      assert.equal(
+        d1.getRoot().t.toXML(),
+        /*html*/ `<doc><tc><p><tn>1234</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+      assert.equal(
+        d2.getRoot().t.toXML(),
+        /*html*/ `<doc><tc><p><tn>1234</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+
+      d1.update((root) => {
+        root.t.mergeByPath([0, 1]);
+      });
+
+      await c1.sync();
+      await c2.sync();
+
+      assert.equal(
+        d1.getRoot().t.toXML(),
+        /*html*/ `<doc><tc><p><tn>1234</tn><tn>5678</tn></p></tc></doc>`,
+      );
+      assert.equal(
+        d2.getRoot().t.toXML(),
+        /*html*/ `<doc><tc><p><tn>1234</tn><tn>5678</tn></p></tc></doc>`,
+      );
+
+      d1.update((root) => {
+        root.t.mergeByPath([0, 0, 1]);
+      });
+
+      await c1.sync();
+      await c2.sync();
+
+      assert.equal(
+        d1.getRoot().t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12345678</tn></p></tc></doc>`,
+      );
+      assert.equal(
+        d2.getRoot().t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12345678</tn></p></tc></doc>`,
+      );
+    }, task.name);
+  });
+
   it('Can sync its content with other clients', async function ({ task }) {
     await withTwoClientsAndDocuments<{ t: Tree }>(async (c1, d1, c2, d2) => {
       d1.update((root) => {


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 5**

<details>
<summary>🧹 Nitpick comments (5)</summary><blockquote>

<details>
<summary>packages/sdk/test/integration/tree_test.ts (3)</summary><blockquote>

`551-556`: **Verify that the input validation for splitByPath is thorough.**

The test confirms splitting at position 2 works, but there's no test for invalid input parameters. Consider adding tests for:
- Invalid path parameters (out of bounds)
- Edge cases like splitting at position 0
- Path validation for nested structures

This would ensure the robustness of the implementation.

```js
// Example test for invalid path
root.t.splitByPath([0, 0, 0, 10]); // Path beyond text length
assert.Throw(() => {
  root.t.splitByPath([999, 0, 0]);
}, "invalid path");
```

---

`616-620`: **Test mergeByPath with additional edge cases.**

The test verifies basic merging functionality, but consider adding tests for:
- Attempting to merge non-adjacent nodes
- Merging nodes with different structures
- Error handling for invalid paths
- Merging nodes with styling attributes

These additional test cases would provide more thorough coverage.

---

`512-628`: **Tests are missing for undo/redo functionality with split and merge operations.**

The test suite thoroughly tests the functionality of split and merge operations, but doesn't verify how these operations interact with an undo/redo system if one exists. Consider adding tests for:

- Undoing a split operation
- Undoing a merge operation
- Redoing splits and merges
- Sequences of operations (split-merge-split)

This would ensure proper integration with document history functionality.

</blockquote></details>
<details>
<summary>packages/sdk/src/document/json/tree.ts (2)</summary><blockquote>

`461-494`: **`mergeByPath` shares the same return-type & index-shift concerns as `splitByPath`**

Mirror the previous suggestion: expose a `boolean` return value and ensure path
or position recalculation between edits to guard against tree mutations.

---

`70-87`: **Minor: `toTreeNode` may throw on malformed attribute JSON; consider safe parsing**

`parseObjectValues` uses `JSON.parse` per attribute; if any attribute was not
stringified (e.g., manually edited), `JSON.parse` will raise and abort the whole
conversion.  Wrapping the parse in `try / catch` (and either skipping the bad
key or propagating a `YorkieError` with more context) would improve robustness.

_No immediate breakage, but worth hardening._

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 27da8f9b0e436660c1bdee5792e0488708b42cb2 and 5fba3018ad42e943e7f2527f7ace8ceff5702df7.

</details>

<details>
<summary>📒 Files selected for processing (4)</summary>

* `packages/sdk/src/document/crdt/tree.ts` (1 hunks)
* `packages/sdk/src/document/json/tree.ts` (3 hunks)
* `packages/sdk/src/util/index_tree.ts` (1 hunks)
* `packages/sdk/test/integration/tree_test.ts` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (2)</summary>

<details>
<summary>packages/sdk/test/integration/tree_test.ts (2)</summary><blockquote>

<details>
<summary>packages/sdk/test/integration/integration_helper.ts (1)</summary>

* `toDocKey` (30-35)

</details>
<details>
<summary>packages/sdk/src/document/json/tree.ts (1)</summary>

* `Tree` (336-1008)

</details>

</blockquote></details>
<details>
<summary>packages/sdk/src/document/json/tree.ts (4)</summary><blockquote>

<details>
<summary>packages/sdk/src/document/crdt/tree.ts (4)</summary>

* `CRDTTreeNode` (431-709)
* `TreeNode` (52-52)
* `TextNode` (66-69)
* `ElementNode` (57-61)

</details>
<details>
<summary>packages/sdk/src/util/object.ts (1)</summary>

* `parseObjectValues` (58-66)

</details>
<details>
<summary>packages/sdk/src/util/index_tree.ts (3)</summary>

* `DefaultTextType` (87-87)
* `TreePos` (530-533)
* `children` (278-283)

</details>
<details>
<summary>packages/sdk/src/util/error.ts (1)</summary>

* `YorkieError` (79-90)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: build (18.x)

</details>

<details>
<summary>🔇 Additional comments (6)</summary><blockquote>

<details>
<summary>packages/sdk/src/util/index_tree.ts (1)</summary>

`302-313`: **Approve getChildrenText implementation**

The new `getChildrenText` method accurately handles:
- Text nodes by returning `this.value`.  
- Nodes whose children are exclusively text by concatenating their `value`s.  
- All other cases by returning an empty string.  

This utility aligns well with the split/merge logic that needs direct-text extraction.

</details>
<details>
<summary>packages/sdk/src/document/crdt/tree.ts (1)</summary>

`1289-1297`: **Approve CRDTTree.pathToTreePos passthrough**

The `pathToTreePos` method cleanly exposes `indexTree.pathToTreePos` on `CRDTTree`, enabling higher-level JSON operations (split/merge) to translate a user-friendly path array into the internal `TreePos`. Error behavior and naming are consistent with existing APIs.

</details>
<details>
<summary>packages/sdk/test/integration/tree_test.ts (4)</summary>

`512-575`: **New test case verifies the splitByPath functionality.**

This test case thoroughly checks the new `splitByPath` method across different scenarios:
- Splitting a text node at a specific position (line 551)
- Splitting at a node boundary to create a new paragraph (line 557) 
- Handling edge cases like splitting at the end of text (line 563)
- Splitting an empty node (line 569)

The assertions correctly verify the resulting XML structure after each split operation.

---

`577-628`: **New test case verifies the mergeByPath functionality.**

This test case validates the new `mergeByPath` method which seems to be the counterpart to `splitByPath`:
- Merging two separate paragraphs (line 616), resulting in the second paragraph's content becoming a child of the first
- Merging text nodes within a paragraph (line 622), correctly concatenating their text content

The assertions are clear and verify the expected XML structure after each operation.

---

`552-554`: **The splitByPath implementation handles text nodes cleanly.**

The test demonstrates that splitting text nodes correctly divides the content while maintaining the structure. The path-based approach is a clean way to target specific locations in the tree.

---

`564-566`: **Verify that splitting at text end creates proper empty nodes.**

When splitting at the end of a text node (line 563), an empty text node is inserted. This is the correct behavior as it allows for future content to be inserted at that position, maintaining the document structure.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-24T08:49:00Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>♻️ Duplicate comments (4)</summary><blockquote>

<details>
<summary>packages/sdk/src/document/json/tree.ts (4)</summary><blockquote>

`89-117`: **`createSplittedNode` lacks boundary checks and may create empty nodes or throw runtime errors.**

This function has several issues that need to be addressed:

1. No validation that `offset` is within bounds (could be negative or too large)
2. Creates empty nodes when offset equals text length (`node.parent!.getChildrenText().length === offset ? []`)
3. Uses non-null assertion on `node.parent!` which could fail for root-level text nodes
4. Reuses parent type which may be incorrect in nested structures


Apply the defensive implementation suggested in the previous review:

```diff
function createSplittedNode(
   node: CRDTTreeNode,
   offset: number,
 ): ElementNode | undefined {
-  const newNode: ElementNode = {
-    type: node.isText ? node.parent!.type : node.type,
-    children: node.isText
-      ? node.parent!.getChildrenText().length === offset
-        ? []
-        : [
-            {
-              type: DefaultTextType,
-              value: node.parent!.getChildrenText().slice(offset),
-            } as TextNode,
-          ]
-      : node.children.slice(offset).map((child) => toTreeNode(child)),
-  };
+  /* --- guard clauses ---------------------------------------------------- */
+  // root-level text, or invalid offset
+  if (node.isText && !node.parent) return;
+
+  const parentText = node.isText
+    ? node.parent!.getChildrenText()
+    : undefined;
+
+  if (node.isText && (offset <= 0 || offset >= parentText!.length)) {
+    // nothing to split ‑- avoid inserting empty elements
+    return;
+  }
+
+  if (!node.isText && (offset <= 0 || offset >= node.children.length)) {
+    return;
+  }
+
+  /* --- build the cloned (right) node ------------------------------------ */
+  const newNode: ElementNode = {
+    type: node.isText ? node.parent!.type : node.type,
+    children: node.isText
+      ? [
+          {
+            type: DefaultTextType,
+            value: parentText!.slice(offset),
+          } as TextNode,
+        ]
+      : node.children.slice(offset).map((child) => toTreeNode(child)),
+  };
```

---

`119-152`: **`separateSplit` fails for root-level paths and lacks offset validation.**

The implementation has several issues:

1. When `parentPath` is empty (for operations near the root), `insertPath[insertPath.length - 1] += 1` tries to modify a non-existent array element
2. No validation that the offset is within valid bounds for the node
3. No handling of empty result from `createSplittedNode`


Fix these issues with proper validation:

```diff
function separateSplit(
  treePos: TreePos<CRDTTreeNode>,
  path: Array<number>,
): Array<{
  fromPath: Array<number>;
  toPath: Array<number>;
  content?: TreeNode;
}> {
  const { node } = treePos;
  const parentPath = [...path].slice(0, -1);
  const last = node.isText
    ? node.parent!.getChildrenText().length
    : node.children.length;
  const toPath = [...parentPath, last];
-  const insertPath = [...parentPath];
-  const res = [];
-
-  insertPath[insertPath.length - 1] += 1;
+  if (parentPath.length === 0) {
+    throw new YorkieError(
+      Code.ErrInvalidArgument,
+      'Cannot split the root element',
+    );
+  }
+
+  const insertPath = [...parentPath];
+  insertPath[insertPath.length - 1] += 1;
+
+  if (path[path.length - 1] < 0 || path[path.length - 1] > last) {
+    throw new YorkieError(
+      Code.ErrInvalidArgument,
+      `Split offset (${path[path.length - 1]}) is out of bounds`,
+    );
+  }
+
+  const res = [];

  if (!path.every((v, i) => v === toPath[i])) {
    res.push({ fromPath: [...path], toPath });
  }

  const newNode = createSplittedNode(node, path[path.length - 1]);

  if (newNode) {
    res.push({ fromPath: insertPath, toPath: insertPath, content: newNode });
  }

  return res;
}
```

---

`154-194`: **`separateMerge` crashes when merging the first child and lacks bounds checks.**

The implementation directly accesses array elements without proper validation:

1. Directly uses `parentNode.children[offset]` and `parentNode.children[offset - 1]` without checking bounds
2. Will crash with undefined access when trying to merge the first child (`offset - 1` becomes -1)
3. No appropriate error message for invalid merge attempts


Add bounds checking to prevent crashes:

```diff
function separateMerge(
  treePos: TreePos<CRDTTreeNode>,
  path: Array<number>,
): Array<{
  fromPath: Array<number>;
  toPath: Array<number>;
  content?: Array<TreeNode>;
}> {
  const { node: parentNode, offset } = treePos;
+  if (offset <= 0 || offset >= parentNode.children.length) {
+    throw new YorkieError(
+      Code.ErrInvalidArgument,
+      'mergeByPath requires a node with a left sibling',
+    );
+  }
+
  const node = parentNode.children[offset];
  const leftSiblingNode = parentNode.children[offset - 1];
  const { children } = node;
  // Rest of the function...
```

---

`430-458`: **`splitByPath` does not propagate the success flag and may apply commands to a mutated tree.**

The method has two significant issues:

1. Return type is implicit `void` while `editInternal` returns `boolean`, so callers cannot determine if the split succeeded
2. All commands are calculated upfront, but each edit mutates the tree, potentially invalidating subsequent path-to-position conversions


Improve the implementation to handle failures and recalculate paths:

```diff
-  public splitByPath(path: Array<number>) {
+  public splitByPath(path: Array<number>): boolean {
      // Validation code...
     
     const treePos = this.tree.pathToTreePos(path);
     const commands = separateSplit(treePos, path);
     
-    commands.forEach((command) => {
+    for (const command of commands) {
       const { fromPath, toPath, content } = command;
       const fromPos = this.tree!.pathToPos(fromPath);
       const toPos = this.tree!.pathToPos(toPath);
     
-      return this.editInternal(fromPos, toPos, content ? [content] : [], 0);
-    });
+      if (!this.editInternal(fromPos, toPos, content ? [content] : [], 0)) {
+        return false;
+      }
+    }
+    return true;
   }
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 5fba3018ad42e943e7f2527f7ace8ceff5702df7 and a4c0606ade0694ea54f4db2d93f020e434b1ced1.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `packages/sdk/src/document/json/tree.ts` (3 hunks)
* `packages/sdk/test/integration/tree_test.ts` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* packages/sdk/test/integration/tree_test.ts

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>packages/sdk/src/document/json/tree.ts (4)</summary><blockquote>

<details>
<summary>packages/sdk/src/document/crdt/tree.ts (4)</summary>

* `CRDTTreeNode` (431-709)
* `TreeNode` (52-52)
* `TextNode` (66-69)
* `ElementNode` (57-61)

</details>
<details>
<summary>packages/sdk/src/util/object.ts (1)</summary>

* `parseObjectValues` (58-66)

</details>
<details>
<summary>packages/sdk/src/util/index_tree.ts (3)</summary>

* `DefaultTextType` (87-87)
* `TreePos` (530-533)
* `children` (278-283)

</details>
<details>
<summary>packages/sdk/src/util/error.ts (1)</summary>

* `YorkieError` (79-90)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: build (18.x)

</details>

<details>
<summary>🔇 Additional comments (1)</summary><blockquote>

<details>
<summary>packages/sdk/src/document/json/tree.ts (1)</summary>

`70-87`: **Good implementation of the `toTreeNode` function.**

The function correctly converts a `CRDTTreeNode` to a plain `TreeNode`, handling both text and element nodes appropriately. The recursive approach for element nodes and the proper handling of attributes ensure accurate conversion.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-24T09:37:59Z
user: login: hackerwins
body: Thanks for your contribution.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-30T01:05:01Z

Review comments:
diff_hunk: @@ -509,6 +509,124 @@ describe('Tree', () => {
     });
   });
 
+  it('Can edit its content by split', function ({ task }) {
+    const key = toDocKey(`${task.name}-${new Date().getTime()}`);
+    const doc = new yorkie.Document<{ t: Tree }>(key);
+
+    doc.update((root) => {
+      root.t = new Tree({
+        type: 'doc',
+        children: [
+          {
+            type: 'tc',
+            children: [
+              {
+                type: 'p',
+                children: [
+                  {
+                    type: 'tn',
+                    children: [{ type: 'text', value: '1234' }],
+                  },
+                ],
+              },
+              {
+                type: 'p',
+                children: [
+                  {
+                    type: 'tn',
+                    children: [{ type: 'text', value: '5678' }],
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+      });
+
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>1234</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+
+      root.t.splitByPath([0, 0, 0, 2]);
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12</tn><tn>34</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+
+      root.t.splitByPath([0, 0, 1]);
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12</tn></p><p><tn>34</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+
+      root.t.splitByPath([0, 2, 0, 4]);
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12</tn></p><p><tn>34</tn></p><p><tn>5678</tn><tn></tn></p></tc></doc>`,
+      );
+
+      root.t.splitByPath([0, 2, 1]);
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>12</tn></p><p><tn>34</tn></p><p><tn>5678</tn></p><p><tn></tn></p></tc></doc>`,
+      );
+    });
+  });
+
+  it('Can edit its content by merge', function ({ task }) {
+    const key = toDocKey(`${task.name}-${new Date().getTime()}`);
+    const doc = new yorkie.Document<{ t: Tree }>(key);
+
+    doc.update((root) => {
+      root.t = new Tree({
+        type: 'doc',
+        children: [
+          {
+            type: 'tc',
+            children: [
+              {
+                type: 'p',
+                children: [
+                  {
+                    type: 'tn',
+                    children: [{ type: 'text', value: '1234' }],
+                  },
+                ],
+              },
+              {
+                type: 'p',
+                children: [
+                  {
+                    type: 'tn',
+                    children: [{ type: 'text', value: '5678' }],
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+      });
+
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>1234</tn></p><p><tn>5678</tn></p></tc></doc>`,
+      );
+
+      root.t.mergeByPath([0, 1]);
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>1234</tn><tn>5678</tn></p></tc></doc>`,
+      );
+
+      root.t.mergeByPath([0, 0, 1]);
+      assert.equal(
+        root.t.toXML(),
+        /*html*/ `<doc><tc><p><tn>15678234</tn></p></tc></doc>`,
+      );
path: packages/sdk/test/integration/tree_test.ts
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Interesting text node merge behavior needs documentation.**

When merging text nodes at line 622, the result shows text content from the second node appearing *before* the first node's content (`15678234`), which appears unintuitive compared to the normal reading order.

This might be confusing for consumers of the API. Consider adding a comment explaining this behavior or documenting it in the implementation.

---

🏁 Script executed:

```shell
#!/bin/bash
# Check for comments in the mergeByPath implementation that might explain this behavior
cat packages/sdk/src/document/json/tree.ts | grep -A 15 "mergeByPath" | grep -i "text node" || echo "No comments found about text node merging behavior"
```

Length of output: 202

---



</details>

**Document reverse text‐node merging in mergeByPath**

There is currently no explanation of why merging two adjacent text nodes via `mergeByPath` results in the second node’s content appearing before the first. This behavior is non-intuitive and should be surfaced to API consumers.

• File to update:  
  – packages/sdk/src/document/json/tree.ts — inside the `mergeByPath` implementation (around the logic handling text-node concatenation)  
• Action items:  
  – Add an inline comment explaining that merged text nodes are prepended in this order (new node first, existing node second).  
  – Update the public documentation (e.g., SDK README or a `docs/` entry) to call out this merge ordering.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-24T08:48:58Z
author_association: NONE
diff_hunk: @@ -293,6 +425,74 @@ export class Tree {
     return this.tree.getIndexTree();
   }
 
+  /**
+   * `splitByPath` splits the tree by the given path.
+   */
+  public splitByPath(path: Array<number>) {
+    if (!this.context || !this.tree) {
+      throw new YorkieError(
+        Code.ErrNotInitialized,
+        'Tree is not initialized yet',
+      );
+    }
+
+    if (!path.length) {
+      throw new YorkieError(
+        Code.ErrInvalidArgument,
+        'path should not be empty',
+      );
+    }
+
+    const treePos = this.tree.pathToTreePos(path);
+    const commands = separateSplit(treePos, path);
+
+    commands.forEach((command) => {
+      const { fromPath, toPath, content } = command;
+      const fromPos = this.tree!.pathToPos(fromPath);
+      const toPos = this.tree!.pathToPos(toPath);
+
+      return this.editInternal(fromPos, toPos, content ? [content] : [], 0);
+    });
+  }
path: packages/sdk/src/document/json/tree.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**`splitByPath` does not propagate the success flag and may apply commands to a mutated tree**

Observations:
1. The method’s return type is implicit `void`, while `editInternal` returns
   `boolean`.  Callers cannot know if the split succeeded.
2. All `commands` are calculated up-front, but **each command mutates the tree**.
   Subsequent `pathToPos` resolutions may be invalid after the first edit
   (node indices shift).

Consider:

* Declaring an explicit return type (`boolean`) and short-circuiting on the
  first failure.
* Re-computing paths **after** each edit, or converting the path positions to
  immutable `CRDTTreePos` before any edit.

```diff
-  public splitByPath(path: Array<number>) {
+  public splitByPath(path: Array<number>): boolean {
      ...
-    commands.forEach((command) => {
+    for (const command of commands) {
         ...
-      return this.editInternal(fromPos, toPos, content ? [content] : [], 0);
-    });
+      if (
+        !this.editInternal(fromPos, toPos, content ? [content] : [], 0)
+      ) {
+        return false;
+      }
+    }
+    return true;
   }
```

This prevents silent failures and index drift.



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  public splitByPath(path: Array<number>): boolean {
    if (!this.context || !this.tree) {
      throw new YorkieError(
        Code.ErrNotInitialized,
        'Tree is not initialized yet',
      );
    }

    if (!path.length) {
      throw new YorkieError(
        Code.ErrInvalidArgument,
        'path should not be empty',
      );
    }

    const treePos = this.tree.pathToTreePos(path);
    const commands = separateSplit(treePos, path);

    for (const command of commands) {
      const { fromPath, toPath, content } = command;
      const fromPos = this.tree!.pathToPos(fromPath);
      const toPos = this.tree!.pathToPos(toPath);

      if (
        !this.editInternal(fromPos, toPos, content ? [content] : [], 0)
      ) {
        return false;
      }
    }
    return true;
  }
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-24T08:48:59Z
author_association: NONE
diff_hunk: @@ -59,6 +64,133 @@ export {
   CRDTTreeNodeIDStruct,
 };
 
+/**
+ * `toTreeNode` returns tree node from CRDTTreeNode.
+ */
+function toTreeNode(node: CRDTTreeNode): TreeNode {
+  if (node.isText) {
+    return {
+      type: node.type,
+      value: node.value,
+    } as TextNode;
+  }
+  const newNode = {
+    type: node.type,
+    children: node.children.map((child) => toTreeNode(child)),
+  } as ElementNode;
+
+  if (node.attrs) {
+    newNode.attributes = parseObjectValues(node.attrs.toObject());
+  }
+
+  return newNode;
+}
+
+/**
+ * `createSplittedNode` returns new node which is splitted from the given node.
+ */
+function createSplittedNode(
+  node: CRDTTreeNode,
+  offset: number,
+): ElementNode | undefined {
+  const newNode: ElementNode = {
+    type: node.isText ? node.parent!.type : node.type,
+    children: node.isText
+      ? node.parent!.getChildrenText().length === offset
+        ? []
+        : [
+            {
+              type: DefaultTextType,
+              value: node.parent!.getChildrenText().slice(offset),
+            } as TextNode,
+          ]
+      : node.children.slice(offset).map((child) => toTreeNode(child)),
+  };
+
+  if (node.attrs) {
+    newNode.attributes = parseObjectValues(node.attrs.toObject());
+  } else if (node.isText && node.parent!.attrs) {
+    newNode.attributes = parseObjectValues(node.parent!.attrs.toObject());
+  }
+
+  return newNode;
+}
+
+/**
+ * `separateSplit` separates the split operation into insert and delete operations:
+ */
+function separateSplit(
+  treePos: TreePos<CRDTTreeNode>,
+  path: Array<number>,
+): Array<{
+  fromPath: Array<number>;
+  toPath: Array<number>;
+  content?: TreeNode;
+}> {
+  const { node } = treePos;
+  const parentPath = [...path].slice(0, -1);
+  const last = node.isText
+    ? node.parent!.getChildrenText().length
+    : node.children.length;
+  const toPath = [...parentPath, last];
+  const insertPath = [...parentPath];
+  const res = [];
+
+  insertPath[insertPath.length - 1] += 1;
+
+  if (!path.every((v, i) => v === toPath[i])) {
+    res.push({ fromPath: [...path], toPath });
+  }
+
+  const newNode = createSplittedNode(node, path[path.length - 1]);
+
+  if (newNode) {
+    res.push({ fromPath: insertPath, toPath: insertPath, content: newNode });
+  }
+
+  return res;
+}
+
+/**
+ * `separateMerge` separates the merge operation into insert and delete operations:
+ */
+function separateMerge(
+  treePos: TreePos<CRDTTreeNode>,
+  path: Array<number>,
+): Array<{
+  fromPath: Array<number>;
+  toPath: Array<number>;
+  content?: Array<TreeNode>;
+}> {
+  const { node: parentNode, offset } = treePos;
+  const node = parentNode.children[offset];
+  const leftSiblingNode = parentNode.children[offset - 1];
+  const { children } = node;
+  const parentPath = [...path].slice(0, -1);
+  const res: ReturnType<typeof separateMerge> = [
+    { fromPath: [...path], toPath: [...parentPath, offset + 1] },
+  ];
+
+  if (!node.children.length) {
+    return res;
+  }
+
+  const insertPath = [
+    ...parentPath,
+    offset - 1,
+    leftSiblingNode.children.length,
+  ];
+  const nodes = children.map((child) => toTreeNode(child));
+
+  res.push({
+    fromPath: insertPath,
+    toPath: insertPath,
+    content: nodes,
+  });
+
+  return res;
+}
path: packages/sdk/src/document/json/tree.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**`separateMerge` crashes when merging the first child and lacks bounds checks**

Problems discovered:
* Accesses `parentNode.children[offset - 1]` without checking that `offset > 0`
  → `leftSiblingNode` is `undefined`, then later dereferenced.
* No check that `offset` is within the parent’s `children` array.
* Merging a node that has no left sibling should raise a clear error, not throw
  from an undefined access.

Quick fix:

```diff
-  const node = parentNode.children[offset];
-  const leftSiblingNode = parentNode.children[offset - 1];
+  if (offset <= 0 || offset >= parentNode.children.length) {
+    throw new YorkieError(
+      Code.ErrInvalidArgument,
+      'mergeByPath requires a node with a left sibling',
+    );
+  }
+
+  const node = parentNode.children[offset];
+  const leftSiblingNode = parentNode.children[offset - 1];
```

Recommend adding unit tests that attempt `mergeByPath([0])` to ensure the guard
works.



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
function separateMerge(
  treePos: TreePos<CRDTTreeNode>,
  path: Array<number>,
): Array<{
  fromPath: Array<number>;
  toPath: Array<number>;
  content?: Array<TreeNode>;
}> {
  const { node: parentNode, offset } = treePos;
  if (offset <= 0 || offset >= parentNode.children.length) {
    throw new YorkieError(
      Code.ErrInvalidArgument,
      'mergeByPath requires a node with a left sibling',
    );
  }

  const node = parentNode.children[offset];
  const leftSiblingNode = parentNode.children[offset - 1];
  const { children } = node;
  const parentPath = [...path].slice(0, -1);
  const res: ReturnType<typeof separateMerge> = [
    { fromPath: [...path], toPath: [...parentPath, offset + 1] },
  ];

  if (!node.children.length) {
    return res;
  }

  const insertPath = [
    ...parentPath,
    offset - 1,
    leftSiblingNode.children.length,
  ];
  const nodes = children.map((child) => toTreeNode(child));

  res.push({
    fromPath: insertPath,
    toPath: insertPath,
    content: nodes,
  });

  return res;
}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-24T08:48:59Z
author_association: NONE
diff_hunk: @@ -59,6 +64,133 @@ export {
   CRDTTreeNodeIDStruct,
 };
 
+/**
+ * `toTreeNode` returns tree node from CRDTTreeNode.
+ */
+function toTreeNode(node: CRDTTreeNode): TreeNode {
+  if (node.isText) {
+    return {
+      type: node.type,
+      value: node.value,
+    } as TextNode;
+  }
+  const newNode = {
+    type: node.type,
+    children: node.children.map((child) => toTreeNode(child)),
+  } as ElementNode;
+
+  if (node.attrs) {
+    newNode.attributes = parseObjectValues(node.attrs.toObject());
+  }
+
+  return newNode;
+}
+
+/**
+ * `createSplittedNode` returns new node which is splitted from the given node.
+ */
+function createSplittedNode(
+  node: CRDTTreeNode,
+  offset: number,
+): ElementNode | undefined {
+  const newNode: ElementNode = {
+    type: node.isText ? node.parent!.type : node.type,
+    children: node.isText
+      ? node.parent!.getChildrenText().length === offset
+        ? []
+        : [
+            {
+              type: DefaultTextType,
+              value: node.parent!.getChildrenText().slice(offset),
+            } as TextNode,
+          ]
+      : node.children.slice(offset).map((child) => toTreeNode(child)),
+  };
+
+  if (node.attrs) {
+    newNode.attributes = parseObjectValues(node.attrs.toObject());
+  } else if (node.isText && node.parent!.attrs) {
+    newNode.attributes = parseObjectValues(node.parent!.attrs.toObject());
+  }
+
+  return newNode;
+}
+
+/**
+ * `separateSplit` separates the split operation into insert and delete operations:
+ */
+function separateSplit(
+  treePos: TreePos<CRDTTreeNode>,
+  path: Array<number>,
+): Array<{
+  fromPath: Array<number>;
+  toPath: Array<number>;
+  content?: TreeNode;
+}> {
+  const { node } = treePos;
+  const parentPath = [...path].slice(0, -1);
+  const last = node.isText
+    ? node.parent!.getChildrenText().length
+    : node.children.length;
+  const toPath = [...parentPath, last];
+  const insertPath = [...parentPath];
+  const res = [];
+
+  insertPath[insertPath.length - 1] += 1;
+
+  if (!path.every((v, i) => v === toPath[i])) {
+    res.push({ fromPath: [...path], toPath });
+  }
+
+  const newNode = createSplittedNode(node, path[path.length - 1]);
+
+  if (newNode) {
+    res.push({ fromPath: insertPath, toPath: insertPath, content: newNode });
+  }
+
+  return res;
+}
path: packages/sdk/src/document/json/tree.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**`separateSplit` fails for root-level paths and may raise `NaN` index errors**

Issues:
1. When `parentPath` is empty (`path.length === 1`) the line  
   `insertPath[insertPath.length - 1] += 1;` mutates `undefined`, producing `NaN`.
2. No validation that `offset` (last segment of path) is within bounds of the
   text length / children count found in `last`.
3. Possible insertion of an empty node (see previous comment).

Suggested patch:

```diff
-  const insertPath = [...parentPath];
-  ...
-  insertPath[insertPath.length - 1] += 1;
+  if (parentPath.length === 0) {
+    throw new YorkieError(
+      Code.ErrInvalidArgument,
+      'Cannot split the root element',
+    );
+  }
+
+  const insertPath = [...parentPath];
+  insertPath[insertPath.length - 1] += 1;
+
+  if (path[path.length - 1] < 0 || path[path.length - 1] > last) {
+    throw new YorkieError(
+      Code.ErrInvalidArgument,
+      `Split offset (${path[path.length - 1]}) is out of bounds`,
+    );
+  }
```

Guarding here prevents runtime exceptions and undefined behaviour.



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
function separateSplit(
  treePos: TreePos<CRDTTreeNode>,
  path: Array<number>,
): Array<{
  fromPath: Array<number>;
  toPath: Array<number>;
  content?: TreeNode;
}> {
  const { node } = treePos;
  const parentPath = [...path].slice(0, -1);
  const last = node.isText
    ? node.parent!.getChildrenText().length
    : node.children.length;
  const toPath = [...parentPath, last];

  // Prevent splitting the root and enforce a valid offset
  if (parentPath.length === 0) {
    throw new YorkieError(
      Code.ErrInvalidArgument,
      'Cannot split the root element',
    );
  }

  const insertPath = [...parentPath];
  insertPath[insertPath.length - 1] += 1;

  if (path[path.length - 1] < 0 || path[path.length - 1] > last) {
    throw new YorkieError(
      Code.ErrInvalidArgument,
      `Split offset (${path[path.length - 1]}) is out of bounds`,
    );
  }

  const res = [];

  if (!path.every((v, i) => v === toPath[i])) {
    res.push({ fromPath: [...path], toPath });
  }

  const newNode = createSplittedNode(node, path[path.length - 1]);

  if (newNode) {
    res.push({ fromPath: insertPath, toPath: insertPath, content: newNode });
  }

  return res;
}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-24T08:48:59Z
author_association: NONE
diff_hunk: @@ -59,6 +64,133 @@ export {
   CRDTTreeNodeIDStruct,
 };
 
+/**
+ * `toTreeNode` returns tree node from CRDTTreeNode.
+ */
+function toTreeNode(node: CRDTTreeNode): TreeNode {
+  if (node.isText) {
+    return {
+      type: node.type,
+      value: node.value,
+    } as TextNode;
+  }
+  const newNode = {
+    type: node.type,
+    children: node.children.map((child) => toTreeNode(child)),
+  } as ElementNode;
+
+  if (node.attrs) {
+    newNode.attributes = parseObjectValues(node.attrs.toObject());
+  }
+
+  return newNode;
+}
+
+/**
+ * `createSplittedNode` returns new node which is splitted from the given node.
+ */
+function createSplittedNode(
+  node: CRDTTreeNode,
+  offset: number,
+): ElementNode | undefined {
+  const newNode: ElementNode = {
+    type: node.isText ? node.parent!.type : node.type,
+    children: node.isText
+      ? node.parent!.getChildrenText().length === offset
+        ? []
+        : [
+            {
+              type: DefaultTextType,
+              value: node.parent!.getChildrenText().slice(offset),
+            } as TextNode,
+          ]
+      : node.children.slice(offset).map((child) => toTreeNode(child)),
+  };
+
+  if (node.attrs) {
+    newNode.attributes = parseObjectValues(node.attrs.toObject());
+  } else if (node.isText && node.parent!.attrs) {
+    newNode.attributes = parseObjectValues(node.parent!.attrs.toObject());
+  }
+
+  return newNode;
+}
+
path: packages/sdk/src/document/json/tree.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Handle boundary cases in `createSplittedNode` to avoid incorrect nodes or runtime errors**

`createSplittedNode` currently:

* Treats `offset` the same whether it is **0**, **at the end**, or **out-of-range**; this can lead to:
  * Creating an *empty* element that is then inserted (`children: []`), polluting the tree.
  * Attempting to slice parent text with an offset larger than the text length → throws.
* Assumes `node.parent` always exists when `node.isText` is `true`; this is not guaranteed for a root‐level text node.
* Directly re-uses `node.parent!.type` when splitting a text node – but if the parent is a different element type (e.g., `<p>` with a text child) you end up inserting a sibling `<p>` inside another `<p>`.

Recommend a defensive refactor:

```diff
function createSplittedNode(
   node: CRDTTreeNode,
   offset: number,
 ): ElementNode | undefined {
-  const newNode: ElementNode = {
-    type: node.isText ? node.parent!.type : node.type,
-    children: node.isText
-      ? node.parent!.getChildrenText().length === offset
-        ? []
-        : [
-            {
-              type: DefaultTextType,
-              value: node.parent!.getChildrenText().slice(offset),
-            } as TextNode,
-          ]
-      : node.children.slice(offset).map((child) => toTreeNode(child)),
-  };
+  /* --- guard clauses ---------------------------------------------------- */
+  // root-level text, or invalid offset
+  if (node.isText && !node.parent) return;
+
+  const parentText = node.isText
+    ? node.parent!.getChildrenText()
+    : undefined;
+
+  if (node.isText && (offset <= 0 || offset >= parentText!.length)) {
+    // nothing to split ‑- avoid inserting empty elements
+    return;
+  }
+
+  if (!node.isText && (offset <= 0 || offset >= node.children.length)) {
+    return;
+  }
+
+  /* --- build the cloned (right) node ------------------------------------ */
+  const newNode: ElementNode = {
+    type: node.isText ? node.parent!.type : node.type,
+    children: node.isText
+      ? [
+          {
+            type: DefaultTextType,
+            value: parentText!.slice(offset),
+          } as TextNode,
+        ]
+      : node.children.slice(offset).map((child) => toTreeNode(child)),
+  };
```

This avoids empty inserts and guards against misuse.



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
function createSplittedNode(
  node: CRDTTreeNode,
  offset: number,
): ElementNode | undefined {
  /* --- guard clauses ---------------------------------------------------- */
  // root-level text, or invalid offset
  if (node.isText && !node.parent) return;

  const parentText = node.isText
    ? node.parent!.getChildrenText()
    : undefined;

  if (node.isText && (offset <= 0 || offset >= parentText!.length)) {
    // nothing to split -- avoid inserting empty elements
    return;
  }

  if (!node.isText && (offset <= 0 || offset >= node.children.length)) {
    return;
  }

  /* --- build the cloned (right) node ------------------------------------ */
  const newNode: ElementNode = {
    type: node.isText ? node.parent!.type : node.type,
    children: node.isText
      ? [
          {
            type: DefaultTextType,
            value: parentText!.slice(offset),
          } as TextNode,
        ]
      : node.children.slice(offset).map((child) => toTreeNode(child)),
  };

  if (node.attrs) {
    newNode.attributes = parseObjectValues(node.attrs.toObject());
  } else if (node.isText && node.parent!.attrs) {
    newNode.attributes = parseObjectValues(node.parent!.attrs.toObject());
  }

  return newNode;
}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-24T08:48:59Z
author_association: NONE
diff_hunk: @@ -293,6 +427,74 @@ export class Tree {
     return this.tree.getIndexTree();
   }
 
+  /**
+   * `splitByPath` splits the tree by the given path.
+   */
+  public splitByPath(path: Array<number>) {
+    if (!this.context || !this.tree) {
+      throw new YorkieError(
+        Code.ErrNotInitialized,
+        'Tree is not initialized yet',
+      );
+    }
+
+    if (!path.length) {
+      throw new YorkieError(
+        Code.ErrInvalidArgument,
+        'path should not be empty',
+      );
+    }
+
+    const treePos = this.tree.pathToTreePos(path);
+    const commands = separateSplit(treePos, path);
+
+    commands.forEach((command) => {
+      const { fromPath, toPath, content } = command;
+      const fromPos = this.tree!.pathToPos(fromPath);
+      const toPos = this.tree!.pathToPos(toPath);
+
+      return this.editInternal(fromPos, toPos, content ? [content] : [], 0);
+    });
+  }
+
+  /**
+   * `mergeByPath` merges the tree by the given path.
+   */
+  public mergeByPath(path: Array<number>) {
+    if (!this.context || !this.tree) {
+      throw new YorkieError(
+        Code.ErrNotInitialized,
+        'Tree is not initialized yet',
+      );
+    }
+
+    if (!path.length) {
+      throw new YorkieError(
+        Code.ErrInvalidArgument,
+        'path should not be empty',
+      );
+    }
+
+    const treePos = this.tree.pathToTreePos(path);
+
+    if (treePos.node.isText) {
+      throw new YorkieError(
+        Code.ErrInvalidArgument,
+        'text node cannot be merged',
+      );
+    }
+
+    const commands = separateMerge(treePos, path);
+
+    commands.forEach((command) => {
+      const { fromPath, toPath, content } = command;
+      const fromPos = this.tree!.pathToPos(fromPath);
+      const toPos = this.tree!.pathToPos(toPath);
+
+      return this.editInternal(fromPos, toPos, content ?? [], 0);
+    });
+  }
path: packages/sdk/src/document/json/tree.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**`mergeByPath` has the same issues as `splitByPath` and needs similar improvements.**

Like `splitByPath`, this method:
1. Doesn't return a success flag
2. Processes commands without recalculating paths after each edit
3. Ignores potential failures from `editInternal`


Apply a similar pattern to handle failures and maintain tree integrity:

```diff
-  public mergeByPath(path: Array<number>) {
+  public mergeByPath(path: Array<number>): boolean {
     // Validation code...
     
     const commands = separateMerge(treePos, path);
     
-    commands.forEach((command) => {
+    for (const command of commands) {
       const { fromPath, toPath, content } = command;
       const fromPos = this.tree!.pathToPos(fromPath);
       const toPos = this.tree!.pathToPos(toPath);
     
-      return this.editInternal(fromPos, toPos, content ?? [], 0);
+      if (!this.editInternal(fromPos, toPos, content ?? [], 0)) {
+        return false;
+      }
+    }
+    return true;
   }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  /**
   * `mergeByPath` merges the tree by the given path.
   */
  public mergeByPath(path: Array<number>): boolean {
    if (!this.context || !this.tree) {
      throw new YorkieError(
        Code.ErrNotInitialized,
        'Tree is not initialized yet',
      );
    }

    if (!path.length) {
      throw new YorkieError(
        Code.ErrInvalidArgument,
        'path should not be empty',
      );
    }

    const treePos = this.tree.pathToTreePos(path);

    if (treePos.node.isText) {
      throw new YorkieError(
        Code.ErrInvalidArgument,
        'text node cannot be merged',
      );
    }

    const commands = separateMerge(treePos, path);

    for (const command of commands) {
      const { fromPath, toPath, content } = command;
      const fromPos = this.tree!.pathToPos(fromPath);
      const toPos = this.tree!.pathToPos(toPath);

      if (!this.editInternal(fromPos, toPos, content ?? [], 0)) {
        return false;
      }
    }
    return true;
  }
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-24T09:37:59Z
author_association: NONE

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-04-24T08:44:11Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

The changes introduce new capabilities for splitting and merging nodes in a CRDT-based tree structure by path. New helper functions and public methods are added to the tree manipulation classes to support these operations, including methods for converting between node representations, generating split and merge commands, and applying edits. The `CRDTTree` class gains a method to convert a path array to a `TreePos`. The `IndexTreeNode` class gains a method to extract concatenated text from children. Integration tests verify the correctness of splitting and merging at various paths and their synchronization across collaborative clients.

## Changes

| File(s)                                      | Change Summary                                                                                                   |
|----------------------------------------------|-----------------------------------------------------------------------------------------------------------------|
| `packages/sdk/src/document/crdt/tree.ts`     | Added `pathToTreePos` public method to `CRDTTree` for converting a path array to a `TreePos`.                   |
| `packages/sdk/src/document/json/tree.ts`     | Added private helper functions for node conversion, splitting, and merging. Added `splitByPath` and `mergeByPath` public methods to `Tree` for path-based editing. |
| `packages/sdk/src/util/index_tree.ts`        | Added `getChildrenText` method to `IndexTreeNode` to return concatenated text of all text children or self if text node. |
| `packages/sdk/test/integration/tree_test.ts` | Added integration tests for splitting and merging tree content by path, verifying tree structure after each operation and synchronization between clients. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant Tree
    participant CRDTTree
    participant IndexTree

    User->>Tree: splitByPath(path)
    Tree->>CRDTTree: pathToTreePos(path)
    CRDTTree->>IndexTree: pathToTreePos(path)
    IndexTree-->>CRDTTree: TreePos
    CRDTTree-->>Tree: TreePos
    Tree->>Tree: separateSplit(TreePos, path)
    loop for each split command
        Tree->>CRDTTree: editInternal(fromPos, toPos, content)
    end
    Tree-->>User: (split complete)

    User->>Tree: mergeByPath(path)
    Tree->>CRDTTree: pathToTreePos(path)
    CRDTTree->>IndexTree: pathToTreePos(path)
    IndexTree-->>CRDTTree: TreePos
    CRDTTree-->>Tree: TreePos
    Tree->>Tree: separateMerge(TreePos, path)
    loop for each merge command
        Tree->>CRDTTree: editInternal(fromPos, toPos, content)
    end
    Tree-->>User: (merge complete)
```

## Poem

> In the forest of code where the tree branches grow,  
> Rabbits now split and merge with a hop and a flow.  
> Paths are their trails, through nodes they will dart,  
> Slicing and joining, a CRDT art.  
> With tests as their carrots, they nibble with glee—  
> Hooray for new features in our document tree!  
> 🐇🌳✨

<!-- walkthrough_end -->
<!-- This is an auto-generated comment: all tool run failures by coderabbit.ai -->

> [!WARNING]
> There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.
> 
> <details>
> <summary>🔧 ESLint</summary>
> 
> > If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.
> 
> <details>
> <summary>packages/sdk/src/document/json/tree.ts</summary>
> 
> 
> Oops! Something went wrong! :(
> 
> ESLint: 8.57.1
> 
> ESLint couldn't find the plugin "@typescript-eslint/eslint-plugin".
> 
> (The package "@typescript-eslint/eslint-plugin" was not found when loaded as a Node module from the directory "/packages/sdk".)
> 
> It's likely that the plugin isn't installed correctly. Try reinstalling by running the following:
> 
>     npm install @typescript-eslint/eslint-plugin@latest --save-dev
> 
> The plugin "@typescript-eslint/eslint-plugin" was referenced from the config file in "packages/sdk/.eslintrc.js » ../../.eslintrc.js".
> 
> If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.
> 
> 
> </details>
> 
> <details>
> <summary>packages/sdk/src/util/index_tree.ts</summary>
> 
> 
> Oops! Something went wrong! :(
> 
> ESLint: 8.57.1
> 
> ESLint couldn't find the plugin "@typescript-eslint/eslint-plugin".
> 
> (The package "@typescript-eslint/eslint-plugin" was not found when loaded as a Node module from the directory "/packages/sdk".)
> 
> It's likely that the plugin isn't installed correctly. Try reinstalling by running the following:
> 
>     npm install @typescript-eslint/eslint-plugin@latest --save-dev
> 
> The plugin "@typescript-eslint/eslint-plugin" was referenced from the config file in "packages/sdk/.eslintrc.js » ../../.eslintrc.js".
> 
> If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.
> 
> 
> </details>
> 
> </details>

<!-- end of auto-generated comment: all tool run failures by coderabbit.ai -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between eb96af6a491cfff318ec79d476ff14ca7f995c73 and e89137abbec2b0b65565f991a97588e3778a97f8.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `packages/sdk/src/document/json/tree.ts` (3 hunks)
* `packages/sdk/src/util/index_tree.ts` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (2)</summary>

* packages/sdk/src/util/index_tree.ts
* packages/sdk/src/document/json/tree.ts

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: build (18.x)

</details>

</details>
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yorkie-team/yorkie-js-sdk&utm_content=982):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: codecov[bot]
created_at: 2025-04-24T08:46:57Z
body: ## [Codecov](https://app.codecov.io/gh/yorkie-team/yorkie-js-sdk/pull/982?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yorkie-team) Report
Attention: Patch coverage is `68.49315%` with `23 lines` in your changes missing coverage. Please review.
> Project coverage is 78.41%. Comparing base [(`92c5334`)](https://app.codecov.io/gh/yorkie-team/yorkie-js-sdk/commit/92c53343f74192759a006d37a9e40c1de52478f4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yorkie-team) to head [(`e89137a`)](https://app.codecov.io/gh/yorkie-team/yorkie-js-sdk/commit/e89137abbec2b0b65565f991a97588e3778a97f8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yorkie-team).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/yorkie-team/yorkie-js-sdk/pull/982?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yorkie-team) | Patch % | Lines |
|---|---|---|
| [packages/sdk/src/document/json/tree.ts](https://app.codecov.io/gh/yorkie-team/yorkie-js-sdk/pull/982?src=pr&el=tree&filepath=packages%2Fsdk%2Fsrc%2Fdocument%2Fjson%2Ftree.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yorkie-team#diff-cGFja2FnZXMvc2RrL3NyYy9kb2N1bWVudC9qc29uL3RyZWUudHM=) | 71.64% | [9 Missing and 10 partials :warning: ](https://app.codecov.io/gh/yorkie-team/yorkie-js-sdk/pull/982?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yorkie-team) |
| [packages/sdk/src/util/index\_tree.ts](https://app.codecov.io/gh/yorkie-team/yorkie-js-sdk/pull/982?src=pr&el=tree&filepath=packages%2Fsdk%2Fsrc%2Futil%2Findex_tree.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yorkie-team#diff-cGFja2FnZXMvc2RrL3NyYy91dGlsL2luZGV4X3RyZWUudHM=) | 20.00% | [2 Missing and 2 partials :warning: ](https://app.codecov.io/gh/yorkie-team/yorkie-js-sdk/pull/982?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yorkie-team) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #982      +/-   ##
==========================================
- Coverage   78.51%   78.41%   -0.10%     
==========================================
  Files          63       63              
  Lines        5529     5602      +73     
  Branches     1019     1039      +20     
==========================================
+ Hits         4341     4393      +52     
- Misses        885      896      +11     
- Partials      303      313      +10     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/yorkie-team/yorkie-js-sdk/pull/982?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yorkie-team).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yorkie-team).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE