@loro-extended/change 0.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.
@@ -0,0 +1,697 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { createTypedDoc } from "./change.js"
3
+ import type { JsonPatch } from "./json-patch.js"
4
+ import { Shape } from "./shape.js"
5
+
6
+ describe("JSON Patch Integration", () => {
7
+ describe("Basic Operations", () => {
8
+ it("should handle add operations on map properties", () => {
9
+ const schema = Shape.doc({
10
+ metadata: Shape.map({
11
+ title: Shape.plain.string(),
12
+ count: Shape.plain.number(),
13
+ }),
14
+ })
15
+
16
+ const emptyState = {
17
+ metadata: {
18
+ title: "",
19
+ count: 0,
20
+ },
21
+ }
22
+
23
+ const typedDoc = createTypedDoc(schema, emptyState)
24
+
25
+ const patch: JsonPatch = [
26
+ { op: "add", path: "/metadata/title", value: "Hello World" },
27
+ { op: "add", path: "/metadata/count", value: 42 },
28
+ ]
29
+
30
+ const result = typedDoc.applyPatch(patch)
31
+
32
+ expect(result.metadata.title).toBe("Hello World")
33
+ expect(result.metadata.count).toBe(42)
34
+ })
35
+
36
+ it("should handle remove operations on map properties", () => {
37
+ const schema = Shape.doc({
38
+ config: Shape.map({
39
+ theme: Shape.plain.string(),
40
+ debug: Shape.plain.boolean(),
41
+ }),
42
+ })
43
+
44
+ const emptyState = {
45
+ config: {
46
+ theme: "light",
47
+ debug: true,
48
+ },
49
+ }
50
+
51
+ const typedDoc = createTypedDoc(schema, emptyState)
52
+
53
+ // First set some values
54
+ typedDoc.change(draft => {
55
+ draft.config.set("theme", "dark")
56
+ draft.config.set("debug", false)
57
+ })
58
+
59
+ const patch: JsonPatch = [{ op: "remove", path: "/config/debug" }]
60
+
61
+ const result = typedDoc.applyPatch(patch)
62
+
63
+ expect(result.config.theme).toBe("dark")
64
+ expect(result.config.debug).toBe(true) // Should fall back to empty state
65
+ })
66
+
67
+ it("should handle replace operations on map properties", () => {
68
+ const schema = Shape.doc({
69
+ settings: Shape.map({
70
+ language: Shape.plain.string(),
71
+ volume: Shape.plain.number(),
72
+ }),
73
+ })
74
+
75
+ const emptyState = {
76
+ settings: {
77
+ language: "en",
78
+ volume: 50,
79
+ },
80
+ }
81
+
82
+ const typedDoc = createTypedDoc(schema, emptyState)
83
+
84
+ // Set initial values
85
+ typedDoc.change(draft => {
86
+ draft.settings.set("language", "fr")
87
+ draft.settings.set("volume", 75)
88
+ })
89
+
90
+ const patch: JsonPatch = [
91
+ { op: "replace", path: "/settings/language", value: "es" },
92
+ { op: "replace", path: "/settings/volume", value: 100 },
93
+ ]
94
+
95
+ const result = typedDoc.applyPatch(patch)
96
+
97
+ expect(result.settings.language).toBe("es")
98
+ expect(result.settings.volume).toBe(100)
99
+ })
100
+ })
101
+
102
+ describe("List Operations", () => {
103
+ it("should handle add operations on lists", () => {
104
+ const schema = Shape.doc({
105
+ items: Shape.list(Shape.plain.string()),
106
+ })
107
+
108
+ const emptyState = {
109
+ items: [],
110
+ }
111
+
112
+ const typedDoc = createTypedDoc(schema, emptyState)
113
+
114
+ const patch: JsonPatch = [
115
+ { op: "add", path: "/items/0", value: "first" },
116
+ { op: "add", path: "/items/1", value: "second" },
117
+ { op: "add", path: "/items/1", value: "middle" }, // Insert in middle
118
+ ]
119
+
120
+ const result = typedDoc.applyPatch(patch)
121
+
122
+ expect(result.items).toEqual(["first", "middle", "second"])
123
+ })
124
+
125
+ it("should handle remove operations on lists", () => {
126
+ const schema = Shape.doc({
127
+ tasks: Shape.list(Shape.plain.string()),
128
+ })
129
+
130
+ const emptyState = {
131
+ tasks: [],
132
+ }
133
+
134
+ const typedDoc = createTypedDoc(schema, emptyState)
135
+
136
+ // Add initial items
137
+ typedDoc.change(draft => {
138
+ draft.tasks.push("task1")
139
+ draft.tasks.push("task2")
140
+ draft.tasks.push("task3")
141
+ })
142
+
143
+ const patch: JsonPatch = [
144
+ { op: "remove", path: "/tasks/1" }, // Remove "task2"
145
+ ]
146
+
147
+ const result = typedDoc.applyPatch(patch)
148
+
149
+ expect(result.tasks).toEqual(["task1", "task3"])
150
+ })
151
+
152
+ it("should handle replace operations on lists", () => {
153
+ const schema = Shape.doc({
154
+ numbers: Shape.list(Shape.plain.number()),
155
+ })
156
+
157
+ const emptyState = {
158
+ numbers: [],
159
+ }
160
+
161
+ const typedDoc = createTypedDoc(schema, emptyState)
162
+
163
+ // Add initial items
164
+ typedDoc.change(draft => {
165
+ draft.numbers.push(1)
166
+ draft.numbers.push(2)
167
+ draft.numbers.push(3)
168
+ })
169
+
170
+ const patch: JsonPatch = [
171
+ { op: "replace", path: "/numbers/1", value: 20 },
172
+ ]
173
+
174
+ const result = typedDoc.applyPatch(patch)
175
+
176
+ expect(result.numbers).toEqual([1, 20, 3])
177
+ })
178
+ })
179
+
180
+ describe("CRDT Container Operations", () => {
181
+ it("should work with text containers", () => {
182
+ const schema = Shape.doc({
183
+ title: Shape.text(),
184
+ content: Shape.text(),
185
+ })
186
+
187
+ const emptyState = {
188
+ title: "",
189
+ content: "",
190
+ }
191
+
192
+ const typedDoc = createTypedDoc(schema, emptyState)
193
+
194
+ // Note: For text containers, we can't directly patch the text content
195
+ // since it's a CRDT container. This test verifies the path navigation works
196
+ // but the actual text manipulation should be done through text methods
197
+
198
+ // This should work for setting up the structure
199
+ const result = typedDoc.value
200
+ expect(result.title).toBe("")
201
+ expect(result.content).toBe("")
202
+ })
203
+
204
+ it("should work with counter containers", () => {
205
+ const schema = Shape.doc({
206
+ views: Shape.counter(),
207
+ likes: Shape.counter(),
208
+ })
209
+
210
+ const emptyState = {
211
+ views: 0,
212
+ likes: 0,
213
+ }
214
+
215
+ const typedDoc = createTypedDoc(schema, emptyState)
216
+
217
+ // Note: Similar to text, counters are CRDT containers
218
+ // The path navigation should work, but actual counter operations
219
+ // should use increment/decrement methods
220
+
221
+ const result = typedDoc.value
222
+ expect(result.views).toBe(0)
223
+ expect(result.likes).toBe(0)
224
+ })
225
+ })
226
+
227
+ describe("Complex Nested Operations", () => {
228
+ it("should handle deeply nested map structures", () => {
229
+ const schema = Shape.doc({
230
+ user: Shape.map({
231
+ profile: Shape.map({
232
+ name: Shape.plain.string(),
233
+ settings: Shape.map({
234
+ theme: Shape.plain.string(),
235
+ notifications: Shape.plain.boolean(),
236
+ }),
237
+ }),
238
+ }),
239
+ })
240
+
241
+ const emptyState = {
242
+ user: {
243
+ profile: {
244
+ name: "",
245
+ settings: {
246
+ theme: "light",
247
+ notifications: true,
248
+ },
249
+ },
250
+ },
251
+ }
252
+
253
+ const typedDoc = createTypedDoc(schema, emptyState)
254
+
255
+ const patch: JsonPatch = [
256
+ { op: "add", path: "/user/profile/name", value: "Alice" },
257
+ { op: "replace", path: "/user/profile/settings/theme", value: "dark" },
258
+ {
259
+ op: "replace",
260
+ path: "/user/profile/settings/notifications",
261
+ value: false,
262
+ },
263
+ ]
264
+
265
+ const result = typedDoc.applyPatch(patch)
266
+
267
+ expect(result.user.profile.name).toBe("Alice")
268
+ expect(result.user.profile.settings.theme).toBe("dark")
269
+ expect(result.user.profile.settings.notifications).toBe(false)
270
+ })
271
+
272
+ it("should handle lists of objects", () => {
273
+ const schema = Shape.doc({
274
+ todos: Shape.list(
275
+ Shape.plain.object({
276
+ id: Shape.plain.string(),
277
+ text: Shape.plain.string(),
278
+ completed: Shape.plain.boolean(),
279
+ }),
280
+ ),
281
+ })
282
+
283
+ const emptyState = {
284
+ todos: [],
285
+ }
286
+
287
+ const typedDoc = createTypedDoc(schema, emptyState)
288
+
289
+ const patch: JsonPatch = [
290
+ {
291
+ op: "add",
292
+ path: "/todos/0",
293
+ value: { id: "1", text: "Buy milk", completed: false },
294
+ },
295
+ {
296
+ op: "add",
297
+ path: "/todos/1",
298
+ value: { id: "2", text: "Walk dog", completed: false },
299
+ },
300
+ {
301
+ op: "replace",
302
+ path: "/todos/1/completed",
303
+ value: true,
304
+ },
305
+ ]
306
+
307
+ const result = typedDoc.applyPatch(patch)
308
+
309
+ expect(result.todos).toHaveLength(2)
310
+ expect(result.todos[0]).toEqual({
311
+ id: "1",
312
+ text: "Buy milk",
313
+ completed: false,
314
+ })
315
+ expect(result.todos[1]).toEqual({
316
+ id: "2",
317
+ text: "Walk dog",
318
+ completed: true,
319
+ })
320
+ })
321
+ })
322
+
323
+ describe("Move and Copy Operations", () => {
324
+ it("should handle move operations", () => {
325
+ const schema = Shape.doc({
326
+ items: Shape.list(Shape.plain.string()),
327
+ })
328
+
329
+ const emptyState = {
330
+ items: [],
331
+ }
332
+
333
+ const typedDoc = createTypedDoc(schema, emptyState)
334
+
335
+ // Add initial items
336
+ typedDoc.change(draft => {
337
+ draft.items.push("first")
338
+ draft.items.push("second")
339
+ draft.items.push("third")
340
+ })
341
+
342
+ const patch: JsonPatch = [
343
+ { op: "move", from: "/items/0", path: "/items/2" }, // Move "first" to end
344
+ ]
345
+
346
+ const result = typedDoc.applyPatch(patch)
347
+
348
+ expect(result.items).toEqual(["second", "third", "first"])
349
+ })
350
+
351
+ it("should handle various move scenarios to prevent regressions", () => {
352
+ const schema = Shape.doc({
353
+ items: Shape.list(Shape.plain.string()),
354
+ })
355
+
356
+ const emptyState = {
357
+ items: [],
358
+ }
359
+
360
+ const typedDoc = createTypedDoc(schema, emptyState)
361
+
362
+ // Test move from 0 to 3 (move first item to end of 4-item list)
363
+ typedDoc.change(draft => {
364
+ draft.items.push("A")
365
+ draft.items.push("B")
366
+ draft.items.push("C")
367
+ draft.items.push("D")
368
+ })
369
+
370
+ const patch1: JsonPatch = [
371
+ { op: "move", from: "/items/0", path: "/items/3" },
372
+ ]
373
+
374
+ const result1 = typedDoc.applyPatch(patch1)
375
+ expect(result1.items).toEqual(["B", "C", "D", "A"])
376
+
377
+ // Reset for next test
378
+ typedDoc.change(draft => {
379
+ draft.items.delete(0, draft.items.length)
380
+ draft.items.push("A")
381
+ draft.items.push("B")
382
+ draft.items.push("C")
383
+ draft.items.push("D")
384
+ })
385
+
386
+ // Test move from 1 to 3 (move middle item to end)
387
+ const patch2: JsonPatch = [
388
+ { op: "move", from: "/items/1", path: "/items/3" },
389
+ ]
390
+
391
+ const result2 = typedDoc.applyPatch(patch2)
392
+ expect(result2.items).toEqual(["A", "C", "D", "B"])
393
+ })
394
+
395
+ it("should handle copy operations", () => {
396
+ const schema = Shape.doc({
397
+ source: Shape.list(Shape.plain.string()),
398
+ target: Shape.list(Shape.plain.string()),
399
+ })
400
+
401
+ const emptyState = {
402
+ source: [],
403
+ target: [],
404
+ }
405
+
406
+ const typedDoc = createTypedDoc(schema, emptyState)
407
+
408
+ // Add initial items
409
+ typedDoc.change(draft => {
410
+ draft.source.push("item1")
411
+ draft.source.push("item2")
412
+ })
413
+
414
+ const patch: JsonPatch = [
415
+ { op: "copy", from: "/source/0", path: "/target/0" },
416
+ { op: "copy", from: "/source/1", path: "/target/1" },
417
+ ]
418
+
419
+ const result = typedDoc.applyPatch(patch)
420
+
421
+ expect(result.source).toEqual(["item1", "item2"])
422
+ expect(result.target).toEqual(["item1", "item2"])
423
+ })
424
+ })
425
+
426
+ describe("Test Operations", () => {
427
+ it("should handle test operations that pass", () => {
428
+ const schema = Shape.doc({
429
+ config: Shape.map({
430
+ version: Shape.plain.string(),
431
+ }),
432
+ })
433
+
434
+ const emptyState = {
435
+ config: {
436
+ version: "1.0.0",
437
+ },
438
+ }
439
+
440
+ const typedDoc = createTypedDoc(schema, emptyState)
441
+
442
+ typedDoc.change(draft => {
443
+ draft.config.set("version", "2.0.0")
444
+ })
445
+
446
+ const patch: JsonPatch = [
447
+ { op: "test", path: "/config/version", value: "2.0.0" },
448
+ { op: "replace", path: "/config/version", value: "2.1.0" },
449
+ ]
450
+
451
+ const result = typedDoc.applyPatch(patch)
452
+
453
+ expect(result.config.version).toBe("2.1.0")
454
+ })
455
+
456
+ it("should throw on test operations that fail", () => {
457
+ const schema = Shape.doc({
458
+ config: Shape.map({
459
+ version: Shape.plain.string(),
460
+ }),
461
+ })
462
+
463
+ const emptyState = {
464
+ config: {
465
+ version: "1.0.0",
466
+ },
467
+ }
468
+
469
+ const typedDoc = createTypedDoc(schema, emptyState)
470
+
471
+ const patch: JsonPatch = [
472
+ { op: "test", path: "/config/version", value: "2.0.0" }, // This should fail
473
+ ]
474
+
475
+ expect(() => {
476
+ typedDoc.applyPatch(patch)
477
+ }).toThrow("JSON Patch test failed at path: /config/version")
478
+ })
479
+ })
480
+
481
+ describe("Path Prefix Support", () => {
482
+ it("should support path prefixes for scoped operations", () => {
483
+ const schema = Shape.doc({
484
+ users: Shape.map({
485
+ alice: Shape.map({
486
+ name: Shape.plain.string(),
487
+ email: Shape.plain.string(),
488
+ }),
489
+ bob: Shape.map({
490
+ name: Shape.plain.string(),
491
+ email: Shape.plain.string(),
492
+ }),
493
+ }),
494
+ })
495
+
496
+ const emptyState = {
497
+ users: {
498
+ alice: {
499
+ name: "",
500
+ email: "",
501
+ },
502
+ bob: {
503
+ name: "",
504
+ email: "",
505
+ },
506
+ },
507
+ }
508
+
509
+ const typedDoc = createTypedDoc(schema, emptyState)
510
+
511
+ // Apply patch with path prefix to scope operations to alice
512
+ const patch: JsonPatch = [
513
+ { op: "add", path: "/name", value: "Alice Smith" },
514
+ { op: "add", path: "/email", value: "alice@example.com" },
515
+ ]
516
+
517
+ const result = typedDoc.applyPatch(patch, ["users", "alice"])
518
+
519
+ expect(result.users.alice.name).toBe("Alice Smith")
520
+ expect(result.users.alice.email).toBe("alice@example.com")
521
+ expect(result.users.bob.name).toBe("") // Should be unchanged
522
+ })
523
+ })
524
+
525
+ describe("Path Formats", () => {
526
+ it("should handle JSON Pointer format paths", () => {
527
+ const schema = Shape.doc({
528
+ data: Shape.map({
529
+ items: Shape.list(Shape.plain.string()),
530
+ }),
531
+ })
532
+
533
+ const emptyState = {
534
+ data: {
535
+ items: [],
536
+ },
537
+ }
538
+
539
+ const typedDoc = createTypedDoc(schema, emptyState)
540
+
541
+ const patch: JsonPatch = [
542
+ { op: "add", path: "/data/items/0", value: "first" },
543
+ { op: "add", path: "/data/items/1", value: "second" },
544
+ ]
545
+
546
+ const result = typedDoc.applyPatch(patch)
547
+
548
+ expect(result.data.items).toEqual(["first", "second"])
549
+ })
550
+
551
+ it("should handle array format paths", () => {
552
+ const schema = Shape.doc({
553
+ data: Shape.map({
554
+ items: Shape.list(Shape.plain.string()),
555
+ }),
556
+ })
557
+
558
+ const emptyState = {
559
+ data: {
560
+ items: [],
561
+ },
562
+ }
563
+
564
+ const typedDoc = createTypedDoc(schema, emptyState)
565
+
566
+ const patch: JsonPatch = [
567
+ { op: "add", path: ["data", "items", 0], value: "first" },
568
+ { op: "add", path: ["data", "items", 1], value: "second" },
569
+ ]
570
+
571
+ const result = typedDoc.applyPatch(patch)
572
+
573
+ expect(result.data.items).toEqual(["first", "second"])
574
+ })
575
+ })
576
+
577
+ describe("Error Handling", () => {
578
+ it("should throw on invalid paths", () => {
579
+ const schema = Shape.doc({
580
+ data: Shape.map({
581
+ value: Shape.plain.string(),
582
+ }),
583
+ })
584
+
585
+ const emptyState = {
586
+ data: {
587
+ value: "",
588
+ },
589
+ }
590
+
591
+ const typedDoc = createTypedDoc(schema, emptyState)
592
+
593
+ const patch: JsonPatch = [
594
+ { op: "add", path: "/nonexistent/path", value: "test" },
595
+ ]
596
+
597
+ expect(() => {
598
+ typedDoc.applyPatch(patch)
599
+ }).toThrow("Cannot navigate to path segment: nonexistent")
600
+ })
601
+
602
+ it("should throw on invalid list indices", () => {
603
+ const schema = Shape.doc({
604
+ items: Shape.list(Shape.plain.string()),
605
+ })
606
+
607
+ const emptyState = {
608
+ items: [],
609
+ }
610
+
611
+ const typedDoc = createTypedDoc(schema, emptyState)
612
+
613
+ const patch: JsonPatch = [
614
+ { op: "remove", path: "/items/5" }, // Index out of bounds
615
+ ]
616
+
617
+ expect(() => {
618
+ typedDoc.applyPatch(patch)
619
+ }).toThrow("Index out of bound")
620
+ })
621
+ })
622
+
623
+ describe("Integration with Existing Change System", () => {
624
+ it("should work alongside regular change operations", () => {
625
+ const schema = Shape.doc({
626
+ counter: Shape.counter(),
627
+ text: Shape.text(),
628
+ data: Shape.map({
629
+ items: Shape.list(Shape.plain.string()),
630
+ }),
631
+ })
632
+
633
+ const emptyState = {
634
+ counter: 0,
635
+ text: "",
636
+ data: {
637
+ items: [],
638
+ },
639
+ }
640
+
641
+ const typedDoc = createTypedDoc(schema, emptyState)
642
+
643
+ // Use regular change operations
644
+ typedDoc.change(draft => {
645
+ draft.counter.increment(5)
646
+ draft.text.insert(0, "Hello")
647
+ })
648
+
649
+ // Then use JSON Patch
650
+ const patch: JsonPatch = [
651
+ { op: "add", path: "/data/items/0", value: "item1" },
652
+ { op: "add", path: "/data/items/1", value: "item2" },
653
+ ]
654
+
655
+ const result = typedDoc.applyPatch(patch)
656
+
657
+ expect(result.counter).toBe(5)
658
+ expect(result.text).toBe("Hello")
659
+ expect(result.data.items).toEqual(["item1", "item2"])
660
+ })
661
+
662
+ it("should maintain state across multiple patch applications", () => {
663
+ const schema = Shape.doc({
664
+ settings: Shape.map({
665
+ theme: Shape.plain.string(),
666
+ language: Shape.plain.string(),
667
+ }),
668
+ })
669
+
670
+ const emptyState = {
671
+ settings: {
672
+ theme: "light",
673
+ language: "en",
674
+ },
675
+ }
676
+
677
+ const typedDoc = createTypedDoc(schema, emptyState)
678
+
679
+ // First patch
680
+ const patch1: JsonPatch = [
681
+ { op: "replace", path: "/settings/theme", value: "dark" },
682
+ ]
683
+
684
+ typedDoc.applyPatch(patch1)
685
+
686
+ // Second patch
687
+ const patch2: JsonPatch = [
688
+ { op: "replace", path: "/settings/language", value: "fr" },
689
+ ]
690
+
691
+ const result = typedDoc.applyPatch(patch2)
692
+
693
+ expect(result.settings.theme).toBe("dark") // Should persist from first patch
694
+ expect(result.settings.language).toBe("fr")
695
+ })
696
+ })
697
+ })