@loro-extended/change 5.1.0 → 5.3.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.
@@ -361,6 +361,131 @@ describe("functional helpers", () => {
361
361
  })
362
362
  })
363
363
 
364
+ describe("loro(ref).subscribe() for imported (remote) changes", () => {
365
+ it("should fire TextRef subscription when changes are imported", () => {
366
+ // Create two documents - simulating two clients
367
+ const doc1 = createTypedDoc(fullSchema)
368
+ const doc2 = createTypedDoc(fullSchema)
369
+
370
+ // Set up subscription on doc2's title ref
371
+ const callback = vi.fn()
372
+ const unsubscribe = loro(doc2.title).subscribe(callback)
373
+
374
+ // Make changes on doc1
375
+ doc1.title.insert(0, "Hello from doc1")
376
+ loro(doc1).doc.commit()
377
+
378
+ // Export from doc1 and import into doc2 (simulating sync)
379
+ const snapshot = loro(doc1).doc.export({ mode: "snapshot" })
380
+ loro(doc2).doc.import(snapshot)
381
+
382
+ // The subscription should have fired
383
+ expect(callback).toHaveBeenCalled()
384
+
385
+ // And the value should be updated
386
+ expect(doc2.title.toString()).toBe("Hello from doc1")
387
+
388
+ unsubscribe()
389
+ })
390
+
391
+ it("should fire CounterRef subscription when changes are imported", () => {
392
+ const doc1 = createTypedDoc(fullSchema)
393
+ const doc2 = createTypedDoc(fullSchema)
394
+
395
+ const callback = vi.fn()
396
+ const unsubscribe = loro(doc2.count).subscribe(callback)
397
+
398
+ doc1.count.increment(42)
399
+ loro(doc1).doc.commit()
400
+
401
+ const snapshot = loro(doc1).doc.export({ mode: "snapshot" })
402
+ loro(doc2).doc.import(snapshot)
403
+
404
+ expect(callback).toHaveBeenCalled()
405
+ expect(doc2.count.value).toBe(42)
406
+
407
+ unsubscribe()
408
+ })
409
+
410
+ it("should fire ListRef subscription when changes are imported", () => {
411
+ const doc1 = createTypedDoc(fullSchema)
412
+ const doc2 = createTypedDoc(fullSchema)
413
+
414
+ const callback = vi.fn()
415
+ const unsubscribe = loro(doc2.items).subscribe(callback)
416
+
417
+ doc1.items.push("item1")
418
+ doc1.items.push("item2")
419
+ loro(doc1).doc.commit()
420
+
421
+ const snapshot = loro(doc1).doc.export({ mode: "snapshot" })
422
+ loro(doc2).doc.import(snapshot)
423
+
424
+ expect(callback).toHaveBeenCalled()
425
+ expect(doc2.items.toJSON()).toEqual(["item1", "item2"])
426
+
427
+ unsubscribe()
428
+ })
429
+
430
+ it("should fire doc-level subscription when changes are imported", () => {
431
+ const doc1 = createTypedDoc(fullSchema)
432
+ const doc2 = createTypedDoc(fullSchema)
433
+
434
+ const callback = vi.fn()
435
+ const unsubscribe = loro(doc2).doc.subscribe(callback)
436
+
437
+ doc1.title.insert(0, "Hello")
438
+ loro(doc1).doc.commit()
439
+
440
+ const snapshot = loro(doc1).doc.export({ mode: "snapshot" })
441
+ loro(doc2).doc.import(snapshot)
442
+
443
+ expect(callback).toHaveBeenCalled()
444
+
445
+ unsubscribe()
446
+ })
447
+
448
+ it("should NOT fire subscription for containers that were not changed", () => {
449
+ const doc1 = createTypedDoc(fullSchema)
450
+ const doc2 = createTypedDoc(fullSchema)
451
+
452
+ // Subscribe to count, but only change title
453
+ const countCallback = vi.fn()
454
+ const unsubscribe = loro(doc2.count).subscribe(countCallback)
455
+
456
+ doc1.title.insert(0, "Hello")
457
+ loro(doc1).doc.commit()
458
+
459
+ const snapshot = loro(doc1).doc.export({ mode: "snapshot" })
460
+ loro(doc2).doc.import(snapshot)
461
+
462
+ // Count subscription should NOT have fired since count wasn't changed
463
+ expect(countCallback).not.toHaveBeenCalled()
464
+
465
+ unsubscribe()
466
+ })
467
+
468
+ it("should provide updated value in subscription callback", () => {
469
+ const doc1 = createTypedDoc(fullSchema)
470
+ const doc2 = createTypedDoc(fullSchema)
471
+
472
+ let capturedValue: string | undefined
473
+ const unsubscribe = loro(doc2.title).subscribe(() => {
474
+ capturedValue = doc2.title.toString()
475
+ })
476
+
477
+ doc1.title.insert(0, "Remote text")
478
+ loro(doc1).doc.commit()
479
+
480
+ const snapshot = loro(doc1).doc.export({ mode: "snapshot" })
481
+ loro(doc2).doc.import(snapshot)
482
+
483
+ expect(capturedValue).toBe("Remote text")
484
+
485
+ unsubscribe()
486
+ })
487
+ })
488
+
364
489
  describe("getLoroDoc() on refs", () => {
365
490
  it("should return LoroDoc from TextRef", () => {
366
491
  const doc = createTypedDoc(fullSchema)
@@ -458,4 +583,406 @@ describe("functional helpers", () => {
458
583
  expect(getLoroContainer(doc.tree)).toBe(loro(doc.tree).container)
459
584
  })
460
585
  })
586
+
587
+ describe("change() on refs", () => {
588
+ describe("ListRef", () => {
589
+ it("should batch push operations", () => {
590
+ const doc = createTypedDoc(fullSchema)
591
+
592
+ change(doc.items, draft => {
593
+ draft.push("item1")
594
+ draft.push("item2")
595
+ draft.push("item3")
596
+ })
597
+
598
+ expect(doc.items.toJSON()).toEqual(["item1", "item2", "item3"])
599
+ })
600
+
601
+ it("should batch delete and push operations", () => {
602
+ const doc = createTypedDoc(fullSchema)
603
+
604
+ // Setup initial data
605
+ doc.items.push("a")
606
+ doc.items.push("b")
607
+ doc.items.push("c")
608
+
609
+ change(doc.items, draft => {
610
+ draft.delete(1, 1) // Remove "b"
611
+ draft.push("d")
612
+ })
613
+
614
+ expect(doc.items.toJSON()).toEqual(["a", "c", "d"])
615
+ })
616
+
617
+ it("should return the original ref for chaining", () => {
618
+ const doc = createTypedDoc(fullSchema)
619
+
620
+ const result = change(doc.items, draft => {
621
+ draft.push("item1")
622
+ })
623
+
624
+ expect(result).toBe(doc.items)
625
+ result.push("item2")
626
+ expect(doc.items.toJSON()).toEqual(["item1", "item2"])
627
+ })
628
+
629
+ it("should support find-and-mutate patterns with value shapes", () => {
630
+ const listSchema = Shape.doc({
631
+ items: Shape.list(
632
+ Shape.plain.struct({
633
+ id: Shape.plain.string(),
634
+ count: Shape.plain.number(),
635
+ }),
636
+ ),
637
+ })
638
+ const doc = createTypedDoc(listSchema)
639
+
640
+ // Setup initial data
641
+ doc.items.push({ id: "a", count: 0 })
642
+ doc.items.push({ id: "b", count: 0 })
643
+
644
+ change(doc.items, draft => {
645
+ const item = draft.find(i => i.id === "b")
646
+ if (item) {
647
+ item.count = 10
648
+ }
649
+ })
650
+
651
+ expect(doc.items.toJSON()).toEqual([
652
+ { id: "a", count: 0 },
653
+ { id: "b", count: 10 },
654
+ ])
655
+ })
656
+ })
657
+
658
+ describe("TextRef", () => {
659
+ it("should batch insert operations", () => {
660
+ const doc = createTypedDoc(fullSchema)
661
+
662
+ change(doc.title, draft => {
663
+ draft.insert(0, "Hello")
664
+ draft.insert(5, " World")
665
+ })
666
+
667
+ expect(doc.title.toString()).toBe("Hello World")
668
+ })
669
+
670
+ it("should batch insert and delete operations", () => {
671
+ const doc = createTypedDoc(fullSchema)
672
+
673
+ doc.title.insert(0, "Hello World")
674
+
675
+ change(doc.title, draft => {
676
+ draft.delete(5, 6) // Remove " World"
677
+ draft.insert(5, " Universe")
678
+ })
679
+
680
+ expect(doc.title.toString()).toBe("Hello Universe")
681
+ })
682
+
683
+ it("should support update operation", () => {
684
+ const doc = createTypedDoc(fullSchema)
685
+
686
+ doc.title.insert(0, "Old Text")
687
+
688
+ change(doc.title, draft => {
689
+ draft.update("New Text")
690
+ })
691
+
692
+ expect(doc.title.toString()).toBe("New Text")
693
+ })
694
+
695
+ it("should return the original ref for chaining", () => {
696
+ const doc = createTypedDoc(fullSchema)
697
+
698
+ const result = change(doc.title, draft => {
699
+ draft.insert(0, "Hello")
700
+ })
701
+
702
+ expect(result).toBe(doc.title)
703
+ result.insert(5, "!")
704
+ expect(doc.title.toString()).toBe("Hello!")
705
+ })
706
+ })
707
+
708
+ describe("CounterRef", () => {
709
+ it("should batch increment operations", () => {
710
+ const doc = createTypedDoc(fullSchema)
711
+
712
+ change(doc.count, draft => {
713
+ draft.increment(5)
714
+ draft.increment(3)
715
+ draft.increment(2)
716
+ })
717
+
718
+ expect(doc.count.value).toBe(10)
719
+ })
720
+
721
+ it("should batch increment and decrement operations", () => {
722
+ const doc = createTypedDoc(fullSchema)
723
+
724
+ doc.count.increment(10)
725
+
726
+ change(doc.count, draft => {
727
+ draft.increment(5)
728
+ draft.decrement(3)
729
+ })
730
+
731
+ expect(doc.count.value).toBe(12)
732
+ })
733
+
734
+ it("should return the original ref for chaining", () => {
735
+ const doc = createTypedDoc(fullSchema)
736
+
737
+ const result = change(doc.count, draft => {
738
+ draft.increment(5)
739
+ })
740
+
741
+ expect(result).toBe(doc.count)
742
+ result.increment(3)
743
+ expect(doc.count.value).toBe(8)
744
+ })
745
+ })
746
+
747
+ describe("StructRef", () => {
748
+ it("should batch property assignments", () => {
749
+ const doc = createTypedDoc(fullSchema)
750
+
751
+ change(doc.profile, draft => {
752
+ draft.bio.insert(0, "Hello")
753
+ draft.age.increment(25)
754
+ })
755
+
756
+ expect(doc.profile.bio.toString()).toBe("Hello")
757
+ expect(doc.profile.age.value).toBe(25)
758
+ })
759
+
760
+ it("should return the original ref for chaining", () => {
761
+ const doc = createTypedDoc(fullSchema)
762
+
763
+ const result = change(doc.profile, draft => {
764
+ draft.bio.insert(0, "Test")
765
+ })
766
+
767
+ expect(result).toBe(doc.profile)
768
+ })
769
+ })
770
+
771
+ describe("RecordRef", () => {
772
+ it("should batch set operations", () => {
773
+ const doc = createTypedDoc(fullSchema)
774
+
775
+ change(doc.users, draft => {
776
+ draft.set("alice", { name: "Alice" })
777
+ draft.set("bob", { name: "Bob" })
778
+ })
779
+
780
+ expect(doc.users.toJSON()).toEqual({
781
+ alice: { name: "Alice" },
782
+ bob: { name: "Bob" },
783
+ })
784
+ })
785
+
786
+ it("should batch set and delete operations", () => {
787
+ const doc = createTypedDoc(fullSchema)
788
+
789
+ doc.users.set("alice", { name: "Alice" })
790
+ doc.users.set("bob", { name: "Bob" })
791
+
792
+ change(doc.users, draft => {
793
+ draft.delete("alice")
794
+ draft.set("charlie", { name: "Charlie" })
795
+ })
796
+
797
+ expect(doc.users.toJSON()).toEqual({
798
+ bob: { name: "Bob" },
799
+ charlie: { name: "Charlie" },
800
+ })
801
+ })
802
+
803
+ it("should return the original ref for chaining", () => {
804
+ const doc = createTypedDoc(fullSchema)
805
+
806
+ const result = change(doc.users, draft => {
807
+ draft.set("alice", { name: "Alice" })
808
+ })
809
+
810
+ expect(result).toBe(doc.users)
811
+ })
812
+ })
813
+
814
+ describe("TreeRef", () => {
815
+ it("should batch createNode operations", () => {
816
+ const doc = createTypedDoc(fullSchema)
817
+
818
+ change(doc.tree, draft => {
819
+ draft.createNode()
820
+ draft.createNode()
821
+ })
822
+
823
+ expect(doc.tree.roots().length).toBe(2)
824
+ })
825
+
826
+ it("should batch node creation with initial data", () => {
827
+ const doc = createTypedDoc(fullSchema)
828
+
829
+ change(doc.tree, draft => {
830
+ const node1 = draft.createNode()
831
+ node1.data.name.insert(0, "Node 1")
832
+
833
+ const node2 = draft.createNode()
834
+ node2.data.name.insert(0, "Node 2")
835
+ })
836
+
837
+ const roots = doc.tree.roots()
838
+ expect(roots.length).toBe(2)
839
+ expect(roots[0].data.name.toString()).toBe("Node 1")
840
+ expect(roots[1].data.name.toString()).toBe("Node 2")
841
+ })
842
+
843
+ it("should return the original ref for chaining", () => {
844
+ const doc = createTypedDoc(fullSchema)
845
+
846
+ const result = change(doc.tree, draft => {
847
+ draft.createNode()
848
+ })
849
+
850
+ expect(result).toBe(doc.tree)
851
+ })
852
+ })
853
+
854
+ describe("MovableListRef", () => {
855
+ it("should batch push operations", () => {
856
+ const doc = createTypedDoc(fullSchema)
857
+
858
+ change(doc.movableItems, draft => {
859
+ draft.push("item1")
860
+ draft.push("item2")
861
+ })
862
+
863
+ expect(doc.movableItems.toJSON()).toEqual(["item1", "item2"])
864
+ })
865
+
866
+ it("should return the original ref for chaining", () => {
867
+ const doc = createTypedDoc(fullSchema)
868
+
869
+ const result = change(doc.movableItems, draft => {
870
+ draft.push("item1")
871
+ })
872
+
873
+ expect(result).toBe(doc.movableItems)
874
+ })
875
+ })
876
+
877
+ describe("nested change() calls", () => {
878
+ it("should handle nested change() calls correctly", () => {
879
+ const doc = createTypedDoc(fullSchema)
880
+
881
+ change(doc.items, outerDraft => {
882
+ outerDraft.push("outer1")
883
+
884
+ // Nested change on a different ref
885
+ change(doc.count, innerDraft => {
886
+ innerDraft.increment(10)
887
+ })
888
+
889
+ outerDraft.push("outer2")
890
+ })
891
+
892
+ expect(doc.items.toJSON()).toEqual(["outer1", "outer2"])
893
+ expect(doc.count.value).toBe(10)
894
+ })
895
+
896
+ it("should handle deeply nested change() calls", () => {
897
+ const doc = createTypedDoc(fullSchema)
898
+
899
+ change(doc.items, d1 => {
900
+ d1.push("L1")
901
+
902
+ change(doc.count, d2 => {
903
+ d2.increment(1)
904
+
905
+ change(doc.title, d3 => {
906
+ d3.insert(0, "Deep")
907
+ })
908
+
909
+ d2.increment(2)
910
+ })
911
+
912
+ d1.push("L1-end")
913
+ })
914
+
915
+ expect(doc.items.toJSON()).toEqual(["L1", "L1-end"])
916
+ expect(doc.count.value).toBe(3)
917
+ expect(doc.title.toString()).toBe("Deep")
918
+ })
919
+ })
920
+
921
+ describe("encapsulation use case", () => {
922
+ it("should allow passing refs without exposing the doc", () => {
923
+ const doc = createTypedDoc(fullSchema)
924
+
925
+ // Simulate a library function that only receives the ref
926
+ function addItems(itemsRef: typeof doc.items) {
927
+ change(itemsRef, draft => {
928
+ draft.push("library-item-1")
929
+ draft.push("library-item-2")
930
+ })
931
+ }
932
+
933
+ // User code passes the ref, not the doc
934
+ addItems(doc.items)
935
+
936
+ expect(doc.items.toJSON()).toEqual(["library-item-1", "library-item-2"])
937
+ })
938
+
939
+ it("should allow passing TreeRef for state machine use case", () => {
940
+ const doc = createTypedDoc(fullSchema)
941
+
942
+ // Simulate a state machine library
943
+ function addStates(statesRef: typeof doc.tree) {
944
+ change(statesRef, draft => {
945
+ const idle = draft.createNode()
946
+ idle.data.name.insert(0, "idle")
947
+
948
+ const running = draft.createNode()
949
+ running.data.name.insert(0, "running")
950
+ })
951
+ }
952
+
953
+ addStates(doc.tree)
954
+
955
+ const roots = doc.tree.roots()
956
+ expect(roots.length).toBe(2)
957
+ expect(roots[0].data.name.toString()).toBe("idle")
958
+ expect(roots[1].data.name.toString()).toBe("running")
959
+ })
960
+ })
961
+
962
+ describe("regression: doc.change() still works", () => {
963
+ it("should still support doc.change() method", () => {
964
+ const doc = createTypedDoc(fullSchema)
965
+
966
+ doc.change(draft => {
967
+ draft.title.insert(0, "Hello")
968
+ draft.count.increment(5)
969
+ })
970
+
971
+ expect(doc.title.toString()).toBe("Hello")
972
+ expect(doc.count.value).toBe(5)
973
+ })
974
+
975
+ it("should still support change(doc, fn) helper", () => {
976
+ const doc = createTypedDoc(fullSchema)
977
+
978
+ change(doc, draft => {
979
+ draft.title.insert(0, "World")
980
+ draft.count.increment(10)
981
+ })
982
+
983
+ expect(doc.title.toString()).toBe("World")
984
+ expect(doc.count.value).toBe(10)
985
+ })
986
+ })
987
+ })
461
988
  })