@loro-extended/change 5.3.0 → 5.4.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,389 @@
1
+ import { LoroDoc, LoroList, UndoManager } from "loro-crdt"
2
+ import { describe, expect, it } from "vitest"
3
+ import { replayDiff } from "./replay-diff.js"
4
+
5
+ describe("replayDiff", () => {
6
+ it("should fire subscribeLocalUpdates for text changes", () => {
7
+ // Create source doc with text changes
8
+ const sourceDoc = new LoroDoc()
9
+ sourceDoc.setPeerId("1")
10
+ const sourceText = sourceDoc.getText("text")
11
+ const beforeFrontier = sourceDoc.frontiers()
12
+
13
+ sourceText.insert(0, "Hello World")
14
+ const afterFrontier = sourceDoc.frontiers()
15
+
16
+ // Get the diff
17
+ const diff = sourceDoc.diff(beforeFrontier, afterFrontier, false)
18
+
19
+ // Create target doc and track local updates
20
+ const targetDoc = new LoroDoc()
21
+ targetDoc.setPeerId("2")
22
+ targetDoc.getText("text") // Ensure container exists
23
+
24
+ const localUpdates: Uint8Array[] = []
25
+ targetDoc.subscribeLocalUpdates(update => {
26
+ localUpdates.push(update)
27
+ })
28
+
29
+ // Replay the diff
30
+ replayDiff(targetDoc, diff)
31
+ targetDoc.commit() // Commit to trigger subscribeLocalUpdates
32
+
33
+ // Verify local updates were fired
34
+ expect(localUpdates.length).toBeGreaterThan(0)
35
+
36
+ // Verify the text was applied
37
+ expect(targetDoc.getText("text").toString()).toBe("Hello World")
38
+ })
39
+
40
+ it("should fire subscribeLocalUpdates for map changes", () => {
41
+ // Create source doc with map changes
42
+ const sourceDoc = new LoroDoc()
43
+ sourceDoc.setPeerId("1")
44
+ const sourceMap = sourceDoc.getMap("map")
45
+ const beforeFrontier = sourceDoc.frontiers()
46
+
47
+ sourceMap.set("name", "Alice")
48
+ sourceMap.set("age", 30)
49
+ const afterFrontier = sourceDoc.frontiers()
50
+
51
+ // Get the diff
52
+ const diff = sourceDoc.diff(beforeFrontier, afterFrontier, false)
53
+
54
+ // Create target doc and track local updates
55
+ const targetDoc = new LoroDoc()
56
+ targetDoc.setPeerId("2")
57
+ targetDoc.getMap("map") // Ensure container exists
58
+
59
+ const localUpdates: Uint8Array[] = []
60
+ targetDoc.subscribeLocalUpdates(update => {
61
+ localUpdates.push(update)
62
+ })
63
+
64
+ // Replay the diff
65
+ replayDiff(targetDoc, diff)
66
+ targetDoc.commit() // Commit to trigger subscribeLocalUpdates
67
+
68
+ // Verify local updates were fired
69
+ expect(localUpdates.length).toBeGreaterThan(0)
70
+
71
+ // Verify the map was applied
72
+ const targetMap = targetDoc.getMap("map")
73
+ expect(targetMap.get("name")).toBe("Alice")
74
+ expect(targetMap.get("age")).toBe(30)
75
+ })
76
+
77
+ it("should fire subscribeLocalUpdates for list changes", () => {
78
+ // Create source doc with list changes
79
+ const sourceDoc = new LoroDoc()
80
+ sourceDoc.setPeerId("1")
81
+ const sourceList = sourceDoc.getList("list")
82
+ const beforeFrontier = sourceDoc.frontiers()
83
+
84
+ sourceList.insert(0, "first")
85
+ sourceList.insert(1, "second")
86
+ sourceList.insert(2, "third")
87
+ const afterFrontier = sourceDoc.frontiers()
88
+
89
+ // Get the diff
90
+ const diff = sourceDoc.diff(beforeFrontier, afterFrontier, false)
91
+
92
+ // Create target doc and track local updates
93
+ const targetDoc = new LoroDoc()
94
+ targetDoc.setPeerId("2")
95
+ targetDoc.getList("list") // Ensure container exists
96
+
97
+ const localUpdates: Uint8Array[] = []
98
+ targetDoc.subscribeLocalUpdates(update => {
99
+ localUpdates.push(update)
100
+ })
101
+
102
+ // Replay the diff
103
+ replayDiff(targetDoc, diff)
104
+ targetDoc.commit() // Commit to trigger subscribeLocalUpdates
105
+
106
+ // Verify local updates were fired
107
+ expect(localUpdates.length).toBeGreaterThan(0)
108
+
109
+ // Verify the list was applied
110
+ const targetList = targetDoc.getList("list")
111
+ expect(targetList.toJSON()).toEqual(["first", "second", "third"])
112
+ })
113
+
114
+ it("should fire subscribeLocalUpdates for counter changes", () => {
115
+ // Create source doc with counter changes
116
+ const sourceDoc = new LoroDoc()
117
+ sourceDoc.setPeerId("1")
118
+ const sourceCounter = sourceDoc.getCounter("counter")
119
+ const beforeFrontier = sourceDoc.frontiers()
120
+
121
+ sourceCounter.increment(5)
122
+ const afterFrontier = sourceDoc.frontiers()
123
+
124
+ // Get the diff
125
+ const diff = sourceDoc.diff(beforeFrontier, afterFrontier, false)
126
+
127
+ // Create target doc and track local updates
128
+ const targetDoc = new LoroDoc()
129
+ targetDoc.setPeerId("2")
130
+ targetDoc.getCounter("counter") // Ensure container exists
131
+
132
+ const localUpdates: Uint8Array[] = []
133
+ targetDoc.subscribeLocalUpdates(update => {
134
+ localUpdates.push(update)
135
+ })
136
+
137
+ // Replay the diff
138
+ replayDiff(targetDoc, diff)
139
+ targetDoc.commit() // Commit to trigger subscribeLocalUpdates
140
+
141
+ // Verify local updates were fired
142
+ expect(localUpdates.length).toBeGreaterThan(0)
143
+
144
+ // Verify the counter was applied
145
+ expect(targetDoc.getCounter("counter").toJSON()).toBe(5)
146
+ })
147
+
148
+ it("should work with UndoManager", () => {
149
+ // Create source doc with changes
150
+ const sourceDoc = new LoroDoc()
151
+ sourceDoc.setPeerId("1")
152
+ const sourceText = sourceDoc.getText("text")
153
+ const beforeFrontier = sourceDoc.frontiers()
154
+
155
+ sourceText.insert(0, "Hello")
156
+ const afterFrontier = sourceDoc.frontiers()
157
+
158
+ // Get the diff
159
+ const diff = sourceDoc.diff(beforeFrontier, afterFrontier, false)
160
+
161
+ // Create target doc with UndoManager
162
+ const targetDoc = new LoroDoc()
163
+ targetDoc.setPeerId("2")
164
+ const targetText = targetDoc.getText("text")
165
+
166
+ const undoManager = new UndoManager(targetDoc, {})
167
+
168
+ // Replay the diff
169
+ replayDiff(targetDoc, diff)
170
+ targetDoc.commit()
171
+
172
+ // Verify the text was applied
173
+ expect(targetText.toString()).toBe("Hello")
174
+
175
+ // Undo should work
176
+ const canUndo = undoManager.canUndo()
177
+ expect(canUndo).toBe(true)
178
+
179
+ undoManager.undo()
180
+ expect(targetText.toString()).toBe("")
181
+ })
182
+
183
+ it("should handle nested containers", () => {
184
+ // Create source doc with nested containers
185
+ const sourceDoc = new LoroDoc()
186
+ sourceDoc.setPeerId("1")
187
+ const sourceMap = sourceDoc.getMap("root")
188
+ const beforeFrontier = sourceDoc.frontiers()
189
+
190
+ // Create a nested structure: map -> list -> text
191
+ const nestedList = sourceMap.setContainer("items", new LoroList())
192
+ nestedList.insert(0, "item1")
193
+ nestedList.insert(1, "item2")
194
+
195
+ const afterFrontier = sourceDoc.frontiers()
196
+
197
+ // Get the diff
198
+ const diff = sourceDoc.diff(beforeFrontier, afterFrontier, false)
199
+
200
+ // Create target doc and track local updates
201
+ const targetDoc = new LoroDoc()
202
+ targetDoc.setPeerId("2")
203
+ targetDoc.getMap("root") // Ensure container exists
204
+
205
+ const localUpdates: Uint8Array[] = []
206
+ targetDoc.subscribeLocalUpdates(update => {
207
+ localUpdates.push(update)
208
+ })
209
+
210
+ // Replay the diff
211
+ replayDiff(targetDoc, diff)
212
+ targetDoc.commit() // Commit to trigger subscribeLocalUpdates
213
+
214
+ // Verify local updates were fired
215
+ expect(localUpdates.length).toBeGreaterThan(0)
216
+
217
+ // Verify the nested structure was applied
218
+ const targetMap = targetDoc.getMap("root")
219
+ const items = targetMap.get("items") as LoroList
220
+ expect(items).toBeDefined()
221
+ expect(items.toJSON()).toEqual(["item1", "item2"])
222
+ })
223
+
224
+ it("should handle map deletions", () => {
225
+ // Create source doc with initial state
226
+ const sourceDoc = new LoroDoc()
227
+ sourceDoc.setPeerId("1")
228
+ const sourceMap = sourceDoc.getMap("map")
229
+ sourceMap.set("keep", "value1")
230
+ sourceMap.set("remove", "value2")
231
+
232
+ const beforeFrontier = sourceDoc.frontiers()
233
+
234
+ // Delete a key
235
+ sourceMap.delete("remove")
236
+ const afterFrontier = sourceDoc.frontiers()
237
+
238
+ // Get the diff
239
+ const diff = sourceDoc.diff(beforeFrontier, afterFrontier, false)
240
+
241
+ // Create target doc with same initial state
242
+ const targetDoc = new LoroDoc()
243
+ targetDoc.setPeerId("2")
244
+ const targetMap = targetDoc.getMap("map")
245
+ targetMap.set("keep", "value1")
246
+ targetMap.set("remove", "value2")
247
+
248
+ const localUpdates: Uint8Array[] = []
249
+ targetDoc.subscribeLocalUpdates(update => {
250
+ localUpdates.push(update)
251
+ })
252
+
253
+ // Replay the diff
254
+ replayDiff(targetDoc, diff)
255
+ targetDoc.commit() // Commit to trigger subscribeLocalUpdates
256
+
257
+ // Verify local updates were fired
258
+ expect(localUpdates.length).toBeGreaterThan(0)
259
+
260
+ // Verify the deletion was applied
261
+ expect(targetMap.get("keep")).toBe("value1")
262
+ expect(targetMap.get("remove")).toBeUndefined()
263
+ })
264
+
265
+ it("should handle list deletions", () => {
266
+ // Create source doc with initial state
267
+ const sourceDoc = new LoroDoc()
268
+ sourceDoc.setPeerId("1")
269
+ const sourceList = sourceDoc.getList("list")
270
+ sourceList.insert(0, "a")
271
+ sourceList.insert(1, "b")
272
+ sourceList.insert(2, "c")
273
+
274
+ const beforeFrontier = sourceDoc.frontiers()
275
+
276
+ // Delete middle element
277
+ sourceList.delete(1, 1)
278
+ const afterFrontier = sourceDoc.frontiers()
279
+
280
+ // Get the diff
281
+ const diff = sourceDoc.diff(beforeFrontier, afterFrontier, false)
282
+
283
+ // Create target doc with same initial state
284
+ const targetDoc = new LoroDoc()
285
+ targetDoc.setPeerId("2")
286
+ const targetList = targetDoc.getList("list")
287
+ targetList.insert(0, "a")
288
+ targetList.insert(1, "b")
289
+ targetList.insert(2, "c")
290
+
291
+ const localUpdates: Uint8Array[] = []
292
+ targetDoc.subscribeLocalUpdates(update => {
293
+ localUpdates.push(update)
294
+ })
295
+
296
+ // Replay the diff
297
+ replayDiff(targetDoc, diff)
298
+ targetDoc.commit() // Commit to trigger subscribeLocalUpdates
299
+
300
+ // Verify local updates were fired
301
+ expect(localUpdates.length).toBeGreaterThan(0)
302
+
303
+ // Verify the deletion was applied
304
+ expect(targetList.toJSON()).toEqual(["a", "c"])
305
+ })
306
+
307
+ it("should handle counter decrement", () => {
308
+ // Create source doc with initial counter value
309
+ const sourceDoc = new LoroDoc()
310
+ sourceDoc.setPeerId("1")
311
+ const sourceCounter = sourceDoc.getCounter("counter")
312
+ sourceCounter.increment(10)
313
+
314
+ const beforeFrontier = sourceDoc.frontiers()
315
+
316
+ // Decrement
317
+ sourceCounter.decrement(3)
318
+ const afterFrontier = sourceDoc.frontiers()
319
+
320
+ // Get the diff
321
+ const diff = sourceDoc.diff(beforeFrontier, afterFrontier, false)
322
+
323
+ // Create target doc with same initial state
324
+ const targetDoc = new LoroDoc()
325
+ targetDoc.setPeerId("2")
326
+ const targetCounter = targetDoc.getCounter("counter")
327
+ targetCounter.increment(10)
328
+
329
+ const localUpdates: Uint8Array[] = []
330
+ targetDoc.subscribeLocalUpdates(update => {
331
+ localUpdates.push(update)
332
+ })
333
+
334
+ // Replay the diff
335
+ replayDiff(targetDoc, diff)
336
+ targetDoc.commit() // Commit to trigger subscribeLocalUpdates
337
+
338
+ // Verify local updates were fired
339
+ expect(localUpdates.length).toBeGreaterThan(0)
340
+
341
+ // Verify the decrement was applied
342
+ expect(targetCounter.toJSON()).toBe(7)
343
+ })
344
+
345
+ it("should handle text with attributes", () => {
346
+ // Create source doc with rich text
347
+ const sourceDoc = new LoroDoc()
348
+ sourceDoc.setPeerId("1")
349
+ sourceDoc.configTextStyle({ bold: { expand: "after" } })
350
+ const sourceText = sourceDoc.getText("text")
351
+ const beforeFrontier = sourceDoc.frontiers()
352
+
353
+ sourceText.insert(0, "Hello World")
354
+ sourceText.mark({ start: 0, end: 5 }, "bold", true)
355
+ const afterFrontier = sourceDoc.frontiers()
356
+
357
+ // Get the diff
358
+ const diff = sourceDoc.diff(beforeFrontier, afterFrontier, false)
359
+
360
+ // Create target doc
361
+ const targetDoc = new LoroDoc()
362
+ targetDoc.setPeerId("2")
363
+ targetDoc.configTextStyle({ bold: { expand: "after" } })
364
+ targetDoc.getText("text") // Ensure container exists
365
+
366
+ const localUpdates: Uint8Array[] = []
367
+ targetDoc.subscribeLocalUpdates(update => {
368
+ localUpdates.push(update)
369
+ })
370
+
371
+ // Replay the diff
372
+ replayDiff(targetDoc, diff)
373
+ targetDoc.commit() // Commit to trigger subscribeLocalUpdates
374
+
375
+ // Verify local updates were fired
376
+ expect(localUpdates.length).toBeGreaterThan(0)
377
+
378
+ // Verify the text was applied
379
+ const targetText = targetDoc.getText("text")
380
+ expect(targetText.toString()).toBe("Hello World")
381
+
382
+ // Verify the delta includes the bold attribute
383
+ const delta = targetText.toDelta()
384
+ expect(delta[0]).toMatchObject({
385
+ insert: "Hello",
386
+ attributes: { bold: true },
387
+ })
388
+ })
389
+ })
@@ -0,0 +1,229 @@
1
+ import type {
2
+ Container,
3
+ ContainerID,
4
+ CounterDiff,
5
+ Diff,
6
+ ListDiff,
7
+ LoroCounter,
8
+ LoroDoc,
9
+ LoroList,
10
+ LoroMap,
11
+ LoroMovableList,
12
+ LoroText,
13
+ LoroTree,
14
+ MapDiff,
15
+ TextDiff,
16
+ TreeDiff,
17
+ TreeDiffItem,
18
+ Value,
19
+ } from "loro-crdt"
20
+
21
+ /**
22
+ * Replay a diff as local operations on a document.
23
+ *
24
+ * Unlike doc.import() which creates import events, this creates LOCAL events
25
+ * that are captured by subscribeLocalUpdates() and UndoManager.
26
+ *
27
+ * @param doc - The target document to apply changes to
28
+ * @param diff - The diff from doc.diff(from, to, false)
29
+ */
30
+ export function replayDiff(doc: LoroDoc, diff: [ContainerID, Diff][]): void {
31
+ // Map from source container IDs to target containers
32
+ // This is needed because when we create new containers, they get different IDs
33
+ const containerMap = new Map<ContainerID, Container>()
34
+
35
+ for (const [containerId, containerDiff] of diff) {
36
+ // First, try to get the container from our map (for newly created containers)
37
+ let container = containerMap.get(containerId)
38
+
39
+ // If not in map, try to get it from the doc (for existing containers)
40
+ if (!container) {
41
+ container = doc.getContainerById(containerId)
42
+ }
43
+
44
+ if (!container) {
45
+ // Container doesn't exist yet - this can happen for newly created containers
46
+ // that haven't been mapped yet. Skip for now, it will be created when
47
+ // processing the parent container's diff.
48
+ continue
49
+ }
50
+
51
+ switch (containerDiff.type) {
52
+ case "text":
53
+ replayTextDiff(container as LoroText, containerDiff)
54
+ break
55
+ case "list":
56
+ replayListDiff(
57
+ container as LoroList | LoroMovableList,
58
+ containerDiff,
59
+ containerMap,
60
+ )
61
+ break
62
+ case "map":
63
+ replayMapDiff(container as LoroMap, containerDiff, containerMap)
64
+ break
65
+ case "tree":
66
+ replayTreeDiff(container as LoroTree, containerDiff)
67
+ break
68
+ case "counter":
69
+ replayCounterDiff(container as LoroCounter, containerDiff)
70
+ break
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Replay text diff operations
77
+ */
78
+ function replayTextDiff(text: LoroText, diff: TextDiff): void {
79
+ // LoroText has applyDelta which handles the delta format directly
80
+ text.applyDelta(diff.diff)
81
+ }
82
+
83
+ /**
84
+ * Replay list diff operations
85
+ */
86
+ function replayListDiff(
87
+ list: LoroList | LoroMovableList,
88
+ diff: ListDiff,
89
+ containerMap: Map<ContainerID, Container>,
90
+ ): void {
91
+ let index = 0
92
+
93
+ for (const delta of diff.diff) {
94
+ if (delta.retain !== undefined) {
95
+ // Retain: skip over existing elements
96
+ index += delta.retain
97
+ } else if (delta.delete !== undefined) {
98
+ // Delete: remove elements at current position
99
+ list.delete(index, delta.delete)
100
+ // Don't advance index - next operation is at same position
101
+ } else if (delta.insert !== undefined) {
102
+ // Insert: add elements at current position
103
+ const values = delta.insert
104
+ for (let i = 0; i < values.length; i++) {
105
+ const value = values[i]
106
+ if (isContainer(value)) {
107
+ // For containers, we need to insert a new container of the same type
108
+ // The container's contents will be handled by its own diff entry
109
+ const newContainer = createContainerOfSameType(value)
110
+ const insertedContainer = (list as LoroList).insertContainer(
111
+ index + i,
112
+ newContainer,
113
+ )
114
+ // Map the source container ID to the newly created container
115
+ containerMap.set(value.id, insertedContainer)
116
+ } else {
117
+ ;(list as LoroList).insert(
118
+ index + i,
119
+ value as Exclude<Value, Container>,
120
+ )
121
+ }
122
+ }
123
+ index += values.length
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Replay map diff operations
130
+ */
131
+ function replayMapDiff(
132
+ map: LoroMap,
133
+ diff: MapDiff,
134
+ containerMap: Map<ContainerID, Container>,
135
+ ): void {
136
+ for (const [key, value] of Object.entries(diff.updated)) {
137
+ if (value === undefined) {
138
+ // Delete the key
139
+ map.delete(key)
140
+ } else if (isContainer(value)) {
141
+ // Set a container - create a new one of the same type
142
+ const newContainer = createContainerOfSameType(value)
143
+ const insertedContainer = map.setContainer(key, newContainer)
144
+ // Map the source container ID to the newly created container
145
+ containerMap.set(value.id, insertedContainer)
146
+ } else {
147
+ // Set a primitive value
148
+ map.set(key, value as Exclude<Value, Container>)
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Replay tree diff operations
155
+ */
156
+ function replayTreeDiff(tree: LoroTree, diff: TreeDiff): void {
157
+ for (const item of diff.diff) {
158
+ replayTreeDiffItem(tree, item)
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Replay a single tree diff item
164
+ */
165
+ function replayTreeDiffItem(tree: LoroTree, item: TreeDiffItem): void {
166
+ switch (item.action) {
167
+ case "create":
168
+ // Create a new node under the specified parent
169
+ // Note: The node ID is determined by the CRDT, we can't specify it
170
+ // This means we're creating a NEW node, not recreating the exact same one
171
+ tree.createNode(item.parent, item.index)
172
+ break
173
+ case "delete":
174
+ // Delete the node
175
+ tree.delete(item.target)
176
+ break
177
+ case "move":
178
+ // Move the node to a new parent/position
179
+ tree.move(item.target, item.parent, item.index)
180
+ break
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Replay counter diff operations
186
+ */
187
+ function replayCounterDiff(counter: LoroCounter, diff: CounterDiff): void {
188
+ if (diff.increment > 0) {
189
+ counter.increment(diff.increment)
190
+ } else if (diff.increment < 0) {
191
+ counter.decrement(-diff.increment)
192
+ }
193
+ // If increment is 0, no operation needed
194
+ }
195
+
196
+ /**
197
+ * Check if a value is a Container
198
+ */
199
+ function isContainer(value: Value | Container): value is Container {
200
+ return (
201
+ value !== null &&
202
+ typeof value === "object" &&
203
+ "kind" in value &&
204
+ typeof (value as Container).kind === "function"
205
+ )
206
+ }
207
+
208
+ /**
209
+ * Create a new detached container of the same type as the given container
210
+ */
211
+ function createContainerOfSameType(container: Container): Container {
212
+ const kind = container.kind()
213
+ switch (kind) {
214
+ case "List":
215
+ return new (container.constructor as new () => LoroList)()
216
+ case "Map":
217
+ return new (container.constructor as new () => LoroMap)()
218
+ case "Text":
219
+ return new (container.constructor as new () => LoroText)()
220
+ case "Tree":
221
+ return new (container.constructor as new () => LoroTree)()
222
+ case "Counter":
223
+ return new (container.constructor as new () => LoroCounter)()
224
+ case "MovableList":
225
+ return new (container.constructor as new () => LoroMovableList)()
226
+ default:
227
+ throw new Error(`Unknown container kind: ${kind}`)
228
+ }
229
+ }