@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.
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  Container,
3
3
  LoroDoc,
4
+ LoroEventBatch,
4
5
  LoroMap,
5
6
  Subscription,
6
7
  Value,
@@ -166,9 +167,10 @@ export class RecordRefInternals<
166
167
  } else {
167
168
  // For container shapes, try to assign the plain value
168
169
  // Use getOrCreateRef to ensure the container is created
170
+ // assignPlainValueToTypedRef handles batching and commits internally
169
171
  const ref = this.getOrCreateRef(key)
170
172
  if (assignPlainValueToTypedRef(ref as TypedRef<any>, value)) {
171
- this.commitIfAuto()
173
+ // Don't call commitIfAuto here - assignPlainValueToTypedRef handles it
172
174
  return
173
175
  }
174
176
  throw new Error(
@@ -185,6 +187,106 @@ export class RecordRefInternals<
185
187
  this.commitIfAuto()
186
188
  }
187
189
 
190
+ /**
191
+ * Replace entire contents with new values.
192
+ * Keys not in `values` are removed.
193
+ */
194
+ replace(values: Record<string, any>): void {
195
+ const container = this.getContainer() as LoroMap
196
+ const currentKeys = new Set(container.keys())
197
+ const newKeys = new Set(Object.keys(values))
198
+
199
+ // Suppress auto-commit during batch operations
200
+ const wasSuppressed = this.isSuppressAutoCommit()
201
+ if (!wasSuppressed) {
202
+ this.setSuppressAutoCommit(true)
203
+ }
204
+
205
+ try {
206
+ // Delete keys that are not in the new values
207
+ for (const key of currentKeys) {
208
+ if (!newKeys.has(key)) {
209
+ container.delete(key)
210
+ this.refCache.delete(key)
211
+ }
212
+ }
213
+
214
+ // Set new/updated values
215
+ for (const key of newKeys) {
216
+ this.set(key, values[key])
217
+ }
218
+ } finally {
219
+ // Restore auto-commit state
220
+ if (!wasSuppressed) {
221
+ this.setSuppressAutoCommit(false)
222
+ }
223
+ }
224
+
225
+ // Commit once after all operations
226
+ this.commitIfAuto()
227
+ }
228
+
229
+ /**
230
+ * Merge values into record.
231
+ * Existing keys not in `values` are kept.
232
+ */
233
+ merge(values: Record<string, any>): void {
234
+ // Suppress auto-commit during batch operations
235
+ const wasSuppressed = this.isSuppressAutoCommit()
236
+ if (!wasSuppressed) {
237
+ this.setSuppressAutoCommit(true)
238
+ }
239
+
240
+ try {
241
+ // Set new/updated values (no deletions)
242
+ for (const key of Object.keys(values)) {
243
+ this.set(key, values[key])
244
+ }
245
+ } finally {
246
+ // Restore auto-commit state
247
+ if (!wasSuppressed) {
248
+ this.setSuppressAutoCommit(false)
249
+ }
250
+ }
251
+
252
+ // Commit once after all operations
253
+ this.commitIfAuto()
254
+ }
255
+
256
+ /**
257
+ * Remove all entries from the record.
258
+ */
259
+ clear(): void {
260
+ const container = this.getContainer() as LoroMap
261
+ const keys = container.keys()
262
+
263
+ if (keys.length === 0) {
264
+ return // No-op on empty record
265
+ }
266
+
267
+ // Suppress auto-commit during batch operations
268
+ const wasSuppressed = this.isSuppressAutoCommit()
269
+ if (!wasSuppressed) {
270
+ this.setSuppressAutoCommit(true)
271
+ }
272
+
273
+ try {
274
+ // Delete all keys
275
+ for (const key of keys) {
276
+ container.delete(key)
277
+ this.refCache.delete(key)
278
+ }
279
+ } finally {
280
+ // Restore auto-commit state
281
+ if (!wasSuppressed) {
282
+ this.setSuppressAutoCommit(false)
283
+ }
284
+ }
285
+
286
+ // Commit once after all operations
287
+ this.commitIfAuto()
288
+ }
289
+
188
290
  /** Absorb mutated plain values back into Loro containers */
189
291
  absorbPlainValues(): void {
190
292
  absorbCachedPlainValues(this.refCache, () => this.getContainer() as LoroMap)
@@ -200,7 +302,7 @@ export class RecordRefInternals<
200
302
  get container(): LoroMap {
201
303
  return self.getContainer() as LoroMap
202
304
  },
203
- subscribe(callback: (event: unknown) => void): Subscription {
305
+ subscribe(callback: (event: LoroEventBatch) => void): Subscription {
204
306
  return (self.getContainer() as LoroMap).subscribe(callback)
205
307
  },
206
308
  setContainer(key: string, container: Container): Container {
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { change } from "../functional-helpers.js"
3
- import { createTypedDoc, Shape } from "../index.js"
3
+ import { createTypedDoc, loro, Shape } from "../index.js"
4
4
 
5
5
  describe("Record Types", () => {
6
6
  describe("Shape.record (Container)", () => {
@@ -405,4 +405,525 @@ describe("Record Types", () => {
405
405
  }).not.toThrow()
406
406
  })
407
407
  })
408
+
409
+ describe("RecordRef values() and entries() methods", () => {
410
+ it("should return properly typed values for value-shaped records", () => {
411
+ const schema = Shape.doc({
412
+ scores: Shape.record(Shape.plain.number()),
413
+ })
414
+
415
+ const doc = createTypedDoc(schema)
416
+
417
+ change(doc, draft => {
418
+ draft.scores.alice = 100
419
+ draft.scores.bob = 50
420
+ })
421
+
422
+ const values = doc.scores.values()
423
+ expect(values).toEqual([100, 50])
424
+
425
+ // Type check: values should be number[]
426
+ const _typeCheck: number[] = values
427
+ })
428
+
429
+ it("should return properly typed entries for value-shaped records", () => {
430
+ const schema = Shape.doc({
431
+ scores: Shape.record(Shape.plain.number()),
432
+ })
433
+
434
+ const doc = createTypedDoc(schema)
435
+
436
+ change(doc, draft => {
437
+ draft.scores.alice = 100
438
+ draft.scores.bob = 50
439
+ })
440
+
441
+ const entries = doc.scores.entries()
442
+ expect(entries).toEqual([
443
+ ["alice", 100],
444
+ ["bob", 50],
445
+ ])
446
+
447
+ // Type check: entries should be [string, number][]
448
+ const _typeCheck: [string, number][] = entries
449
+ })
450
+
451
+ it("should return properly typed refs for container-shaped records", () => {
452
+ const schema = Shape.doc({
453
+ players: Shape.record(
454
+ Shape.struct({
455
+ name: Shape.plain.string(),
456
+ score: Shape.plain.number(),
457
+ }),
458
+ ),
459
+ })
460
+
461
+ const doc = createTypedDoc(schema)
462
+
463
+ change(doc, draft => {
464
+ draft.players.alice = { name: "Alice", score: 100 }
465
+ draft.players.bob = { name: "Bob", score: 50 }
466
+ })
467
+
468
+ const values = doc.players.values()
469
+ expect(values.length).toBe(2)
470
+ // Values should be StructRefs that we can access properties on
471
+ expect(values[0].name).toBe("Alice")
472
+ expect(values[0].score).toBe(100)
473
+ expect(values[1].name).toBe("Bob")
474
+ expect(values[1].score).toBe(50)
475
+ })
476
+
477
+ it("should return properly typed entries for container-shaped records", () => {
478
+ const schema = Shape.doc({
479
+ players: Shape.record(
480
+ Shape.struct({
481
+ name: Shape.plain.string(),
482
+ score: Shape.plain.number(),
483
+ }),
484
+ ),
485
+ })
486
+
487
+ const doc = createTypedDoc(schema)
488
+
489
+ change(doc, draft => {
490
+ draft.players.alice = { name: "Alice", score: 100 }
491
+ draft.players.bob = { name: "Bob", score: 50 }
492
+ })
493
+
494
+ const entries = doc.players.entries()
495
+ expect(entries.length).toBe(2)
496
+ expect(entries[0][0]).toBe("alice")
497
+ expect(entries[0][1].name).toBe("Alice")
498
+ expect(entries[1][0]).toBe("bob")
499
+ expect(entries[1][1].name).toBe("Bob")
500
+ })
501
+
502
+ it("should return empty arrays for empty records", () => {
503
+ const schema = Shape.doc({
504
+ scores: Shape.record(Shape.plain.number()),
505
+ })
506
+
507
+ const doc = createTypedDoc(schema)
508
+
509
+ expect(doc.scores.values()).toEqual([])
510
+ expect(doc.scores.entries()).toEqual([])
511
+ })
512
+ })
513
+
514
+ describe("RecordRef bulk update methods", () => {
515
+ describe("replace()", () => {
516
+ it("should clear all entries when replacing with empty object", () => {
517
+ const schema = Shape.doc({
518
+ scores: Shape.record(Shape.plain.number()),
519
+ })
520
+
521
+ const doc = createTypedDoc(schema)
522
+
523
+ change(doc, draft => {
524
+ draft.scores.alice = 100
525
+ draft.scores.bob = 50
526
+ })
527
+
528
+ expect(doc.toJSON().scores).toEqual({ alice: 100, bob: 50 })
529
+
530
+ change(doc, draft => {
531
+ draft.scores.replace({})
532
+ })
533
+
534
+ expect(doc.toJSON().scores).toEqual({})
535
+ })
536
+
537
+ it("should add new entries", () => {
538
+ const schema = Shape.doc({
539
+ scores: Shape.record(Shape.plain.number()),
540
+ })
541
+
542
+ const doc = createTypedDoc(schema)
543
+
544
+ change(doc, draft => {
545
+ draft.scores.replace({
546
+ alice: 100,
547
+ bob: 50,
548
+ })
549
+ })
550
+
551
+ expect(doc.toJSON().scores).toEqual({ alice: 100, bob: 50 })
552
+ })
553
+
554
+ it("should update existing entries", () => {
555
+ const schema = Shape.doc({
556
+ scores: Shape.record(Shape.plain.number()),
557
+ })
558
+
559
+ const doc = createTypedDoc(schema)
560
+
561
+ change(doc, draft => {
562
+ draft.scores.alice = 100
563
+ draft.scores.bob = 50
564
+ })
565
+
566
+ change(doc, draft => {
567
+ draft.scores.replace({
568
+ alice: 200,
569
+ bob: 75,
570
+ })
571
+ })
572
+
573
+ expect(doc.toJSON().scores).toEqual({ alice: 200, bob: 75 })
574
+ })
575
+
576
+ it("should remove entries not in the new object", () => {
577
+ const schema = Shape.doc({
578
+ scores: Shape.record(Shape.plain.number()),
579
+ })
580
+
581
+ const doc = createTypedDoc(schema)
582
+
583
+ change(doc, draft => {
584
+ draft.scores.alice = 100
585
+ draft.scores.bob = 50
586
+ draft.scores.charlie = 25
587
+ })
588
+
589
+ change(doc, draft => {
590
+ draft.scores.replace({
591
+ alice: 150,
592
+ // bob and charlie are removed
593
+ })
594
+ })
595
+
596
+ expect(doc.toJSON().scores).toEqual({ alice: 150 })
597
+ })
598
+
599
+ it("should handle nested struct values", () => {
600
+ const schema = Shape.doc({
601
+ players: Shape.record(
602
+ Shape.struct({
603
+ name: Shape.plain.string(),
604
+ score: Shape.plain.number(),
605
+ }),
606
+ ),
607
+ })
608
+
609
+ const doc = createTypedDoc(schema)
610
+
611
+ change(doc, draft => {
612
+ draft.players.replace({
613
+ alice: { name: "Alice", score: 100 },
614
+ bob: { name: "Bob", score: 50 },
615
+ })
616
+ })
617
+
618
+ expect(doc.toJSON().players).toEqual({
619
+ alice: { name: "Alice", score: 100 },
620
+ bob: { name: "Bob", score: 50 },
621
+ })
622
+ })
623
+
624
+ it("should batch all operations into a single commit", () => {
625
+ const schema = Shape.doc({
626
+ scores: Shape.record(Shape.plain.number()),
627
+ })
628
+
629
+ const doc = createTypedDoc(schema)
630
+
631
+ change(doc, draft => {
632
+ draft.scores.alice = 100
633
+ draft.scores.bob = 50
634
+ })
635
+
636
+ // Track subscription calls
637
+ let subscriptionCount = 0
638
+ const unsub = loro(doc.scores).subscribe(() => {
639
+ subscriptionCount++
640
+ })
641
+
642
+ change(doc, draft => {
643
+ draft.scores.replace({
644
+ charlie: 75,
645
+ dave: 25,
646
+ })
647
+ })
648
+
649
+ // Should only have one subscription notification for the batched operation
650
+ expect(subscriptionCount).toBe(1)
651
+ unsub()
652
+ })
653
+ })
654
+
655
+ describe("merge()", () => {
656
+ it("should add new entries", () => {
657
+ const schema = Shape.doc({
658
+ scores: Shape.record(Shape.plain.number()),
659
+ })
660
+
661
+ const doc = createTypedDoc(schema)
662
+
663
+ change(doc, draft => {
664
+ draft.scores.merge({
665
+ alice: 100,
666
+ bob: 50,
667
+ })
668
+ })
669
+
670
+ expect(doc.toJSON().scores).toEqual({ alice: 100, bob: 50 })
671
+ })
672
+
673
+ it("should update existing entries", () => {
674
+ const schema = Shape.doc({
675
+ scores: Shape.record(Shape.plain.number()),
676
+ })
677
+
678
+ const doc = createTypedDoc(schema)
679
+
680
+ change(doc, draft => {
681
+ draft.scores.alice = 100
682
+ draft.scores.bob = 50
683
+ })
684
+
685
+ change(doc, draft => {
686
+ draft.scores.merge({
687
+ alice: 200,
688
+ })
689
+ })
690
+
691
+ expect(doc.toJSON().scores).toEqual({ alice: 200, bob: 50 })
692
+ })
693
+
694
+ it("should NOT remove entries not in the new object", () => {
695
+ const schema = Shape.doc({
696
+ scores: Shape.record(Shape.plain.number()),
697
+ })
698
+
699
+ const doc = createTypedDoc(schema)
700
+
701
+ change(doc, draft => {
702
+ draft.scores.alice = 100
703
+ draft.scores.bob = 50
704
+ })
705
+
706
+ change(doc, draft => {
707
+ draft.scores.merge({
708
+ charlie: 75,
709
+ })
710
+ })
711
+
712
+ expect(doc.toJSON().scores).toEqual({
713
+ alice: 100,
714
+ bob: 50,
715
+ charlie: 75,
716
+ })
717
+ })
718
+
719
+ it("should handle nested struct values", () => {
720
+ const schema = Shape.doc({
721
+ players: Shape.record(
722
+ Shape.struct({
723
+ name: Shape.plain.string(),
724
+ score: Shape.plain.number(),
725
+ }),
726
+ ),
727
+ })
728
+
729
+ const doc = createTypedDoc(schema)
730
+
731
+ change(doc, draft => {
732
+ draft.players.alice = { name: "Alice", score: 100 }
733
+ })
734
+
735
+ change(doc, draft => {
736
+ draft.players.merge({
737
+ bob: { name: "Bob", score: 50 },
738
+ })
739
+ })
740
+
741
+ expect(doc.toJSON().players).toEqual({
742
+ alice: { name: "Alice", score: 100 },
743
+ bob: { name: "Bob", score: 50 },
744
+ })
745
+ })
746
+
747
+ it("should batch all operations into a single commit", () => {
748
+ const schema = Shape.doc({
749
+ scores: Shape.record(Shape.plain.number()),
750
+ })
751
+
752
+ const doc = createTypedDoc(schema)
753
+
754
+ // Track subscription calls
755
+ let subscriptionCount = 0
756
+ const unsub = loro(doc.scores).subscribe(() => {
757
+ subscriptionCount++
758
+ })
759
+
760
+ change(doc, draft => {
761
+ draft.scores.merge({
762
+ alice: 100,
763
+ bob: 50,
764
+ charlie: 25,
765
+ })
766
+ })
767
+
768
+ // Should only have one subscription notification for the batched operation
769
+ expect(subscriptionCount).toBe(1)
770
+ unsub()
771
+ })
772
+ })
773
+
774
+ describe("clear()", () => {
775
+ it("should remove all entries", () => {
776
+ const schema = Shape.doc({
777
+ scores: Shape.record(Shape.plain.number()),
778
+ })
779
+
780
+ const doc = createTypedDoc(schema)
781
+
782
+ change(doc, draft => {
783
+ draft.scores.alice = 100
784
+ draft.scores.bob = 50
785
+ draft.scores.charlie = 25
786
+ })
787
+
788
+ expect(doc.toJSON().scores).toEqual({
789
+ alice: 100,
790
+ bob: 50,
791
+ charlie: 25,
792
+ })
793
+
794
+ change(doc, draft => {
795
+ draft.scores.clear()
796
+ })
797
+
798
+ expect(doc.toJSON().scores).toEqual({})
799
+ })
800
+
801
+ it("should be a no-op on empty record", () => {
802
+ const schema = Shape.doc({
803
+ scores: Shape.record(Shape.plain.number()),
804
+ })
805
+
806
+ const doc = createTypedDoc(schema)
807
+
808
+ expect(doc.toJSON().scores).toEqual({})
809
+
810
+ // Should not throw
811
+ change(doc, draft => {
812
+ draft.scores.clear()
813
+ })
814
+
815
+ expect(doc.toJSON().scores).toEqual({})
816
+ })
817
+
818
+ it("should batch all operations into a single commit", () => {
819
+ const schema = Shape.doc({
820
+ scores: Shape.record(Shape.plain.number()),
821
+ })
822
+
823
+ const doc = createTypedDoc(schema)
824
+
825
+ change(doc, draft => {
826
+ draft.scores.alice = 100
827
+ draft.scores.bob = 50
828
+ draft.scores.charlie = 25
829
+ })
830
+
831
+ // Track subscription calls
832
+ let subscriptionCount = 0
833
+ const unsub = loro(doc.scores).subscribe(() => {
834
+ subscriptionCount++
835
+ })
836
+
837
+ change(doc, draft => {
838
+ draft.scores.clear()
839
+ })
840
+
841
+ // Should only have one subscription notification for the batched operation
842
+ expect(subscriptionCount).toBe(1)
843
+ unsub()
844
+ })
845
+ })
846
+
847
+ describe("container-valued records", () => {
848
+ it("should work with replace() on record of structs", () => {
849
+ const schema = Shape.doc({
850
+ players: Shape.record(
851
+ Shape.struct({
852
+ name: Shape.plain.string(),
853
+ score: Shape.counter(),
854
+ }),
855
+ ),
856
+ })
857
+
858
+ const doc = createTypedDoc(schema)
859
+
860
+ change(doc, draft => {
861
+ draft.players.alice = { name: "Alice", score: 100 }
862
+ draft.players.bob = { name: "Bob", score: 50 }
863
+ })
864
+
865
+ change(doc, draft => {
866
+ draft.players.replace({
867
+ charlie: { name: "Charlie", score: 75 },
868
+ })
869
+ })
870
+
871
+ expect(doc.toJSON().players).toEqual({
872
+ charlie: { name: "Charlie", score: 75 },
873
+ })
874
+ })
875
+
876
+ it("should work with merge() on record of structs", () => {
877
+ const schema = Shape.doc({
878
+ players: Shape.record(
879
+ Shape.struct({
880
+ name: Shape.plain.string(),
881
+ score: Shape.counter(),
882
+ }),
883
+ ),
884
+ })
885
+
886
+ const doc = createTypedDoc(schema)
887
+
888
+ change(doc, draft => {
889
+ draft.players.alice = { name: "Alice", score: 100 }
890
+ })
891
+
892
+ change(doc, draft => {
893
+ draft.players.merge({
894
+ bob: { name: "Bob", score: 50 },
895
+ })
896
+ })
897
+
898
+ expect(doc.toJSON().players).toEqual({
899
+ alice: { name: "Alice", score: 100 },
900
+ bob: { name: "Bob", score: 50 },
901
+ })
902
+ })
903
+
904
+ it("should work with clear() on record of structs", () => {
905
+ const schema = Shape.doc({
906
+ players: Shape.record(
907
+ Shape.struct({
908
+ name: Shape.plain.string(),
909
+ score: Shape.counter(),
910
+ }),
911
+ ),
912
+ })
913
+
914
+ const doc = createTypedDoc(schema)
915
+
916
+ change(doc, draft => {
917
+ draft.players.alice = { name: "Alice", score: 100 }
918
+ draft.players.bob = { name: "Bob", score: 50 }
919
+ })
920
+
921
+ change(doc, draft => {
922
+ draft.players.clear()
923
+ })
924
+
925
+ expect(doc.toJSON().players).toEqual({})
926
+ })
927
+ })
928
+ })
408
929
  })