@loro-extended/change 2.0.0 → 4.0.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.
@@ -0,0 +1,384 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { change } from "../functional-helpers.js"
3
+ import { Shape } from "../shape.js"
4
+ import { createTypedDoc } from "../typed-doc.js"
5
+
6
+ // Define the state machine schema from the plan
7
+ const StateNodeDataShape = Shape.struct({
8
+ name: Shape.text(),
9
+ facts: Shape.record(Shape.plain.any()),
10
+ rules: Shape.list(
11
+ Shape.plain.struct({
12
+ name: Shape.plain.string(),
13
+ rego: Shape.plain.string(),
14
+ description: Shape.plain.string().nullable(),
15
+ }),
16
+ ),
17
+ })
18
+
19
+ const ResmSchema = Shape.doc({
20
+ states: Shape.tree(StateNodeDataShape),
21
+ currentPath: Shape.list(Shape.plain.string()),
22
+ input: Shape.record(Shape.plain.any()),
23
+ })
24
+
25
+ describe("TreeRef", () => {
26
+ describe("basic operations", () => {
27
+ it("should create a root node with typed data", () => {
28
+ const typedDoc = createTypedDoc(ResmSchema)
29
+
30
+ change(typedDoc, draft => {
31
+ const root = draft.states.createNode()
32
+ expect(root).toBeDefined()
33
+ expect(root.id).toBeDefined()
34
+ })
35
+ })
36
+
37
+ it("should create a root node and set data", () => {
38
+ const typedDoc = createTypedDoc(ResmSchema)
39
+
40
+ change(typedDoc, draft => {
41
+ const root = draft.states.createNode()
42
+ // Set data after creation
43
+ root.data.name.insert(0, "idle")
44
+ })
45
+
46
+ // Verify the data was set
47
+ const json = typedDoc.toJSON()
48
+ expect(json.states).toHaveLength(1)
49
+ expect(json.states[0].data.name).toBe("idle")
50
+ })
51
+
52
+ it("should create child nodes", () => {
53
+ const typedDoc = createTypedDoc(ResmSchema)
54
+
55
+ change(typedDoc, draft => {
56
+ const root = draft.states.createNode()
57
+ root.data.name.insert(0, "idle")
58
+
59
+ const child = root.createNode()
60
+ child.data.name.insert(0, "running")
61
+ })
62
+
63
+ const json = typedDoc.toJSON()
64
+ expect(json.states).toHaveLength(1)
65
+ expect(json.states[0].data.name).toBe("idle")
66
+ expect(json.states[0].children).toHaveLength(1)
67
+ expect(json.states[0].children[0].data.name).toBe("running")
68
+ })
69
+
70
+ it("should access node.data with type safety", () => {
71
+ const typedDoc = createTypedDoc(ResmSchema)
72
+
73
+ change(typedDoc, draft => {
74
+ const root = draft.states.createNode()
75
+ root.data.name.insert(0, "idle")
76
+
77
+ // Access nested list container
78
+ root.data.rules.push({
79
+ name: "rule1",
80
+ rego: "package test",
81
+ description: null,
82
+ })
83
+ })
84
+
85
+ const json = typedDoc.toJSON()
86
+ expect(json.states[0].data.name).toBe("idle")
87
+ expect(json.states[0].data.rules).toEqual([
88
+ { name: "rule1", rego: "package test", description: null },
89
+ ])
90
+ })
91
+
92
+ it("should access record containers in node.data", () => {
93
+ const typedDoc = createTypedDoc(ResmSchema)
94
+
95
+ change(typedDoc, draft => {
96
+ const root = draft.states.createNode()
97
+ root.data.name.insert(0, "idle")
98
+
99
+ // Access nested record container - use set method
100
+ const facts = root.data.facts
101
+ facts.set("key1", "value1")
102
+ })
103
+
104
+ const json = typedDoc.toJSON()
105
+ expect(json.states[0].data.name).toBe("idle")
106
+ expect(json.states[0].data.facts).toEqual({ key1: "value1" })
107
+ })
108
+ })
109
+
110
+ describe("tree navigation", () => {
111
+ it("should get roots", () => {
112
+ const typedDoc = createTypedDoc(ResmSchema)
113
+
114
+ change(typedDoc, draft => {
115
+ draft.states.createNode()
116
+ draft.states.createNode()
117
+ })
118
+
119
+ change(typedDoc, draft => {
120
+ const roots = draft.states.roots()
121
+ expect(roots).toHaveLength(2)
122
+ })
123
+ })
124
+
125
+ it("should get all nodes", () => {
126
+ const typedDoc = createTypedDoc(ResmSchema)
127
+
128
+ change(typedDoc, draft => {
129
+ const root = draft.states.createNode()
130
+ root.createNode()
131
+ root.createNode()
132
+ })
133
+
134
+ change(typedDoc, draft => {
135
+ const nodes = draft.states.nodes()
136
+ expect(nodes).toHaveLength(3) // 1 root + 2 children
137
+ })
138
+ })
139
+
140
+ it("should navigate parent/children relationships", () => {
141
+ const typedDoc = createTypedDoc(ResmSchema)
142
+
143
+ let rootId: string | undefined
144
+ let childId: string | undefined
145
+
146
+ change(typedDoc, draft => {
147
+ const root = draft.states.createNode()
148
+ root.data.name.insert(0, "root")
149
+ rootId = root.id
150
+
151
+ const child = root.createNode()
152
+ child.data.name.insert(0, "child")
153
+ childId = child.id
154
+ })
155
+
156
+ change(typedDoc, draft => {
157
+ const root = draft.states.getNodeByID(rootId as string)
158
+ expect(root).toBeDefined()
159
+ expect(root?.children()).toHaveLength(1)
160
+
161
+ const child = draft.states.getNodeByID(childId as string)
162
+ expect(child).toBeDefined()
163
+ expect(child?.parent()?.id).toBe(rootId)
164
+ })
165
+ })
166
+
167
+ it("should get node by ID", () => {
168
+ const typedDoc = createTypedDoc(ResmSchema)
169
+
170
+ let nodeId: string | undefined
171
+
172
+ change(typedDoc, draft => {
173
+ const root = draft.states.createNode()
174
+ root.data.name.insert(0, "test")
175
+ nodeId = root.id
176
+ })
177
+
178
+ change(typedDoc, draft => {
179
+ const node = draft.states.getNodeByID(nodeId as string)
180
+ expect(node).toBeDefined()
181
+ expect(node?.data.name.toString()).toBe("test")
182
+ })
183
+ })
184
+
185
+ it("should check if node exists with has()", () => {
186
+ const typedDoc = createTypedDoc(ResmSchema)
187
+
188
+ let nodeId: string | undefined
189
+
190
+ change(typedDoc, draft => {
191
+ const root = draft.states.createNode()
192
+ nodeId = root.id
193
+ })
194
+
195
+ change(typedDoc, draft => {
196
+ expect(draft.states.has(nodeId as string)).toBe(true)
197
+ expect(draft.states.has("0@999" as any)).toBe(false)
198
+ })
199
+ })
200
+ })
201
+
202
+ describe("tree mutations", () => {
203
+ it("should delete a node", () => {
204
+ const typedDoc = createTypedDoc(ResmSchema)
205
+
206
+ let nodeId: string | undefined
207
+
208
+ change(typedDoc, draft => {
209
+ const root = draft.states.createNode()
210
+ nodeId = root.id
211
+ })
212
+
213
+ change(typedDoc, draft => {
214
+ draft.states.delete(nodeId as string)
215
+ })
216
+
217
+ const json = typedDoc.toJSON()
218
+ expect(json.states).toHaveLength(0)
219
+ })
220
+
221
+ it("should move nodes between parents", () => {
222
+ const typedDoc = createTypedDoc(ResmSchema)
223
+
224
+ let root1Id: string | undefined
225
+ let root2Id: string | undefined
226
+ let childId: string | undefined
227
+
228
+ change(typedDoc, draft => {
229
+ const root1 = draft.states.createNode()
230
+ root1.data.name.insert(0, "root1")
231
+ root1Id = root1.id
232
+
233
+ const root2 = draft.states.createNode()
234
+ root2.data.name.insert(0, "root2")
235
+ root2Id = root2.id
236
+
237
+ const child = root1.createNode()
238
+ child.data.name.insert(0, "child")
239
+ childId = child.id
240
+ })
241
+
242
+ // Move child from root1 to root2
243
+ change(typedDoc, draft => {
244
+ const child = draft.states.getNodeByID(childId as string)
245
+ const root2 = draft.states.getNodeByID(root2Id as string)
246
+ if (child && root2) {
247
+ child.move(root2)
248
+ }
249
+ })
250
+
251
+ change(typedDoc, draft => {
252
+ const root1 = draft.states.getNodeByID(root1Id as string)
253
+ const root2 = draft.states.getNodeByID(root2Id as string)
254
+
255
+ expect(root1?.children()).toHaveLength(0)
256
+ expect(root2?.children()).toHaveLength(1)
257
+ })
258
+ })
259
+ })
260
+
261
+ describe("serialization", () => {
262
+ it("should serialize tree to nested JSON with toJSON()", () => {
263
+ const typedDoc = createTypedDoc(ResmSchema)
264
+
265
+ change(typedDoc, draft => {
266
+ const root = draft.states.createNode()
267
+ root.data.name.insert(0, "root")
268
+
269
+ const child1 = root.createNode()
270
+ child1.data.name.insert(0, "child1")
271
+
272
+ const child2 = root.createNode()
273
+ child2.data.name.insert(0, "child2")
274
+
275
+ const grandchild = child1.createNode()
276
+ grandchild.data.name.insert(0, "grandchild")
277
+ })
278
+
279
+ const json = typedDoc.toJSON()
280
+ expect(json.states).toHaveLength(1)
281
+ expect(json.states[0].data.name).toBe("root")
282
+ expect(json.states[0].children).toHaveLength(2)
283
+ expect(json.states[0].children[0].children).toHaveLength(1)
284
+ })
285
+
286
+ it("should serialize tree to flat array with toArray()", () => {
287
+ const typedDoc = createTypedDoc(ResmSchema)
288
+
289
+ change(typedDoc, draft => {
290
+ const root = draft.states.createNode()
291
+ root.data.name.insert(0, "root")
292
+
293
+ const child = root.createNode()
294
+ child.data.name.insert(0, "child")
295
+ })
296
+
297
+ change(typedDoc, draft => {
298
+ const array = draft.states.toArray()
299
+ expect(array).toHaveLength(2)
300
+ // Each item should have id, parent, index, fractionalIndex, data
301
+ expect(array[0]).toHaveProperty("id")
302
+ expect(array[0]).toHaveProperty("parent")
303
+ expect(array[0]).toHaveProperty("index")
304
+ expect(array[0]).toHaveProperty("data")
305
+ })
306
+ })
307
+ })
308
+
309
+ describe("fractional index", () => {
310
+ it("should enable fractional index", () => {
311
+ const typedDoc = createTypedDoc(ResmSchema)
312
+
313
+ change(typedDoc, draft => {
314
+ draft.states.enableFractionalIndex(8)
315
+ const root = draft.states.createNode()
316
+ expect(root.fractionalIndex()).toBeDefined()
317
+ })
318
+ })
319
+
320
+ it("should get node index among siblings", () => {
321
+ const typedDoc = createTypedDoc(ResmSchema)
322
+
323
+ change(typedDoc, draft => {
324
+ const root = draft.states.createNode()
325
+ const child1 = root.createNode()
326
+ const child2 = root.createNode()
327
+
328
+ expect(child1.index()).toBe(0)
329
+ expect(child2.index()).toBe(1)
330
+ })
331
+ })
332
+ })
333
+
334
+ describe("absorbPlainValues", () => {
335
+ it("should propagate absorbPlainValues to all nodes", () => {
336
+ const typedDoc = createTypedDoc(ResmSchema)
337
+
338
+ change(typedDoc, draft => {
339
+ const root = draft.states.createNode()
340
+ root.data.name.insert(0, "test")
341
+ root.data.rules.push({
342
+ name: "rule1",
343
+ rego: "package test",
344
+ description: null,
345
+ })
346
+
347
+ // absorbPlainValues is called automatically at the end of change()
348
+ })
349
+
350
+ // Verify data was persisted
351
+ const json = typedDoc.toJSON()
352
+ expect(json.states[0].data.name).toBe("test")
353
+ expect(json.states[0].data.rules).toEqual([
354
+ { name: "rule1", rego: "package test", description: null },
355
+ ])
356
+ })
357
+ })
358
+
359
+ describe("node deletion tracking", () => {
360
+ it("should track deleted nodes with isDeleted()", () => {
361
+ const typedDoc = createTypedDoc(ResmSchema)
362
+
363
+ let nodeId: string | undefined
364
+
365
+ change(typedDoc, draft => {
366
+ const root = draft.states.createNode()
367
+ nodeId = root.id
368
+ })
369
+
370
+ change(typedDoc, draft => {
371
+ const node = draft.states.getNodeByID(nodeId as string)
372
+ if (node) {
373
+ expect(node.isDeleted()).toBe(false)
374
+ draft.states.delete(nodeId as string)
375
+ }
376
+ })
377
+
378
+ // After deletion, Loro's has() still returns true (node exists in history)
379
+ // but isDeleted() returns true and the node won't appear in toJSON()
380
+ const json = typedDoc.toJSON()
381
+ expect(json.states).toHaveLength(0)
382
+ })
383
+ })
384
+ })
@@ -1,40 +1,268 @@
1
- import type { TreeContainerShape } from "../shape.js"
1
+ import type { LoroDoc, LoroTree, LoroTreeNode, TreeID } from "loro-crdt"
2
+ import type {
3
+ StructContainerShape,
4
+ TreeContainerShape,
5
+ TreeNodeJSON,
6
+ } from "../shape.js"
2
7
  import type { Infer } from "../types.js"
