@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loro-extended/change",
3
- "version": "5.0.0",
3
+ "version": "5.2.0",
4
4
  "description": "A schema-driven, type-safe wrapper for Loro CRDT that provides natural JavaScript syntax for collaborative data mutations",
5
5
  "author": "Duane Johnson",
6
6
  "license": "MIT",
@@ -21,6 +21,7 @@
21
21
  "./src/*": "./src/*"
22
22
  },
23
23
  "devDependencies": {
24
+ "@typescript/native-preview": "7.0.0-dev.20260103.1",
24
25
  "tsup": "^8.5.0",
25
26
  "tsx": "^4.20.3",
26
27
  "typescript": "^5.9.2",
@@ -31,8 +32,6 @@
31
32
  },
32
33
  "scripts": {
33
34
  "build": "tsup",
34
- "check": "biome check --write .",
35
- "test": "vitest",
36
- "typecheck": "tsc --noEmit --skipLibCheck"
35
+ "verify": "verify"
37
36
  }
38
37
  }
@@ -0,0 +1,260 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { createTypedDoc, forkAt, loro, Shape } from "./index.js"
3
+
4
+ describe("forkAt", () => {
5
+ describe("TypedDoc.forkAt() method", () => {
6
+ it("should fork at a specific version and return correct state", () => {
7
+ const schema = Shape.doc({
8
+ title: Shape.text(),
9
+ count: Shape.counter(),
10
+ })
11
+
12
+ const doc = createTypedDoc(schema)
13
+ doc.title.update("Hello")
14
+ doc.count.increment(5)
15
+
16
+ // Get frontiers at this point
17
+ const frontiers = loro(doc).doc.frontiers()
18
+
19
+ // Make more changes
20
+ doc.title.update("World")
21
+ doc.count.increment(10)
22
+
23
+ // Fork at the earlier version
24
+ const forkedDoc = doc.forkAt(frontiers)
25
+
26
+ // Forked doc should have the earlier state
27
+ expect(forkedDoc.title.toString()).toBe("Hello")
28
+ expect(forkedDoc.count.value).toBe(5)
29
+
30
+ // Original doc should still have the latest state
31
+ expect(doc.title.toString()).toBe("World")
32
+ expect(doc.count.value).toBe(15)
33
+ })
34
+
35
+ it("should preserve type safety on forked doc", () => {
36
+ const schema = Shape.doc({
37
+ items: Shape.list(
38
+ Shape.struct({
39
+ name: Shape.text(),
40
+ done: Shape.plain.boolean(),
41
+ }),
42
+ ),
43
+ })
44
+
45
+ const doc = createTypedDoc(schema)
46
+ doc.items.push({ name: "Task 1", done: false })
47
+
48
+ const frontiers = loro(doc).doc.frontiers()
49
+
50
+ doc.items.push({ name: "Task 2", done: true })
51
+
52
+ const forkedDoc = doc.forkAt(frontiers)
53
+
54
+ // Type safety: forkedDoc.items should have the same type
55
+ expect(forkedDoc.items.length).toBe(1)
56
+ const firstItem = forkedDoc.items[0]
57
+ if (firstItem) {
58
+ expect(firstItem.name.toString()).toBe("Task 1")
59
+ expect(firstItem.done).toBe(false)
60
+ }
61
+
62
+ // Can mutate forked doc independently
63
+ forkedDoc.items.push({ name: "Forked Task", done: true })
64
+ expect(forkedDoc.items.length).toBe(2)
65
+ expect(doc.items.length).toBe(2) // Original unchanged
66
+ })
67
+
68
+ it("should create independent documents (changes don't affect original)", () => {
69
+ const schema = Shape.doc({
70
+ value: Shape.counter(),
71
+ })
72
+
73
+ const doc = createTypedDoc(schema)
74
+ doc.value.increment(10)
75
+
76
+ const frontiers = loro(doc).doc.frontiers()
77
+ const forkedDoc = doc.forkAt(frontiers)
78
+
79
+ // Mutate forked doc
80
+ forkedDoc.value.increment(100)
81
+
82
+ // Original should be unchanged
83
+ expect(doc.value.value).toBe(10)
84
+ expect(forkedDoc.value.value).toBe(110)
85
+
86
+ // Mutate original
87
+ doc.value.increment(5)
88
+
89
+ // Forked should be unchanged
90
+ expect(doc.value.value).toBe(15)
91
+ expect(forkedDoc.value.value).toBe(110)
92
+ })
93
+
94
+ it("should work with complex schemas (maps, trees)", () => {
95
+ const schema = Shape.doc({
96
+ settings: Shape.record(Shape.plain.string()),
97
+ tree: Shape.tree(
98
+ Shape.struct({
99
+ label: Shape.text(),
100
+ }),
101
+ ),
102
+ })
103
+
104
+ const doc = createTypedDoc(schema)
105
+ doc.settings.set("theme", "dark")
106
+ const node = doc.tree.createNode({ label: "Root" })
107
+
108
+ const frontiers = loro(doc).doc.frontiers()
109
+
110
+ doc.settings.set("theme", "light")
111
+ doc.settings.set("lang", "en")
112
+ node.data.label.update("Updated Root")
113
+
114
+ const forkedDoc = doc.forkAt(frontiers)
115
+
116
+ expect(forkedDoc.settings.get("theme")).toBe("dark")
117
+ // "lang" was not set before the fork, so it should not exist
118
+ // Note: Record returns placeholder value (empty string) for missing keys
119
+ expect(forkedDoc.settings.has("lang")).toBe(false)
120
+
121
+ // Tree should have the earlier state
122
+ const forkedRoots = forkedDoc.tree.roots()
123
+ expect(forkedRoots.length).toBe(1)
124
+ expect(forkedRoots[0].data.label.toString()).toBe("Root")
125
+ })
126
+
127
+ it("should have different PeerID from original", () => {
128
+ const schema = Shape.doc({
129
+ text: Shape.text(),
130
+ })
131
+
132
+ const doc = createTypedDoc(schema)
133
+ doc.text.update("Hello")
134
+
135
+ const frontiers = loro(doc).doc.frontiers()
136
+ const forkedDoc = doc.forkAt(frontiers)
137
+
138
+ const originalPeerId = loro(doc).doc.peerId
139
+ const forkedPeerId = loro(forkedDoc).doc.peerId
140
+
141
+ expect(forkedPeerId).not.toBe(originalPeerId)
142
+ })
143
+ })
144
+
145
+ describe("forkAt() functional helper", () => {
146
+ it("should fork at a specific version", () => {
147
+ const schema = Shape.doc({
148
+ title: Shape.text(),
149
+ })
150
+
151
+ const doc = createTypedDoc(schema)
152
+ doc.title.update("Hello")
153
+
154
+ const frontiers = loro(doc).doc.frontiers()
155
+
156
+ doc.title.update("World")
157
+
158
+ // Use functional helper
159
+ const forkedDoc = forkAt(doc, frontiers)
160
+
161
+ expect(forkedDoc.title.toString()).toBe("Hello")
162
+ expect(doc.title.toString()).toBe("World")
163
+ })
164
+
165
+ it("should preserve schema from original doc", () => {
166
+ const schema = Shape.doc({
167
+ count: Shape.counter().placeholder(42),
168
+ })
169
+
170
+ const doc = createTypedDoc(schema)
171
+ // Don't increment - should use placeholder
172
+
173
+ const frontiers = loro(doc).doc.frontiers()
174
+ const forkedDoc = forkAt(doc, frontiers)
175
+
176
+ // Placeholder should be preserved
177
+ expect(forkedDoc.toJSON().count).toBe(42)
178
+ })
179
+ })
180
+
181
+ describe("raw LoroDoc.forkAt() access", () => {
182
+ it("should still be accessible via loro() escape hatch", () => {
183
+ const schema = Shape.doc({
184
+ text: Shape.text(),
185
+ })
186
+
187
+ const doc = createTypedDoc(schema)
188
+ doc.text.update("Hello")
189
+
190
+ const frontiers = loro(doc).doc.frontiers()
191
+ doc.text.update("World")
192
+
193
+ // Raw access returns LoroDoc, not TypedDoc
194
+ const rawForkedDoc = loro(doc).doc.forkAt(frontiers)
195
+
196
+ // It's a plain LoroDoc
197
+ expect(rawForkedDoc.toJSON()).toEqual({ text: "Hello" })
198
+
199
+ // Can wrap it manually if needed
200
+ const typedForkedDoc = createTypedDoc(schema, rawForkedDoc)
201
+ expect(typedForkedDoc.text.toString()).toBe("Hello")
202
+ })
203
+ })
204
+
205
+ describe("edge cases", () => {
206
+ it("should fork at empty frontiers (initial state)", () => {
207
+ const schema = Shape.doc({
208
+ count: Shape.counter().placeholder(0),
209
+ })
210
+
211
+ const doc = createTypedDoc(schema)
212
+ const emptyFrontiers = loro(doc).doc.frontiers()
213
+
214
+ doc.count.increment(10)
215
+
216
+ const forkedDoc = doc.forkAt(emptyFrontiers)
217
+
218
+ // Should be at initial state (placeholder value)
219
+ expect(forkedDoc.count.value).toBe(0)
220
+ })
221
+
222
+ it("should fork at current frontiers (same state)", () => {
223
+ const schema = Shape.doc({
224
+ text: Shape.text(),
225
+ })
226
+
227
+ const doc = createTypedDoc(schema)
228
+ doc.text.update("Hello")
229
+
230
+ const currentFrontiers = loro(doc).doc.frontiers()
231
+ const forkedDoc = doc.forkAt(currentFrontiers)
232
+
233
+ expect(forkedDoc.text.toString()).toBe("Hello")
234
+ })
235
+
236
+ it("should work with change() on forked doc", () => {
237
+ const schema = Shape.doc({
238
+ items: Shape.list(Shape.plain.number()),
239
+ })
240
+
241
+ const doc = createTypedDoc(schema)
242
+ doc.items.push(1)
243
+ doc.items.push(2)
244
+
245
+ const frontiers = loro(doc).doc.frontiers()
246
+ doc.items.push(3)
247
+
248
+ const forkedDoc = doc.forkAt(frontiers)
249
+
250
+ // Use change() on forked doc
251
+ forkedDoc.change(draft => {
252
+ draft.items.push(100)
253
+ draft.items.push(200)
254
+ })
255
+
256
+ expect(forkedDoc.items.toJSON()).toEqual([1, 2, 100, 200])
257
+ expect(doc.items.toJSON()).toEqual([1, 2, 3])
258
+ })
259
+ })
260
+ })
@@ -458,4 +458,406 @@ describe("functional helpers", () => {
458
458
  expect(getLoroContainer(doc.tree)).toBe(loro(doc.tree).container)
459
459
  })
