@loro-extended/change 5.0.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -0
- package/dist/index.d.ts +262 -137
- package/dist/index.js +2027 -1930
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/fork-at.test.ts +260 -0
- package/src/functional-helpers.test.ts +402 -0
- package/src/functional-helpers.ts +172 -12
- package/src/index.ts +7 -2
- package/src/loro.ts +26 -2
- package/src/shape.ts +9 -5
- package/src/typed-doc.ts +58 -6
- package/src/typed-refs/base.ts +18 -0
- package/src/typed-refs/doc-ref-internals.ts +2 -2
- package/src/typed-refs/list-ref-base-internals.ts +2 -2
- package/src/typed-refs/list-ref-base.ts +2 -2
- package/src/typed-refs/record-ref-internals.ts +2 -2
- package/src/typed-refs/struct-ref-internals.ts +2 -2
- package/src/typed-refs/struct-ref.ts +7 -1
- package/src/typed-refs/tree-deleted-nodes.test.ts +213 -0
- package/src/typed-refs/tree-loro.test.ts +52 -0
- package/src/typed-refs/tree-node-ref-internals.ts +34 -1
- package/src/typed-refs/tree-node-ref.test.ts +24 -17
- package/src/typed-refs/tree-node-ref.ts +8 -0
- package/src/typed-refs/tree-node.test.ts +54 -22
- package/src/typed-refs/tree-ref.ts +10 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { TreeID } from "loro-crdt"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { change } from "../functional-helpers.js"
|
|
4
|
+
import { Shape } from "../shape.js"
|
|
5
|
+
import { createTypedDoc } from "../typed-doc.js"
|
|
6
|
+
|
|
7
|
+
// Define a simple schema for testing deleted node behavior
|
|
8
|
+
const StateNodeDataShape = Shape.struct({
|
|
9
|
+
name: Shape.text(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const TestSchema = Shape.doc({
|
|
13
|
+
states: Shape.tree(StateNodeDataShape),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe("deleted node filtering", () => {
|
|
17
|
+
/**
|
|
18
|
+
* nodes() now excludes deleted nodes by default.
|
|
19
|
+
* Use nodes({ includeDeleted: true }) to include them.
|
|
20
|
+
*/
|
|
21
|
+
describe("nodes()", () => {
|
|
22
|
+
it("excludes deleted nodes by default", () => {
|
|
23
|
+
const typedDoc = createTypedDoc(TestSchema)
|
|
24
|
+
|
|
25
|
+
let nodeId: TreeID | undefined
|
|
26
|
+
|
|
27
|
+
// Create a node
|
|
28
|
+
change(typedDoc, draft => {
|
|
29
|
+
const root = draft.states.createNode()
|
|
30
|
+
root.data.name.insert(0, "test")
|
|
31
|
+
nodeId = root.id
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Delete the node
|
|
35
|
+
change(typedDoc, draft => {
|
|
36
|
+
if (nodeId === undefined) throw new Error("nodeId should be defined")
|
|
37
|
+
draft.states.delete(nodeId)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// nodes() now excludes deleted nodes by default
|
|
41
|
+
change(typedDoc, draft => {
|
|
42
|
+
const allNodes = draft.states.nodes()
|
|
43
|
+
|
|
44
|
+
// Fixed behavior: deleted nodes are excluded
|
|
45
|
+
expect(allNodes.length).toBe(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// toJSON also excludes deleted nodes
|
|
49
|
+
const json = typedDoc.toJSON()
|
|
50
|
+
expect(json.states).toHaveLength(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("includes deleted nodes when includeDeleted: true", () => {
|
|
54
|
+
const typedDoc = createTypedDoc(TestSchema)
|
|
55
|
+
|
|
56
|
+
let nodeId: TreeID | undefined
|
|
57
|
+
|
|
58
|
+
// Create a node
|
|
59
|
+
change(typedDoc, draft => {
|
|
60
|
+
const root = draft.states.createNode()
|
|
61
|
+
root.data.name.insert(0, "test")
|
|
62
|
+
nodeId = root.id
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Delete the node
|
|
66
|
+
change(typedDoc, draft => {
|
|
67
|
+
if (nodeId === undefined) throw new Error("nodeId should be defined")
|
|
68
|
+
draft.states.delete(nodeId)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// nodes({ includeDeleted: true }) includes deleted nodes
|
|
72
|
+
change(typedDoc, draft => {
|
|
73
|
+
const allNodes = draft.states.nodes({ includeDeleted: true })
|
|
74
|
+
|
|
75
|
+
// Deleted nodes are included when explicitly requested
|
|
76
|
+
expect(allNodes.length).toBe(1)
|
|
77
|
+
expect(allNodes[0].isDeleted()).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* FINDING: roots() does NOT include deleted nodes
|
|
84
|
+
* Loro's underlying roots() already filters them out.
|
|
85
|
+
* No fix needed.
|
|
86
|
+
*/
|
|
87
|
+
describe("roots()", () => {
|
|
88
|
+
it("correctly excludes deleted root nodes - NO FIX NEEDED", () => {
|
|
89
|
+
const typedDoc = createTypedDoc(TestSchema)
|
|
90
|
+
|
|
91
|
+
let rootId: TreeID | undefined
|
|
92
|
+
|
|
93
|
+
// Create a root node
|
|
94
|
+
change(typedDoc, draft => {
|
|
95
|
+
const root = draft.states.createNode()
|
|
96
|
+
root.data.name.insert(0, "root")
|
|
97
|
+
rootId = root.id
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Delete the root node
|
|
101
|
+
change(typedDoc, draft => {
|
|
102
|
+
if (rootId === undefined) throw new Error("rootId should be defined")
|
|
103
|
+
draft.states.delete(rootId)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// roots() correctly excludes deleted nodes
|
|
107
|
+
change(typedDoc, draft => {
|
|
108
|
+
const allRoots = draft.states.roots()
|
|
109
|
+
|
|
110
|
+
// Correct behavior: deleted roots are NOT included
|
|
111
|
+
expect(allRoots.length).toBe(0)
|
|
112
|
+
expect(allRoots.some(n => n.isDeleted())).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// toJSON also excludes deleted nodes
|
|
116
|
+
const json = typedDoc.toJSON()
|
|
117
|
+
expect(json.states).toHaveLength(0)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* FINDING: children() does NOT include deleted nodes
|
|
123
|
+
* Loro's underlying children() already filters them out.
|
|
124
|
+
* No fix needed.
|
|
125
|
+
*/
|
|
126
|
+
describe("children()", () => {
|
|
127
|
+
it("correctly excludes deleted child nodes - NO FIX NEEDED", () => {
|
|
128
|
+
const typedDoc = createTypedDoc(TestSchema)
|
|
129
|
+
|
|
130
|
+
let rootId: TreeID | undefined
|
|
131
|
+
let childId: TreeID | undefined
|
|
132
|
+
|
|
133
|
+
// Create a root with a child
|
|
134
|
+
change(typedDoc, draft => {
|
|
135
|
+
const root = draft.states.createNode()
|
|
136
|
+
root.data.name.insert(0, "root")
|
|
137
|
+
rootId = root.id
|
|
138
|
+
|
|
139
|
+
const child = root.createNode()
|
|
140
|
+
child.data.name.insert(0, "child")
|
|
141
|
+
childId = child.id
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Delete the child node
|
|
145
|
+
change(typedDoc, draft => {
|
|
146
|
+
if (childId === undefined) throw new Error("childId should be defined")
|
|
147
|
+
draft.states.delete(childId)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// children() correctly excludes deleted nodes
|
|
151
|
+
change(typedDoc, draft => {
|
|
152
|
+
if (rootId === undefined) throw new Error("rootId should be defined")
|
|
153
|
+
const root = draft.states.getNodeByID(rootId)
|
|
154
|
+
expect(root).toBeDefined()
|
|
155
|
+
if (root === undefined) throw new Error("root should be defined")
|
|
156
|
+
|
|
157
|
+
const children = root.children()
|
|
158
|
+
|
|
159
|
+
// Correct behavior: deleted children are NOT included
|
|
160
|
+
expect(children.length).toBe(0)
|
|
161
|
+
expect(children.some(n => n.isDeleted())).toBe(false)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// toJSON also excludes deleted nodes
|
|
165
|
+
const json = typedDoc.toJSON()
|
|
166
|
+
expect(json.states).toHaveLength(1)
|
|
167
|
+
expect(json.states[0].children).toHaveLength(0)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* FINDING: Accessing .data on a deleted node doesn't throw immediately
|
|
173
|
+
* because StructRef is created lazily. But using the underlying LoroMap
|
|
174
|
+
* will fail with "container is deleted" error.
|
|
175
|
+
*/
|
|
176
|
+
describe("accessing deleted node data", () => {
|
|
177
|
+
it("accessing .data property succeeds but using it fails", () => {
|
|
178
|
+
const typedDoc = createTypedDoc(TestSchema)
|
|
179
|
+
|
|
180
|
+
let nodeId: TreeID | undefined
|
|
181
|
+
|
|
182
|
+
// Create a node
|
|
183
|
+
change(typedDoc, draft => {
|
|
184
|
+
const root = draft.states.createNode()
|
|
185
|
+
root.data.name.insert(0, "test")
|
|
186
|
+
nodeId = root.id
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// Delete the node
|
|
190
|
+
change(typedDoc, draft => {
|
|
191
|
+
if (nodeId === undefined) throw new Error("nodeId should be defined")
|
|
192
|
+
draft.states.delete(nodeId)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Try to access data on deleted node (must use includeDeleted: true)
|
|
196
|
+
change(typedDoc, draft => {
|
|
197
|
+
const allNodes = draft.states.nodes({ includeDeleted: true })
|
|
198
|
+
const deletedNode = allNodes.find(n => n.isDeleted())
|
|
199
|
+
|
|
200
|
+
expect(deletedNode).toBeDefined()
|
|
201
|
+
if (deletedNode === undefined)
|
|
202
|
+
throw new Error("deletedNode should be defined")
|
|
203
|
+
|
|
204
|
+
// Accessing .data property succeeds (lazy creation)
|
|
205
|
+
const data = deletedNode.data
|
|
206
|
+
expect(data).toBeDefined()
|
|
207
|
+
|
|
208
|
+
// But trying to use the underlying container would fail
|
|
209
|
+
// (We don't test this as it would throw and break the test)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { loro } from "../loro.js"
|
|
3
|
+
import { Shape } from "../shape.js"
|
|
4
|
+
import { createTypedDoc } from "../typed-doc.js"
|
|
5
|
+
|
|
6
|
+
describe("TreeRef loro() support", () => {
|
|
7
|
+
it("should support loro() on TreeRef property", () => {
|
|
8
|
+
const StateNodeDataShape = Shape.struct({
|
|
9
|
+
name: Shape.plain.string(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const Schema = Shape.doc({
|
|
13
|
+
states: Shape.tree(StateNodeDataShape),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const doc = createTypedDoc(Schema)
|
|
17
|
+
|
|
18
|
+
// This should compile and run without error
|
|
19
|
+
const treeLoro = loro(doc.states)
|
|
20
|
+
|
|
21
|
+
expect(treeLoro).toBeDefined()
|
|
22
|
+
expect(treeLoro.doc).toBeDefined()
|
|
23
|
+
expect(treeLoro.container).toBeDefined()
|
|
24
|
+
expect(typeof treeLoro.subscribe).toBe("function")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("should support loro() on TreeNodeRef", () => {
|
|
28
|
+
const StateNodeDataShape = Shape.struct({
|
|
29
|
+
name: Shape.plain.string(),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const Schema = Shape.doc({
|
|
33
|
+
states: Shape.tree(StateNodeDataShape),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const doc = createTypedDoc(Schema)
|
|
37
|
+
|
|
38
|
+
doc.change(draft => {
|
|
39
|
+
const root = draft.states.createNode({ name: "root" })
|
|
40
|
+
|
|
41
|
+
// This should compile and run without error
|
|
42
|
+
const nodeLoro = loro(root)
|
|
43
|
+
|
|
44
|
+
expect(nodeLoro).toBeDefined()
|
|
45
|
+
expect(nodeLoro.doc).toBeDefined()
|
|
46
|
+
expect(nodeLoro.container).toBeDefined()
|
|
47
|
+
// subscribe might be a no-op or undefined depending on LoroTreeNode support
|
|
48
|
+
// but the method should exist on the interface
|
|
49
|
+
expect(typeof nodeLoro.subscribe).toBe("function")
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { LoroDoc, LoroTreeNode } from "loro-crdt"
|
|
1
|
+
import type { LoroDoc, LoroTreeNode, Subscription } from "loro-crdt"
|
|
2
2
|
import { deriveShapePlaceholder } from "../derive-placeholder.js"
|
|
3
|
+
import type { LoroTreeNodeRef } from "../loro.js"
|
|
3
4
|
import type { StructContainerShape } from "../shape.js"
|
|
4
5
|
import type { Infer } from "../types.js"
|
|
5
6
|
import {
|
|
@@ -24,6 +25,7 @@ export class TreeNodeRefInternals<DataShape extends StructContainerShape>
|
|
|
24
25
|
implements RefInternalsBase
|
|
25
26
|
{
|
|
26
27
|
private dataRef: StructRef<DataShape["shapes"]> | undefined
|
|
28
|
+
private loroNamespace: LoroTreeNodeRef | undefined
|
|
27
29
|
|
|
28
30
|
constructor(private readonly params: TreeNodeRefParams<DataShape>) {}
|
|
29
31
|
|
|
@@ -108,4 +110,35 @@ export class TreeNodeRefInternals<DataShape extends StructContainerShape>
|
|
|
108
110
|
this.dataRef[INTERNAL_SYMBOL].absorbPlainValues()
|
|
109
111
|
}
|
|
110
112
|
}
|
|
113
|
+
|
|
114
|
+
/** Get the loro namespace (cached) */
|
|
115
|
+
getLoroNamespace(): LoroTreeNodeRef {
|
|
116
|
+
if (!this.loroNamespace) {
|
|
117
|
+
this.loroNamespace = this.createLoroNamespace()
|
|
118
|
+
}
|
|
119
|
+
return this.loroNamespace
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Create the loro namespace for tree node */
|
|
123
|
+
protected createLoroNamespace(): LoroTreeNodeRef {
|
|
124
|
+
const self = this
|
|
125
|
+
return {
|
|
126
|
+
get doc(): LoroDoc {
|
|
127
|
+
return self.getDoc()
|
|
128
|
+
},
|
|
129
|
+
get container(): LoroTreeNode {
|
|
130
|
+
return self.getNode()
|
|
131
|
+
},
|
|
132
|
+
subscribe(callback: (event: unknown) => void): Subscription {
|
|
133
|
+
// LoroTreeNode doesn't have subscribe, but we can subscribe to the tree
|
|
134
|
+
// However, LoroRefBase expects subscribe.
|
|
135
|
+
// For now, we can throw or return a dummy subscription if LoroTreeNode doesn't support it.
|
|
136
|
+
// But wait, LoroTreeNode is just a handle.
|
|
137
|
+
// Let's check if LoroTreeNode has subscribe.
|
|
138
|
+
// If not, we might need to subscribe to the tree and filter?
|
|
139
|
+
// Or maybe we just cast it if it exists at runtime.
|
|
140
|
+
return (self.getNode() as any).subscribe?.(callback) || (() => {})
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
111
144
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TreeID } from "loro-crdt"
|
|
1
2
|
import { describe, expect, it } from "vitest"
|
|
2
3
|
import { change, createTypedDoc, Shape } from "../index.js"
|
|
3
4
|
|
|
@@ -25,7 +26,7 @@ describe("TreeNodeRef.data value updates across change() calls", () => {
|
|
|
25
26
|
const doc = createTypedDoc(Schema)
|
|
26
27
|
|
|
27
28
|
// Create a node with initial data
|
|
28
|
-
let nodeId:
|
|
29
|
+
let nodeId: TreeID | undefined
|
|
29
30
|
change(doc, draft => {
|
|
30
31
|
const node = draft.states.createNode({ name: "initial", value: 100 })
|
|
31
32
|
nodeId = node.id
|
|
@@ -38,8 +39,9 @@ describe("TreeNodeRef.data value updates across change() calls", () => {
|
|
|
38
39
|
expect(node?.data.value).toBe(100)
|
|
39
40
|
|
|
40
41
|
// Update the node data in a new change()
|
|
42
|
+
const capturedNodeId = nodeId
|
|
41
43
|
change(doc, draft => {
|
|
42
|
-
const draftNode = draft.states.getNodeByID(
|
|
44
|
+
const draftNode = draft.states.getNodeByID(capturedNodeId)
|
|
43
45
|
if (draftNode) {
|
|
44
46
|
draftNode.data.name = "updated"
|
|
45
47
|
draftNode.data.value = 999
|
|
@@ -63,19 +65,20 @@ describe("TreeNodeRef.data value updates across change() calls", () => {
|
|
|
63
65
|
|
|
64
66
|
const doc = createTypedDoc(Schema)
|
|
65
67
|
|
|
66
|
-
let nodeId:
|
|
68
|
+
let nodeId: TreeID | undefined
|
|
67
69
|
change(doc, draft => {
|
|
68
70
|
const node = draft.tree.createNode({ count: 0 })
|
|
69
71
|
nodeId = node.id
|
|
70
72
|
})
|
|
71
73
|
if (!nodeId) throw new Error("nodeId should be defined")
|
|
72
74
|
|
|
73
|
-
const
|
|
75
|
+
const capturedNodeId = nodeId
|
|
76
|
+
const node = doc.tree.getNodeByID(capturedNodeId)
|
|
74
77
|
|
|
75
78
|
// Multiple updates
|
|
76
79
|
for (let i = 1; i <= 5; i++) {
|
|
77
80
|
change(doc, draft => {
|
|
78
|
-
const draftNode = draft.tree.getNodeByID(
|
|
81
|
+
const draftNode = draft.tree.getNodeByID(capturedNodeId)
|
|
79
82
|
if (draftNode) {
|
|
80
83
|
draftNode.data.count = i
|
|
81
84
|
}
|
|
@@ -95,18 +98,19 @@ describe("TreeNodeRef.data value updates across change() calls", () => {
|
|
|
95
98
|
|
|
96
99
|
const doc = createTypedDoc(Schema)
|
|
97
100
|
|
|
98
|
-
let nodeId:
|
|
101
|
+
let nodeId: TreeID | undefined
|
|
99
102
|
change(doc, draft => {
|
|
100
103
|
const node = draft.nodes.createNode({ active: true })
|
|
101
104
|
nodeId = node.id
|
|
102
105
|
})
|
|
103
106
|
if (!nodeId) throw new Error("nodeId should be defined")
|
|
104
107
|
|
|
105
|
-
const
|
|
108
|
+
const capturedNodeId = nodeId
|
|
109
|
+
const node = doc.nodes.getNodeByID(capturedNodeId)
|
|
106
110
|
expect(node?.data.active).toBe(true)
|
|
107
111
|
|
|
108
112
|
change(doc, draft => {
|
|
109
|
-
const draftNode = draft.nodes.getNodeByID(
|
|
113
|
+
const draftNode = draft.nodes.getNodeByID(capturedNodeId)
|
|
110
114
|
if (draftNode) {
|
|
111
115
|
draftNode.data.active = false
|
|
112
116
|
}
|
|
@@ -129,7 +133,7 @@ describe("TreeNodeRef.data value updates across change() calls", () => {
|
|
|
129
133
|
|
|
130
134
|
const doc = createTypedDoc(Schema)
|
|
131
135
|
|
|
132
|
-
let nodeId:
|
|
136
|
+
let nodeId: TreeID | undefined
|
|
133
137
|
change(doc, draft => {
|
|
134
138
|
const node = draft.states.createNode({ name: "state1", facts: {} })
|
|
135
139
|
node.data.facts.set("key1", "value1")
|
|
@@ -137,13 +141,14 @@ describe("TreeNodeRef.data value updates across change() calls", () => {
|
|
|
137
141
|
})
|
|
138
142
|
if (!nodeId) throw new Error("nodeId should be defined")
|
|
139
143
|
|
|
140
|
-
const
|
|
144
|
+
const capturedNodeId = nodeId
|
|
145
|
+
const node = doc.states.getNodeByID(capturedNodeId)
|
|
141
146
|
expect(node?.data.name).toBe("state1")
|
|
142
147
|
expect(node?.data.facts.get("key1")).toBe("value1")
|
|
143
148
|
|
|
144
149
|
// Update both the plain value and the record
|
|
145
150
|
change(doc, draft => {
|
|
146
|
-
const draftNode = draft.states.getNodeByID(
|
|
151
|
+
const draftNode = draft.states.getNodeByID(capturedNodeId)
|
|
147
152
|
if (draftNode) {
|
|
148
153
|
draftNode.data.name = "state2"
|
|
149
154
|
draftNode.data.facts.set("key1", "updated")
|
|
@@ -171,8 +176,8 @@ describe("TreeNodeRef.data value updates across change() calls", () => {
|
|
|
171
176
|
|
|
172
177
|
const doc = createTypedDoc(Schema)
|
|
173
178
|
|
|
174
|
-
let parentId:
|
|
175
|
-
let childId:
|
|
179
|
+
let parentId: TreeID | undefined
|
|
180
|
+
let childId: TreeID | undefined
|
|
176
181
|
change(doc, draft => {
|
|
177
182
|
const parent = draft.tree.createNode({ label: "parent" })
|
|
178
183
|
const child = parent.createNode({ label: "child" })
|
|
@@ -182,15 +187,17 @@ describe("TreeNodeRef.data value updates across change() calls", () => {
|
|
|
182
187
|
if (!parentId) throw new Error("parentId should be defined")
|
|
183
188
|
if (!childId) throw new Error("childId should be defined")
|
|
184
189
|
|
|
185
|
-
const
|
|
186
|
-
const
|
|
190
|
+
const capturedParentId = parentId
|
|
191
|
+
const capturedChildId = childId
|
|
192
|
+
const parent = doc.tree.getNodeByID(capturedParentId)
|
|
193
|
+
const child = doc.tree.getNodeByID(capturedChildId)
|
|
187
194
|
expect(parent?.data.label).toBe("parent")
|
|
188
195
|
expect(child?.data.label).toBe("child")
|
|
189
196
|
|
|
190
197
|
// Update both nodes
|
|
191
198
|
change(doc, draft => {
|
|
192
|
-
const draftParent = draft.tree.getNodeByID(
|
|
193
|
-
const draftChild = draft.tree.getNodeByID(
|
|
199
|
+
const draftParent = draft.tree.getNodeByID(capturedParentId)
|
|
200
|
+
const draftChild = draft.tree.getNodeByID(capturedChildId)
|
|
194
201
|
if (draftParent) draftParent.data.label = "parent-updated"
|
|
195
202
|
if (draftChild) draftChild.data.label = "child-updated"
|
|
196
203
|
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LoroDoc, LoroTreeNode, TreeID } from "loro-crdt"
|
|
2
|
+
import { LORO_SYMBOL, type LoroTreeNodeRef } from "../loro.js"
|
|
2
3
|
import type { StructContainerShape } from "../shape.js"
|
|
3
4
|
import type { Infer } from "../types.js"
|
|
4
5
|
import { INTERNAL_SYMBOL } from "./base.js"
|
|
@@ -40,6 +41,13 @@ export class TreeNodeRef<DataShape extends StructContainerShape> {
|
|
|
40
41
|
this[INTERNAL_SYMBOL] = new TreeNodeRefInternals(params)
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Access the loro() namespace via the well-known symbol.
|
|
46
|
+
*/
|
|
47
|
+
get [LORO_SYMBOL](): LoroTreeNodeRef {
|
|
48
|
+
return this[INTERNAL_SYMBOL].getLoroNamespace()
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
/**
|
|
44
52
|
* The unique TreeID of this node.
|
|
45
53
|
*/
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TreeID } from "loro-crdt"
|
|
1
2
|
import { describe, expect, it } from "vitest"
|
|
2
3
|
import { change } from "../functional-helpers.js"
|
|
3
4
|
import { Shape } from "../shape.js"
|
|
@@ -140,8 +141,8 @@ describe("TreeRef", () => {
|
|
|
140
141
|
it("should navigate parent/children relationships", () => {
|
|
141
142
|
const typedDoc = createTypedDoc(ResmSchema)
|
|
142
143
|
|
|
143
|
-
let rootId:
|
|
144
|
-
let childId:
|
|
144
|
+
let rootId: TreeID | undefined
|
|
145
|
+
let childId: TreeID | undefined
|
|
145
146
|
|
|
146
147
|
change(typedDoc, draft => {
|
|
147
148
|
const root = draft.states.createNode()
|
|
@@ -153,21 +154,27 @@ describe("TreeRef", () => {
|
|
|
153
154
|
childId = child.id
|
|
154
155
|
})
|
|
155
156
|
|
|
157
|
+
expect(rootId).toBeDefined()
|
|
158
|
+
expect(childId).toBeDefined()
|
|
159
|
+
if (!rootId || !childId) return
|
|
160
|
+
|
|
161
|
+
const capturedRootId = rootId
|
|
162
|
+
const capturedChildId = childId
|
|
156
163
|
change(typedDoc, draft => {
|
|
157
|
-
const root = draft.states.getNodeByID(
|
|
164
|
+
const root = draft.states.getNodeByID(capturedRootId)
|
|
158
165
|
expect(root).toBeDefined()
|
|
159
166
|
expect(root?.children()).toHaveLength(1)
|
|
160
167
|
|
|
161
|
-
const child = draft.states.getNodeByID(
|
|
168
|
+
const child = draft.states.getNodeByID(capturedChildId)
|
|
162
169
|
expect(child).toBeDefined()
|
|
163
|
-
expect(child?.parent()?.id).toBe(
|
|
170
|
+
expect(child?.parent()?.id).toBe(capturedRootId)
|
|
164
171
|
})
|
|
165
172
|
})
|
|
166
173
|
|
|
167
174
|
it("should get node by ID", () => {
|
|
168
175
|
const typedDoc = createTypedDoc(ResmSchema)
|
|
169
176
|
|
|
170
|
-
let nodeId:
|
|
177
|
+
let nodeId: TreeID | undefined
|
|
171
178
|
|
|
172
179
|
change(typedDoc, draft => {
|
|
173
180
|
const root = draft.states.createNode()
|
|
@@ -175,8 +182,12 @@ describe("TreeRef", () => {
|
|
|
175
182
|
nodeId = root.id
|
|
176
183
|
})
|
|
177
184
|
|
|
185
|
+
expect(nodeId).toBeDefined()
|
|
186
|
+
if (!nodeId) return
|
|
187
|
+
|
|
188
|
+
const capturedNodeId = nodeId
|
|
178
189
|
change(typedDoc, draft => {
|
|
179
|
-
const node = draft.states.getNodeByID(
|
|
190
|
+
const node = draft.states.getNodeByID(capturedNodeId)
|
|
180
191
|
expect(node).toBeDefined()
|
|
181
192
|
expect(node?.data.name.toString()).toBe("test")
|
|
182
193
|
})
|
|
@@ -185,16 +196,20 @@ describe("TreeRef", () => {
|
|
|
185
196
|
it("should check if node exists with has()", () => {
|
|
186
197
|
const typedDoc = createTypedDoc(ResmSchema)
|
|
187
198
|
|
|
188
|
-
let nodeId:
|
|
199
|
+
let nodeId: TreeID | undefined
|
|
189
200
|
|
|
190
201
|
change(typedDoc, draft => {
|
|
191
202
|
const root = draft.states.createNode()
|
|
192
203
|
nodeId = root.id
|
|
193
204
|
})
|
|
194
205
|
|
|
206
|
+
expect(nodeId).toBeDefined()
|
|
207
|
+
if (!nodeId) return
|
|
208
|
+
|
|
209
|
+
const capturedNodeId = nodeId
|
|
195
210
|
change(typedDoc, draft => {
|
|
196
|
-
expect(draft.states.has(
|
|
197
|
-
expect(draft.states.has("0@999" as
|
|
211
|
+
expect(draft.states.has(capturedNodeId)).toBe(true)
|
|
212
|
+
expect(draft.states.has("0@999" as TreeID)).toBe(false)
|
|
198
213
|
})
|
|
199
214
|
})
|
|
200
215
|
})
|
|
@@ -203,15 +218,19 @@ describe("TreeRef", () => {
|
|
|
203
218
|
it("should delete a node", () => {
|
|
204
219
|
const typedDoc = createTypedDoc(ResmSchema)
|
|
205
220
|
|
|
206
|
-
let nodeId:
|
|
221
|
+
let nodeId: TreeID | undefined
|
|
207
222
|
|
|
208
223
|
change(typedDoc, draft => {
|
|
209
224
|
const root = draft.states.createNode()
|
|
210
225
|
nodeId = root.id
|
|
211
226
|
})
|
|
212
227
|
|
|
228
|
+
expect(nodeId).toBeDefined()
|
|
229
|
+
if (!nodeId) return
|
|
230
|
+
|
|
231
|
+
const capturedNodeId = nodeId
|
|
213
232
|
change(typedDoc, draft => {
|
|
214
|
-
draft.states.delete(
|
|
233
|
+
draft.states.delete(capturedNodeId)
|
|
215
234
|
})
|
|
216
235
|
|
|
217
236
|
const json = typedDoc.toJSON()
|
|
@@ -221,9 +240,9 @@ describe("TreeRef", () => {
|
|
|
221
240
|
it("should move nodes between parents", () => {
|
|
222
241
|
const typedDoc = createTypedDoc(ResmSchema)
|
|
223
242
|
|
|
224
|
-
let root1Id:
|
|
225
|
-
let root2Id:
|
|
226
|
-
let childId:
|
|
243
|
+
let root1Id: TreeID | undefined
|
|
244
|
+
let root2Id: TreeID | undefined
|
|
245
|
+
let childId: TreeID | undefined
|
|
227
246
|
|
|
228
247
|
change(typedDoc, draft => {
|
|
229
248
|
const root1 = draft.states.createNode()
|
|
@@ -239,18 +258,27 @@ describe("TreeRef", () => {
|
|
|
239
258
|
childId = child.id
|
|
240
259
|
})
|
|
241
260
|
|
|
261
|
+
expect(root1Id).toBeDefined()
|
|
262
|
+
expect(root2Id).toBeDefined()
|
|
263
|
+
expect(childId).toBeDefined()
|
|
264
|
+
if (!root1Id || !root2Id || !childId) return
|
|
265
|
+
|
|
266
|
+
const capturedRoot1Id = root1Id
|
|
267
|
+
const capturedRoot2Id = root2Id
|
|
268
|
+
const capturedChildId = childId
|
|
269
|
+
|
|
242
270
|
// Move child from root1 to root2
|
|
243
271
|
change(typedDoc, draft => {
|
|
244
|
-
const child = draft.states.getNodeByID(
|
|
245
|
-
const root2 = draft.states.getNodeByID(
|
|
272
|
+
const child = draft.states.getNodeByID(capturedChildId)
|
|
273
|
+
const root2 = draft.states.getNodeByID(capturedRoot2Id)
|
|
246
274
|
if (child && root2) {
|
|
247
275
|
child.move(root2)
|
|
248
276
|
}
|
|
249
277
|
})
|
|
250
278
|
|
|
251
279
|
change(typedDoc, draft => {
|
|
252
|
-
const root1 = draft.states.getNodeByID(
|
|
253
|
-
const root2 = draft.states.getNodeByID(
|
|
280
|
+
const root1 = draft.states.getNodeByID(capturedRoot1Id)
|
|
281
|
+
const root2 = draft.states.getNodeByID(capturedRoot2Id)
|
|
254
282
|
|
|
255
283
|
expect(root1?.children()).toHaveLength(0)
|
|
256
284
|
expect(root2?.children()).toHaveLength(1)
|
|
@@ -360,18 +388,22 @@ describe("TreeRef", () => {
|
|
|
360
388
|
it("should track deleted nodes with isDeleted()", () => {
|
|
361
389
|
const typedDoc = createTypedDoc(ResmSchema)
|
|
362
390
|
|
|
363
|
-
let nodeId:
|
|
391
|
+
let nodeId: TreeID | undefined
|
|
364
392
|
|
|
365
393
|
change(typedDoc, draft => {
|
|
366
394
|
const root = draft.states.createNode()
|
|
367
395
|
nodeId = root.id
|
|
368
396
|
})
|
|
369
397
|
|
|
398
|
+
expect(nodeId).toBeDefined()
|
|
399
|
+
if (!nodeId) return
|
|
400
|
+
|
|
401
|
+
const capturedNodeId = nodeId
|
|
370
402
|
change(typedDoc, draft => {
|
|
371
|
-
const node = draft.states.getNodeByID(
|
|
403
|
+
const node = draft.states.getNodeByID(capturedNodeId)
|
|
372
404
|
if (node) {
|
|
373
405
|
expect(node.isDeleted()).toBe(false)
|
|
374
|
-
draft.states.delete(
|
|
406
|
+
draft.states.delete(capturedNodeId)
|
|
375
407
|
}
|
|
376
408
|
})
|
|
377
409
|
|
|
@@ -112,11 +112,18 @@ export class TreeRef<DataShape extends StructContainerShape> extends TypedRef<
|
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
114
|
* Get all nodes in the tree (unordered).
|
|
115
|
-
*
|
|
115
|
+
* By default, excludes deleted nodes (tombstones).
|
|
116
|
+
*
|
|
117
|
+
* @param options.includeDeleted - If true, includes deleted nodes. Default: false.
|
|
118
|
+
* @returns Array of TreeNodeRef for matching nodes
|
|
116
119
|
*/
|
|
117
|
-
nodes(): TreeNodeRef<DataShape>[] {
|
|
120
|
+
nodes(options?: { includeDeleted?: boolean }): TreeNodeRef<DataShape>[] {
|
|
118
121
|
const container = this[INTERNAL_SYMBOL].getContainer() as LoroTree
|
|
119
|
-
|
|
122
|
+
const allNodes = container.nodes()
|
|
123
|
+
const filtered = options?.includeDeleted
|
|
124
|
+
? allNodes
|
|
125
|
+
: allNodes.filter(node => !node.isDeleted())
|
|
126
|
+
return filtered.map(node => this.getOrCreateNodeRef(node))
|
|
120
127
|
}
|
|
121
128
|
|
|
122
129
|
/**
|