3
- import { TypedRef } from "./base.js"
8
+ import { TreeNodeRef } from "./tree-node.js"
4
9
 
5
- // Tree typed ref
6
- export class TreeRef<T extends TreeContainerShape> extends TypedRef<T> {
7
- absorbPlainValues() {
8
- // TODO(duane): implement for trees
10
+ /**
11
+ * Parameters for creating a TreeRef.
12
+ */
13
+ export interface TreeRefParams<DataShape extends StructContainerShape> {
14
+ shape: TreeContainerShape<DataShape>
15
+ placeholder?: never[]
16
+ getContainer: () => LoroTree
17
+ autoCommit?: boolean
18
+ batchedMutation?: boolean
19
+ getDoc?: () => LoroDoc
20
+ }
21
+
22
+ /**
23
+ * Typed ref for tree (forest) containers.
24
+ * Wraps LoroTree with type-safe access to node metadata.
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * const StateNodeDataShape = Shape.struct({
29
+ * name: Shape.text(),
30
+ * facts: Shape.record(Shape.plain.any()),
31
+ * })
32
+ *
33
+ * doc.$.change(draft => {
34
+ * const root = draft.states.createNode({ name: "idle", facts: {} })
35
+ * const child = root.createNode({ name: "running", facts: {} })
36
+ * child.data.name = "active"
37
+ * })
38
+ * ```
39
+ */
40
+ export class TreeRef<DataShape extends StructContainerShape> {
41
+ private nodeCache = new Map<TreeID, TreeNodeRef<DataShape>>()
42
+ private _cachedContainer?: LoroTree
43
+ protected _params: TreeRefParams<DataShape>
44
+
45
+ constructor(params: TreeRefParams<DataShape>) {
46
+ this._params = params
47
+ }
48
+
49
+ protected get shape(): TreeContainerShape<DataShape> {
50
+ return this._params.shape
51
+ }
52
+
53
+ protected get container(): LoroTree {
54
+ if (!this._cachedContainer) {
55
+ this._cachedContainer = this._params.getContainer()
56
+ }
57
+ return this._cachedContainer
58
+ }
59
+
60
+ protected get dataShape(): DataShape {
61
+ return this.shape.shape
62
+ }
63
+
64
+ protected get autoCommit(): boolean {
65
+ return !!this._params.autoCommit
66
+ }
67
+
68
+ protected get batchedMutation(): boolean {
69
+ return !!this._params.batchedMutation
70
+ }
71
+
72
+ protected get doc(): LoroDoc | undefined {
73
+ return this._params.getDoc?.()
74
+ }
75
+
76
+ /**
77
+ * Commits changes if autoCommit is enabled.
78
+ */
79
+ protected commitIfAuto(): void {
80
+ if (this.autoCommit && this.doc) {
81
+ this.doc.commit()
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Absorb plain values from all cached nodes.
87
+ * Called before committing changes to ensure all pending values are written.
88
+ */
89
+ absorbPlainValues(): void {
90
+ for (const nodeRef of this.nodeCache.values()) {
91
+ nodeRef.absorbPlainValues()
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Get or create a TreeNodeRef for the given LoroTreeNode.
97
+ * Uses caching to ensure the same TreeNodeRef is returned for the same node.
98
+ */
99
+ getOrCreateNodeRef(node: LoroTreeNode): TreeNodeRef<DataShape> {
100
+ const id = node.id
101
+ let nodeRef = this.nodeCache.get(id)
102
+ if (!nodeRef) {
103
+ nodeRef = new TreeNodeRef({
104
+ node,
105
+ dataShape: this.dataShape,
106
+ treeRef: this,
107
+ autoCommit: this.autoCommit,
108
+ batchedMutation: this.batchedMutation,
109
+ getDoc: this._params.getDoc,
110
+ })
111
+ this.nodeCache.set(id, nodeRef)
112
+ }
113
+ return nodeRef
114
+ }
115
+
116
+ /**
117
+ * Create a new root node with optional initial data.
118
+ *
119
+ * @param initialData - Optional partial data to initialize the node with
120
+ * @returns The created TreeNodeRef
121
+ */
122
+ createNode(initialData?: Partial<Infer<DataShape>>): TreeNodeRef<DataShape> {
123
+ const loroNode = this.container.createNode()
124
+ const nodeRef = this.getOrCreateNodeRef(loroNode)
125
+
126
+ // Initialize data if provided
127
+ if (initialData) {
128
+ for (const [key, value] of Object.entries(initialData)) {
129
+ ;(nodeRef.data as any)[key] = value
130
+ }
131
+ }
132
+
133
+ this.commitIfAuto()
134
+ return nodeRef
135
+ }
136
+
137
+ /**
138
+ * Get all root nodes (nodes without parents).
139
+ * Returns nodes in their fractional index order.
140
+ */
141
+ roots(): TreeNodeRef<DataShape>[] {
142
+ return this.container.roots().map(node => this.getOrCreateNodeRef(node))
9
143
  }
10
144
 
11
- toJSON(): Infer<T> {
12
- // TODO(duane): implement proper tree serialization
13
- return this.container.toJSON() as Infer<T>
145
+ /**
146
+ * Get all nodes in the tree (unordered).
147
+ * Includes all nodes, not just roots.
148
+ */
149
+ nodes(): TreeNodeRef<DataShape>[] {
150
+ return this.container.nodes().map(node => this.getOrCreateNodeRef(node))
14
151
  }
15
152
 
16
- createNode(parent?: any, index?: number): any {
17
- this.assertMutable()
18
- return this.container.createNode(parent, index)
153
+ /**
154
+ * Get a node by its TreeID.
155
+ *
156
+ * @param id - The TreeID of the node to find
157
+ * @returns The TreeNodeRef if found, undefined otherwise
158
+ */
159
+ getNodeByID(id: TreeID): TreeNodeRef<DataShape> | undefined {
160
+ // Check cache first
161
+ const cached = this.nodeCache.get(id)
162
+ if (cached) return cached
163
+
164
+ // Check if node exists in tree
165
+ if (!this.container.has(id)) return undefined
166
+
167
+ // Find the node in the tree's nodes
168
+ const nodes = this.container.nodes()
169
+ const node = nodes.find(n => n.id === id)
170
+ if (!node) return undefined
171
+
172
+ return this.getOrCreateNodeRef(node)
173
+ }
174
+
175
+ /**
176
+ * Check if a node with the given ID exists in the tree.
177
+ */
178
+ has(id: TreeID): boolean {
179
+ return this.container.has(id)
19
180
  }
20
181
 
21
- move(target: any, parent?: any, index?: number): void {
22
- this.assertMutable()
23
- this.container.move(target, parent, index)
182
+ /**
183
+ * Delete a node and all its descendants.
184
+ * Also removes the node from the cache.
185
+ *
186
+ * @param target - The TreeID or TreeNodeRef to delete
187
+ */
188
+ delete(target: TreeID | TreeNodeRef<DataShape>): void {
189
+ const id = typeof target === "string" ? target : target.id
190
+ this.container.delete(id)
191
+ // Remove from cache
192
+ this.nodeCache.delete(id)
193
+ this.commitIfAuto()
24
194
  }
25
195
 
26
- delete(target: any): void {
27
- this.assertMutable()
28
- this.container.delete(target)
196
+ /**
197
+ * Enable fractional index generation for ordering.
198
+ *
199
+ * @param jitter - Optional jitter value to avoid conflicts (0 = no jitter)
200
+ */
201
+ enableFractionalIndex(jitter = 0): void {
202
+ this.container.enableFractionalIndex(jitter)
29
203
  }
30
204
 
31
- has(target: any): boolean {
32
- return this.container.has(target)
205
+ /**
206
+ * Serialize the tree to a nested JSON structure.
207
+ * Each node includes its data and children recursively.
208
+ */
209
+ toJSON(): TreeNodeJSON<DataShape>[] {
210
+ // Use Loro's native toJSON which returns nested structure
211
+ const nativeJson = this.container.toJSON() as any[]
212
+ return this.transformNativeJson(nativeJson)
33
213
  }
34
214
 
35
- getNodeByID(id: any): any {
36
- return this.container.getNodeByID
37
- ? this.container.getNodeByID(id)
38
- : undefined
215
+ /**
216
+ * Transform Loro's native JSON format to our typed format.
217
+ */
218
+ private transformNativeJson(nodes: any[]): TreeNodeJSON<DataShape>[] {
219
+ return nodes.map(node => ({
220
+ id: node.id as TreeID,
221
+ parent: node.parent as TreeID | null,
222
+ index: node.index as number,
223
+ fractionalIndex: node.fractional_index as string,
224
+ data: node.meta as Infer<DataShape>,
225
+ children: this.transformNativeJson(node.children || []),
226
+ }))
227
+ }
228
+
229
+ /**
230
+ * Get a flat array representation of all nodes.
231
+ * Flattens the nested tree structure into a single array.
232
+ */
233
+ toArray(): Array<{
234
+ id: TreeID
235
+ parent: TreeID | null
236
+ index: number
237
+ fractionalIndex: string
238
+ data: Infer<DataShape>
239
+ }> {
240
+ const result: Array<{
241
+ id: TreeID
242
+ parent: TreeID | null
243
+ index: number
244
+ fractionalIndex: string
245
+ data: Infer<DataShape>
246
+ }> = []
247
+
248
+ // Flatten the nested structure
249
+ const flattenNodes = (nodes: any[]) => {
250
+ for (const node of nodes) {
251
+ result.push({
252
+ id: node.id as TreeID,
253
+ parent: node.parent as TreeID | null,
254
+ index: node.index as number,
255
+ fractionalIndex: node.fractional_index as string,
256
+ data: node.meta as Infer<DataShape>,
257
+ })
258
+ if (node.children && node.children.length > 0) {
259
+ flattenNodes(node.children)
260
+ }
261
+ }
262
+ }
263
+
264
+ const nativeJson = this.container.toJSON() as any[]
265
+ flattenNodes(nativeJson)
266
+ return result
39
267
  }
40
268
  }