460
460
  })
461
+
462
+ describe("change() on refs", () => {
463
+ describe("ListRef", () => {
464
+ it("should batch push operations", () => {
465
+ const doc = createTypedDoc(fullSchema)
466
+
467
+ change(doc.items, draft => {
468
+ draft.push("item1")
469
+ draft.push("item2")
470
+ draft.push("item3")
471
+ })
472
+
473
+ expect(doc.items.toJSON()).toEqual(["item1", "item2", "item3"])
474
+ })
475
+
476
+ it("should batch delete and push operations", () => {
477
+ const doc = createTypedDoc(fullSchema)
478
+
479
+ // Setup initial data
480
+ doc.items.push("a")
481
+ doc.items.push("b")
482
+ doc.items.push("c")
483
+
484
+ change(doc.items, draft => {
485
+ draft.delete(1, 1) // Remove "b"
486
+ draft.push("d")
487
+ })
488
+
489
+ expect(doc.items.toJSON()).toEqual(["a", "c", "d"])
490
+ })
491
+
492
+ it("should return the original ref for chaining", () => {
493
+ const doc = createTypedDoc(fullSchema)
494
+
495
+ const result = change(doc.items, draft => {
496
+ draft.push("item1")
497
+ })
498
+
499
+ expect(result).toBe(doc.items)
500
+ result.push("item2")
501
+ expect(doc.items.toJSON()).toEqual(["item1", "item2"])
502
+ })
503
+
504
+ it("should support find-and-mutate patterns with value shapes", () => {
505
+ const listSchema = Shape.doc({
506
+ items: Shape.list(
507
+ Shape.plain.struct({
508
+ id: Shape.plain.string(),
509
+ count: Shape.plain.number(),
510
+ }),
511
+ ),
512
+ })
513
+ const doc = createTypedDoc(listSchema)
514
+
515
+ // Setup initial data
516
+ doc.items.push({ id: "a", count: 0 })
517
+ doc.items.push({ id: "b", count: 0 })
518
+
519
+ change(doc.items, draft => {
520
+ const item = draft.find(i => i.id === "b")
521
+ if (item) {
522
+ item.count = 10
523
+ }
524
+ })
525
+
526
+ expect(doc.items.toJSON()).toEqual([
527
+ { id: "a", count: 0 },
528
+ { id: "b", count: 10 },
529
+ ])
530
+ })
531
+ })
532
+
533
+ describe("TextRef", () => {
534
+ it("should batch insert operations", () => {
535
+ const doc = createTypedDoc(fullSchema)
536
+
537
+ change(doc.title, draft => {
538
+ draft.insert(0, "Hello")
539
+ draft.insert(5, " World")
540
+ })
541
+
542
+ expect(doc.title.toString()).toBe("Hello World")
543
+ })
544
+
545
+ it("should batch insert and delete operations", () => {
546
+ const doc = createTypedDoc(fullSchema)
547
+
548
+ doc.title.insert(0, "Hello World")
549
+
550
+ change(doc.title, draft => {
551
+ draft.delete(5, 6) // Remove " World"
552
+ draft.insert(5, " Universe")
553
+ })
554
+
555
+ expect(doc.title.toString()).toBe("Hello Universe")
556
+ })
557
+
558
+ it("should support update operation", () => {
559
+ const doc = createTypedDoc(fullSchema)
560
+
561
+ doc.title.insert(0, "Old Text")
562
+
563
+ change(doc.title, draft => {
564
+ draft.update("New Text")
565
+ })
566
+
567
+ expect(doc.title.toString()).toBe("New Text")
568
+ })
569
+
570
+ it("should return the original ref for chaining", () => {
571
+ const doc = createTypedDoc(fullSchema)
572
+
573
+ const result = change(doc.title, draft => {
574
+ draft.insert(0, "Hello")
575
+ })
576
+
577
+ expect(result).toBe(doc.title)
578
+ result.insert(5, "!")
579
+ expect(doc.title.toString()).toBe("Hello!")
580
+ })
581
+ })
582
+
583
+ describe("CounterRef", () => {
584
+ it("should batch increment operations", () => {
585
+ const doc = createTypedDoc(fullSchema)
586
+
587
+ change(doc.count, draft => {
588
+ draft.increment(5)
589
+ draft.increment(3)
590
+ draft.increment(2)
591
+ })
592
+
593
+ expect(doc.count.value).toBe(10)
594
+ })
595
+
596
+ it("should batch increment and decrement operations", () => {
597
+ const doc = createTypedDoc(fullSchema)
598
+
599
+ doc.count.increment(10)
600
+
601
+ change(doc.count, draft => {
602
+ draft.increment(5)
603
+ draft.decrement(3)
604
+ })
605
+
606
+ expect(doc.count.value).toBe(12)
607
+ })
608
+
609
+ it("should return the original ref for chaining", () => {
610
+ const doc = createTypedDoc(fullSchema)
611
+
612
+ const result = change(doc.count, draft => {
613
+ draft.increment(5)
614
+ })
615
+
616
+ expect(result).toBe(doc.count)
617
+ result.increment(3)
618
+ expect(doc.count.value).toBe(8)
619
+ })
620
+ })
621
+
622
+ describe("StructRef", () => {
623
+ it("should batch property assignments", () => {
624
+ const doc = createTypedDoc(fullSchema)
625
+
626
+ change(doc.profile, draft => {
627
+ draft.bio.insert(0, "Hello")
628
+ draft.age.increment(25)
629
+ })
630
+
631
+ expect(doc.profile.bio.toString()).toBe("Hello")
632
+ expect(doc.profile.age.value).toBe(25)
633
+ })
634
+
635
+ it("should return the original ref for chaining", () => {
636
+ const doc = createTypedDoc(fullSchema)
637
+
638
+ const result = change(doc.profile, draft => {
639
+ draft.bio.insert(0, "Test")
640
+ })
641
+
642
+ expect(result).toBe(doc.profile)
643
+ })
644
+ })
645
+
646
+ describe("RecordRef", () => {
647
+ it("should batch set operations", () => {
648
+ const doc = createTypedDoc(fullSchema)
649
+
650
+ change(doc.users, draft => {
651
+ draft.set("alice", { name: "Alice" })
652
+ draft.set("bob", { name: "Bob" })
653
+ })
654
+
655
+ expect(doc.users.toJSON()).toEqual({
656
+ alice: { name: "Alice" },
657
+ bob: { name: "Bob" },
658
+ })
659
+ })
660
+
661
+ it("should batch set and delete operations", () => {
662
+ const doc = createTypedDoc(fullSchema)
663
+
664
+ doc.users.set("alice", { name: "Alice" })
665
+ doc.users.set("bob", { name: "Bob" })
666
+
667
+ change(doc.users, draft => {
668
+ draft.delete("alice")
669
+ draft.set("charlie", { name: "Charlie" })
670
+ })
671
+
672
+ expect(doc.users.toJSON()).toEqual({
673
+ bob: { name: "Bob" },
674
+ charlie: { name: "Charlie" },
675
+ })
676
+ })
677
+
678
+ it("should return the original ref for chaining", () => {
679
+ const doc = createTypedDoc(fullSchema)
680
+
681
+ const result = change(doc.users, draft => {
682
+ draft.set("alice", { name: "Alice" })
683
+ })
684
+
685
+ expect(result).toBe(doc.users)
686
+ })
687
+ })
688
+
689
+ describe("TreeRef", () => {
690
+ it("should batch createNode operations", () => {
691
+ const doc = createTypedDoc(fullSchema)
692
+
693
+ change(doc.tree, draft => {
694
+ draft.createNode()
695
+ draft.createNode()
696
+ })
697
+
698
+ expect(doc.tree.roots().length).toBe(2)
699
+ })
700
+
701
+ it("should batch node creation with initial data", () => {
702
+ const doc = createTypedDoc(fullSchema)
703
+
704
+ change(doc.tree, draft => {
705
+ const node1 = draft.createNode()
706
+ node1.data.name.insert(0, "Node 1")
707
+
708
+ const node2 = draft.createNode()
709
+ node2.data.name.insert(0, "Node 2")
710
+ })
711
+
712
+ const roots = doc.tree.roots()
713
+ expect(roots.length).toBe(2)
714
+ expect(roots[0].data.name.toString()).toBe("Node 1")
715
+ expect(roots[1].data.name.toString()).toBe("Node 2")
716
+ })
717
+
718
+ it("should return the original ref for chaining", () => {
719
+ const doc = createTypedDoc(fullSchema)
720
+
721
+ const result = change(doc.tree, draft => {
722
+ draft.createNode()
723
+ })
724
+
725
+ expect(result).toBe(doc.tree)
726
+ })
727
+ })
728
+
729
+ describe("MovableListRef", () => {
730
+ it("should batch push operations", () => {
731
+ const doc = createTypedDoc(fullSchema)
732
+
733
+ change(doc.movableItems, draft => {
734
+ draft.push("item1")
735
+ draft.push("item2")
736
+ })
737
+
738
+ expect(doc.movableItems.toJSON()).toEqual(["item1", "item2"])
739
+ })
740
+
741
+ it("should return the original ref for chaining", () => {
742
+ const doc = createTypedDoc(fullSchema)
743
+
744
+ const result = change(doc.movableItems, draft => {
745
+ draft.push("item1")
746
+ })
747
+
748
+ expect(result).toBe(doc.movableItems)
749
+ })
750
+ })
751
+
752
+ describe("nested change() calls", () => {
753
+ it("should handle nested change() calls correctly", () => {
754
+ const doc = createTypedDoc(fullSchema)
755
+
756
+ change(doc.items, outerDraft => {
757
+ outerDraft.push("outer1")
758
+
759
+ // Nested change on a different ref
760
+ change(doc.count, innerDraft => {
761
+ innerDraft.increment(10)
762
+ })
763
+
764
+ outerDraft.push("outer2")
765
+ })
766
+
767
+ expect(doc.items.toJSON()).toEqual(["outer1", "outer2"])
768
+ expect(doc.count.value).toBe(10)
769
+ })
770
+
771
+ it("should handle deeply nested change() calls", () => {
772
+ const doc = createTypedDoc(fullSchema)
773
+
774
+ change(doc.items, d1 => {
775
+ d1.push("L1")
776
+
777
+ change(doc.count, d2 => {
778
+ d2.increment(1)
779
+
780
+ change(doc.title, d3 => {
781
+ d3.insert(0, "Deep")
782
+ })
783
+
784
+ d2.increment(2)
785
+ })
786
+
787
+ d1.push("L1-end")
788
+ })
789
+
790
+ expect(doc.items.toJSON()).toEqual(["L1", "L1-end"])
791
+ expect(doc.count.value).toBe(3)
792
+ expect(doc.title.toString()).toBe("Deep")
793
+ })
794
+ })
795
+
796
+ describe("encapsulation use case", () => {
797
+ it("should allow passing refs without exposing the doc", () => {
798
+ const doc = createTypedDoc(fullSchema)
799
+
800
+ // Simulate a library function that only receives the ref
801
+ function addItems(itemsRef: typeof doc.items) {
802
+ change(itemsRef, draft => {
803
+ draft.push("library-item-1")
804
+ draft.push("library-item-2")
805
+ })
806
+ }
807
+
808
+ // User code passes the ref, not the doc
809
+ addItems(doc.items)
810
+
811
+ expect(doc.items.toJSON()).toEqual(["library-item-1", "library-item-2"])
812
+ })
813
+
814
+ it("should allow passing TreeRef for state machine use case", () => {
815
+ const doc = createTypedDoc(fullSchema)
816
+
817
+ // Simulate a state machine library
818
+ function addStates(statesRef: typeof doc.tree) {
819
+ change(statesRef, draft => {
820
+ const idle = draft.createNode()
821
+ idle.data.name.insert(0, "idle")
822
+
823
+ const running = draft.createNode()
824
+ running.data.name.insert(0, "running")
825
+ })
826
+ }
827
+
828
+ addStates(doc.tree)
829
+
830
+ const roots = doc.tree.roots()
831
+ expect(roots.length).toBe(2)
832
+ expect(roots[0].data.name.toString()).toBe("idle")
833
+ expect(roots[1].data.name.toString()).toBe("running")
834
+ })
835
+ })
836
+
837
+ describe("regression: doc.change() still works", () => {
838
+ it("should still support doc.change() method", () => {
839
+ const doc = createTypedDoc(fullSchema)
840
+
841
+ doc.change(draft => {
842
+ draft.title.insert(0, "Hello")
843
+ draft.count.increment(5)
844
+ })
845
+
846
+ expect(doc.title.toString()).toBe("Hello")
847
+ expect(doc.count.value).toBe(5)
848
+ })
849
+
850
+ it("should still support change(doc, fn) helper", () => {
851
+ const doc = createTypedDoc(fullSchema)
852
+
853
+ change(doc, draft => {
854
+ draft.title.insert(0, "World")
855
+ draft.count.increment(10)
856
+ })
857
+
858
+ expect(doc.title.toString()).toBe("World")
859
+ expect(doc.count.value).toBe(10)
860
+ })
861
+ })
862
+ })
461
863
  })