cojson 0.19.20 → 0.19.21

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.
Files changed (88) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/coValueCore/coValueCore.d.ts +9 -0
  3. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  4. package/dist/coValueCore/coValueCore.js +21 -0
  5. package/dist/coValueCore/coValueCore.js.map +1 -1
  6. package/dist/coValues/account.d.ts.map +1 -1
  7. package/dist/coValues/account.js +10 -10
  8. package/dist/coValues/account.js.map +1 -1
  9. package/dist/ids.d.ts +1 -1
  10. package/dist/ids.d.ts.map +1 -1
  11. package/dist/ids.js.map +1 -1
  12. package/dist/knownState.d.ts +5 -0
  13. package/dist/knownState.d.ts.map +1 -1
  14. package/dist/knownState.js +15 -0
  15. package/dist/knownState.js.map +1 -1
  16. package/dist/localNode.d.ts.map +1 -1
  17. package/dist/localNode.js +8 -2
  18. package/dist/localNode.js.map +1 -1
  19. package/dist/storage/knownState.d.ts +5 -0
  20. package/dist/storage/knownState.d.ts.map +1 -1
  21. package/dist/storage/knownState.js +11 -0
  22. package/dist/storage/knownState.js.map +1 -1
  23. package/dist/storage/sqlite/client.d.ts +2 -0
  24. package/dist/storage/sqlite/client.d.ts.map +1 -1
  25. package/dist/storage/sqlite/client.js +18 -0
  26. package/dist/storage/sqlite/client.js.map +1 -1
  27. package/dist/storage/sqliteAsync/client.d.ts +2 -0
  28. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  29. package/dist/storage/sqliteAsync/client.js +20 -0
  30. package/dist/storage/sqliteAsync/client.js.map +1 -1
  31. package/dist/storage/storageAsync.d.ts +2 -0
  32. package/dist/storage/storageAsync.d.ts.map +1 -1
  33. package/dist/storage/storageAsync.js +40 -0
  34. package/dist/storage/storageAsync.js.map +1 -1
  35. package/dist/storage/storageSync.d.ts +1 -0
  36. package/dist/storage/storageSync.d.ts.map +1 -1
  37. package/dist/storage/storageSync.js +15 -0
  38. package/dist/storage/storageSync.js.map +1 -1
  39. package/dist/storage/types.d.ts +18 -0
  40. package/dist/storage/types.d.ts.map +1 -1
  41. package/dist/sync.d.ts +17 -0
  42. package/dist/sync.d.ts.map +1 -1
  43. package/dist/sync.js +111 -41
  44. package/dist/sync.js.map +1 -1
  45. package/dist/tests/StorageApiAsync.test.js +91 -0
  46. package/dist/tests/StorageApiAsync.test.js.map +1 -1
  47. package/dist/tests/StorageApiSync.test.js +91 -0
  48. package/dist/tests/StorageApiSync.test.js.map +1 -1
  49. package/dist/tests/coValueCore.loadFromStorage.test.js +1 -0
  50. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  51. package/dist/tests/knownState.lazyLoading.test.d.ts +2 -0
  52. package/dist/tests/knownState.lazyLoading.test.d.ts.map +1 -0
  53. package/dist/tests/knownState.lazyLoading.test.js +166 -0
  54. package/dist/tests/knownState.lazyLoading.test.js.map +1 -0
  55. package/dist/tests/messagesTestUtils.d.ts +5 -2
  56. package/dist/tests/messagesTestUtils.d.ts.map +1 -1
  57. package/dist/tests/messagesTestUtils.js +4 -0
  58. package/dist/tests/messagesTestUtils.js.map +1 -1
  59. package/dist/tests/sync.load.test.js +388 -0
  60. package/dist/tests/sync.load.test.js.map +1 -1
  61. package/dist/tests/sync.mesh.test.js +4 -4
  62. package/dist/tests/testStorage.js +36 -0
  63. package/dist/tests/testStorage.js.map +1 -1
  64. package/dist/tests/testUtils.d.ts +14 -2
  65. package/dist/tests/testUtils.d.ts.map +1 -1
  66. package/dist/tests/testUtils.js.map +1 -1
  67. package/package.json +4 -4
  68. package/src/coValueCore/coValueCore.ts +26 -0
  69. package/src/coValues/account.ts +12 -14
  70. package/src/ids.ts +1 -1
  71. package/src/knownState.ts +24 -0
  72. package/src/localNode.ts +9 -2
  73. package/src/storage/knownState.ts +12 -0
  74. package/src/storage/sqlite/client.ts +31 -0
  75. package/src/storage/sqliteAsync/client.ts +35 -0
  76. package/src/storage/storageAsync.ts +51 -0
  77. package/src/storage/storageSync.ts +22 -0
  78. package/src/storage/types.ts +26 -0
  79. package/src/sync.ts +126 -42
  80. package/src/tests/StorageApiAsync.test.ts +136 -0
  81. package/src/tests/StorageApiSync.test.ts +132 -0
  82. package/src/tests/coValueCore.loadFromStorage.test.ts +3 -0
  83. package/src/tests/knownState.lazyLoading.test.ts +217 -0
  84. package/src/tests/messagesTestUtils.ts +10 -3
  85. package/src/tests/sync.load.test.ts +483 -1
  86. package/src/tests/sync.mesh.test.ts +4 -4
  87. package/src/tests/testStorage.ts +38 -0
  88. package/src/tests/testUtils.ts +14 -2
package/src/sync.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  CoValueKnownState,
26
26
  knownStateFrom,
27
27
  KnownStateSessions,
28
+ peerHasAllContent,
28
29
  } from "./knownState.js";
29
30
  import { StorageAPI } from "./storage/index.js";
30
31
 
@@ -583,33 +584,125 @@ export class SyncManager {
583
584
  peer.setKnownState(msg.id, knownStateFrom(msg));
584
585
  const coValue = this.local.getCoValue(msg.id);
585
586
 
587
+ // Fast path: CoValue is already in memory
586
588
  if (coValue.isAvailable()) {
587
589
  this.sendNewContent(msg.id, peer);
588
590
  return;
589
591
  }
590
592
 
591
- const peers = this.getServerPeers(msg.id, peer.id);
593
+ const peerKnownState = peer.getOptimisticKnownState(msg.id);
592
594
 
593
- coValue.load(peers);
595
+ // Fast path: Peer has no content at all - skip lazy load check, just load directly
596
+ if (!peerKnownState?.header) {
597
+ this.loadFromStorageAndRespond(msg.id, peer, coValue);
598
+ return;
599
+ }
600
+
601
+ // Check storage knownState before doing full load (lazy load optimization)
602
+ coValue.getKnownStateFromStorage((storageKnownState) => {
603
+ // Race condition: CoValue might have been loaded while we were waiting for storage
604
+ if (coValue.isAvailable()) {
605
+ this.sendNewContent(msg.id, peer);
606
+ return;
607
+ }
608
+
609
+ if (!storageKnownState) {
610
+ // Not in storage, try loading from peers
611
+ this.loadFromPeersAndRespond(msg.id, peer, coValue);
612
+ return;
613
+ }
614
+
615
+ // Check if peer already has all content
616
+ if (peerHasAllContent(storageKnownState, peerKnownState)) {
617
+ // Peer already has everything - reply with known message, no full load needed
618
+ peer.trackToldKnownState(msg.id);
619
+ this.trySendToPeer(peer, {
620
+ action: "known",
621
+ ...storageKnownState,
622
+ });
623
+ return;
624
+ }
625
+
626
+ // Peer needs content - do full load from storage
627
+ this.loadFromStorageAndRespond(msg.id, peer, coValue);
628
+ });
629
+ }
630
+
631
+ /**
632
+ * Helper to load from storage and respond appropriately.
633
+ * Falls back to peers if not found in storage.
634
+ */
635
+ private loadFromStorageAndRespond(
636
+ id: RawCoID,
637
+ peer: PeerState,
638
+ coValue: CoValueCore,
639
+ ) {
640
+ coValue.loadFromStorage((found) => {
641
+ if (found && coValue.isAvailable()) {
642
+ this.sendNewContent(id, peer);
643
+ } else {
644
+ this.loadFromPeersAndRespond(id, peer, coValue);
645
+ }
646
+ });
647
+ }
648
+
649
+ /**
650
+ * Helper to load from peers and respond appropriately.
651
+ */
652
+ private loadFromPeersAndRespond(
653
+ id: RawCoID,
654
+ peer: PeerState,
655
+ coValue: CoValueCore,
656
+ ) {
657
+ const peers = this.getServerPeers(id, peer.id);
658
+ coValue.loadFromPeers(peers);
594
659
 
595
660
  const handleLoadResult = () => {
596
661
  if (coValue.isAvailable()) {
662
+ this.sendNewContent(id, peer);
597
663
  return;
598
664
  }
665
+ this.handleLoadNotFound(id, peer);
666
+ };
599
667
 
600
- peer.trackToldKnownState(msg.id);
668
+ if (peers.length > 0) {
669
+ coValue.waitForAvailableOrUnavailable().then(handleLoadResult);
670
+ } else {
671
+ handleLoadResult();
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Handle case when CoValue is not found.
677
+ */
678
+ private handleLoadNotFound(id: RawCoID, peer: PeerState) {
679
+ peer.trackToldKnownState(id);
680
+ this.trySendToPeer(peer, {
681
+ action: "known",
682
+ id,
683
+ header: false,
684
+ sessions: {},
685
+ });
686
+ }
687
+
688
+ /**
689
+ * Request full content from a peer when we don't have the CoValue.
690
+ */
691
+ private requestFullContent(id: RawCoID, peer: PeerState | undefined) {
692
+ if (peer) {
601
693
  this.trySendToPeer(peer, {
602
694
  action: "known",
603
- id: msg.id,
695
+ isCorrection: true,
696
+ id,
604
697
  header: false,
605
698
  sessions: {},
606
699
  });
607
- };
608
-
609
- if (peers.length > 0 || this.local.storage) {
610
- coValue.waitForAvailableOrUnavailable().then(handleLoadResult);
611
700
  } else {
612
- handleLoadResult();
701
+ // The wrong assumption has been made by storage or import, we don't have a recovery mechanism
702
+ // Should never happen
703
+ logger.error("Received new content with no header on a missing CoValue", {
704
+ id,
705
+ });
613
706
  }
614
707
  }
615
708
 
@@ -694,46 +787,37 @@ export class SyncManager {
694
787
  */
695
788
  if (!coValue.hasVerifiedContent()) {
696
789
  /**
697
- * The peer has assumed we already have the CoValue
790
+ * The peer/import has assumed we already have the CoValue
698
791
  */
699
792
  if (!msg.header) {
700
- // We check if the covalue was in memory and has been garbage collected
701
- // In that case we should have it tracked in the storage
702
- const storageKnownState = this.local.storage?.getKnownState(msg.id);
703
-
704
- if (storageKnownState?.header) {
705
- // If the CoValue has been garbage collected, we load it from the storage before handling the new content
706
- coValue.loadFromStorage((found) => {
707
- if (found) {
708
- this.handleNewContent(msg, from);
709
- } else {
710
- logger.error("Known CoValue not found in storage", {
711
- id: msg.id,
712
- });
713
- }
714
- });
715
- return;
716
- }
717
-
718
- // The peer assumption is not correct, so we ask for the full CoValue
719
- if (peer) {
720
- this.trySendToPeer(peer, {
721
- action: "known",
722
- isCorrection: true,
723
- id: msg.id,
724
- header: false,
725
- sessions: {},
726
- });
727
- } else {
728
- // The wrong assumption has been made by storage or import, we don't have a recovery mechanism
729
- // Should never happen
730
- logger.error(
731
- "Received new content with no header on a missing CoValue",
793
+ // Content from storage without header - this can happen if:
794
+ // 1. Storage is streaming a large CoValue in chunks
795
+ // 2. Server is under heavy load, so a chunk isn't processed for a long time
796
+ // 3. GC cleanup unmounts the CoValue while streaming is in progress
797
+ // 4. The chunk is finally processed, but the CoValue is no longer available
798
+ // TODO: Fix this by either not unmounting CoValues with active streaming,
799
+ // or by cleaning up the streaming queue on unmount
800
+ if (from === "storage") {
801
+ logger.warn(
802
+ "Received content from storage without header - CoValue may have been garbage collected mid-stream",
732
803
  {
733
804
  id: msg.id,
805
+ from,
734
806
  },
735
807
  );
808
+ return;
736
809
  }
810
+
811
+ // Try to load from storage - the CoValue might have been garbage collected from memory
812
+ coValue.loadFromStorage((found) => {
813
+ if (found) {
814
+ // CoValue was in storage, process the new content
815
+ this.handleNewContent(msg, from);
816
+ } else {
817
+ // CoValue not in storage, ask peer for full content
818
+ this.requestFullContent(msg.id, peer);
819
+ }
820
+ });
737
821
  return;
738
822
  }
739
823
 
@@ -823,4 +823,140 @@ describe("StorageApiAsync", () => {
823
823
  expect(() => storage.close()).not.toThrow();
824
824
  });
825
825
  });
826
+
827
+ describe("loadKnownState", () => {
828
+ test("should return cached knownState if available", async () => {
829
+ const { fixturesNode, dbPath } = await createFixturesNode();
830
+ const { storage } = await createTestNode(dbPath);
831
+
832
+ // Create a group to have data in the database
833
+ const group = fixturesNode.createGroup();
834
+ group.addMember("everyone", "reader");
835
+ await group.core.waitForSync();
836
+
837
+ // First call should hit the database and cache the result
838
+ const result1 = await new Promise<CoValueKnownState | undefined>(
839
+ (resolve) => {
840
+ storage.loadKnownState(group.id, resolve);
841
+ },
842
+ );
843
+
844
+ expect(result1).toBeDefined();
845
+ expect(result1?.id).toBe(group.id);
846
+ expect(result1?.header).toBe(true);
847
+
848
+ // Second call should return from cache
849
+ const result2 = await new Promise<CoValueKnownState | undefined>(
850
+ (resolve) => {
851
+ storage.loadKnownState(group.id, resolve);
852
+ },
853
+ );
854
+
855
+ expect(result2).toEqual(result1);
856
+ });
857
+
858
+ test("should return undefined for non-existent CoValue", async () => {
859
+ const { storage } = await createTestNode();
860
+
861
+ const result = await new Promise<CoValueKnownState | undefined>(
862
+ (resolve) => {
863
+ storage.loadKnownState("co_nonexistent" as any, resolve);
864
+ },
865
+ );
866
+
867
+ expect(result).toBeUndefined();
868
+ });
869
+
870
+ test("should deduplicate concurrent requests for the same ID", async () => {
871
+ const { fixturesNode, dbPath } = await createFixturesNode();
872
+ const { storage } = await createTestNode(dbPath);
873
+
874
+ // Create a group to have data in the database
875
+ const group = fixturesNode.createGroup();
876
+ group.addMember("everyone", "reader");
877
+ await group.core.waitForSync();
878
+
879
+ // Clear the cache to force database access
880
+ storage.knownStates.knownStates.clear();
881
+
882
+ // Spy on the database client to track how many times it's called
883
+ const dbClientSpy = vi.spyOn(
884
+ (storage as any).dbClient,
885
+ "getCoValueKnownState",
886
+ );
887
+
888
+ // Make multiple concurrent requests for the same ID
889
+ const promises = [
890
+ new Promise<CoValueKnownState | undefined>((resolve) => {
891
+ storage.loadKnownState(group.id, resolve);
892
+ }),
893
+ new Promise<CoValueKnownState | undefined>((resolve) => {
894
+ storage.loadKnownState(group.id, resolve);
895
+ }),
896
+ new Promise<CoValueKnownState | undefined>((resolve) => {
897
+ storage.loadKnownState(group.id, resolve);
898
+ }),
899
+ ];
900
+
901
+ const results = await Promise.all(promises);
902
+
903
+ // All results should be the same
904
+ expect(results[0]).toEqual(results[1]);
905
+ expect(results[1]).toEqual(results[2]);
906
+ expect(results[0]?.id).toBe(group.id);
907
+
908
+ // Database should only be called once due to deduplication
909
+ expect(dbClientSpy).toHaveBeenCalledTimes(1);
910
+ });
911
+
912
+ test("should use cache and not query database when cache is populated", async () => {
913
+ const { fixturesNode, dbPath } = await createFixturesNode();
914
+ const { storage } = await createTestNode(dbPath);
915
+
916
+ // Create a group to have data in the database
917
+ const group = fixturesNode.createGroup();
918
+ group.addMember("everyone", "reader");
919
+ await group.core.waitForSync();
920
+
921
+ // Spy on the database client to track calls
922
+ const dbClientSpy = vi.spyOn(
923
+ (storage as any).dbClient,
924
+ "getCoValueKnownState",
925
+ );
926
+
927
+ // First call - should hit the database
928
+ const result1 = await new Promise<CoValueKnownState | undefined>(
929
+ (resolve) => {
930
+ storage.loadKnownState(group.id, resolve);
931
+ },
932
+ );
933
+
934
+ expect(result1).toBeDefined();
935
+ expect(dbClientSpy).toHaveBeenCalledTimes(1);
936
+
937
+ // Clear the spy to reset call count
938
+ dbClientSpy.mockClear();
939
+
940
+ // Second call - should use cache, not database
941
+ const result2 = await new Promise<CoValueKnownState | undefined>(
942
+ (resolve) => {
943
+ storage.loadKnownState(group.id, resolve);
944
+ },
945
+ );
946
+
947
+ expect(result2).toEqual(result1);
948
+ // Database should NOT be called since cache was hit
949
+ expect(dbClientSpy).toHaveBeenCalledTimes(0);
950
+
951
+ // Third call - also from cache
952
+ const result3 = await new Promise<CoValueKnownState | undefined>(
953
+ (resolve) => {
954
+ storage.loadKnownState(group.id, resolve);
955
+ },
956
+ );
957
+
958
+ expect(result3).toEqual(result1);
959
+ expect(dbClientSpy).toHaveBeenCalledTimes(0);
960
+ });
961
+ });
826
962
  });
@@ -619,4 +619,136 @@ describe("StorageApiSync", () => {
619
619
  expect(() => storage.close()).not.toThrow();
620
620
  });
621
621
  });
622
+
623
+ describe("loadKnownState", () => {
624
+ test("should return correct knownState structure for existing CoValue", async () => {
625
+ const { fixturesNode, dbPath } = await createFixturesNode();
626
+ const { storage } = await createTestNode(dbPath);
627
+
628
+ // Create a group to have data in the database
629
+ const group = fixturesNode.createGroup();
630
+ group.addMember("everyone", "reader");
631
+ await group.core.waitForSync();
632
+
633
+ const result = await new Promise<CoValueKnownState | undefined>(
634
+ (resolve) => {
635
+ storage.loadKnownState(group.id, resolve);
636
+ },
637
+ );
638
+
639
+ expect(result).toBeDefined();
640
+ expect(result?.id).toBe(group.id);
641
+ expect(result?.header).toBe(true);
642
+ expect(result?.sessions).toEqual(group.core.knownState().sessions);
643
+ });
644
+
645
+ test("should return undefined for non-existent CoValue", async () => {
646
+ const { storage } = await createTestNode();
647
+
648
+ const result = await new Promise<CoValueKnownState | undefined>(
649
+ (resolve) => {
650
+ storage.loadKnownState("co_nonexistent" as any, resolve);
651
+ },
652
+ );
653
+
654
+ expect(result).toBeUndefined();
655
+ });
656
+
657
+ test("should handle CoValue with no sessions (header only)", async () => {
658
+ const { fixturesNode, dbPath } = await createFixturesNode();
659
+ const { storage } = await createTestNode(dbPath);
660
+
661
+ // Create a CoValue with just a header (no transactions yet)
662
+ const coValue = fixturesNode.createCoValue({
663
+ type: "comap",
664
+ ruleset: { type: "unsafeAllowAll" },
665
+ meta: null,
666
+ ...crypto.createdNowUnique(),
667
+ });
668
+ await coValue.waitForSync();
669
+
670
+ const result = await new Promise<CoValueKnownState | undefined>(
671
+ (resolve) => {
672
+ storage.loadKnownState(coValue.id, resolve);
673
+ },
674
+ );
675
+
676
+ expect(result).toBeDefined();
677
+ expect(result?.id).toBe(coValue.id);
678
+ expect(result?.header).toBe(true);
679
+ // The sessions should have one entry with lastIdx = 0 (just header)
680
+ expect(Object.keys(result?.sessions || {}).length).toBe(0);
681
+ });
682
+
683
+ test("should handle CoValue with multiple sessions", async () => {
684
+ const { fixturesNode, dbPath } = await createFixturesNode();
685
+ const { fixturesNode: fixturesNode2 } = await createFixturesNode(dbPath);
686
+ const { storage } = await createTestNode(dbPath);
687
+
688
+ // Create a CoValue and have two nodes make transactions
689
+ const coValue = fixturesNode.createCoValue({
690
+ type: "comap",
691
+ ruleset: { type: "unsafeAllowAll" },
692
+ meta: null,
693
+ ...crypto.createdNowUnique(),
694
+ });
695
+
696
+ coValue.makeTransaction([{ key1: "value1" }], "trusting");
697
+ await coValue.waitForSync();
698
+
699
+ const coValueOnNode2 = await loadCoValueOrFail(
700
+ fixturesNode2,
701
+ coValue.id as CoID<RawCoMap>,
702
+ );
703
+
704
+ coValueOnNode2.set("key2", "value2", "trusting");
705
+ await coValueOnNode2.core.waitForSync();
706
+
707
+ const result = await new Promise<CoValueKnownState | undefined>(
708
+ (resolve) => {
709
+ storage.loadKnownState(coValue.id, resolve);
710
+ },
711
+ );
712
+
713
+ expect(result).toBeDefined();
714
+ expect(result?.id).toBe(coValue.id);
715
+ expect(result?.header).toBe(true);
716
+ // Should have two sessions
717
+ expect(Object.keys(result?.sessions || {}).length).toBe(2);
718
+ // Verify sessions match the expected state
719
+ expect(result?.sessions).toEqual(
720
+ coValueOnNode2.core.knownState().sessions,
721
+ );
722
+ });
723
+
724
+ test("should use cache when knownState is cached", async () => {
725
+ const { fixturesNode, dbPath } = await createFixturesNode();
726
+ const { storage } = await createTestNode(dbPath);
727
+
728
+ // Create a group to have data in the database
729
+ const group = fixturesNode.createGroup();
730
+ group.addMember("everyone", "reader");
731
+ await group.core.waitForSync();
732
+
733
+ // First call should hit the database and cache the result
734
+ const result1 = await new Promise<CoValueKnownState | undefined>(
735
+ (resolve) => {
736
+ storage.loadKnownState(group.id, resolve);
737
+ },
738
+ );
739
+
740
+ expect(result1).toBeDefined();
741
+ expect(result1?.id).toBe(group.id);
742
+ expect(result1?.header).toBe(true);
743
+
744
+ // Second call should return from cache
745
+ const result2 = await new Promise<CoValueKnownState | undefined>(
746
+ (resolve) => {
747
+ storage.loadKnownState(group.id, resolve);
748
+ },
749
+ );
750
+
751
+ expect(result2).toEqual(result1);
752
+ });
753
+ });
622
754
  });
@@ -36,6 +36,7 @@ function createMockStorage(
36
36
  ) => void;
37
37
  store?: (data: any, correctionCallback: any) => void;
38
38
  getKnownState?: (id: RawCoID) => any;
39
+ loadKnownState?: (id: string, callback: (knownState: any) => void) => void;
39
40
  waitForSync?: (id: string, coValue: any) => Promise<void>;
40
41
  trackCoValuesSyncState?: (
41
42
  operations: Array<{ id: RawCoID; peerId: PeerID; synced: boolean }>,
@@ -51,6 +52,8 @@ function createMockStorage(
51
52
  load: opts.load || vi.fn(),
52
53
  store: opts.store || vi.fn(),
53
54
  getKnownState: opts.getKnownState || vi.fn(),
55
+ loadKnownState:
56
+ opts.loadKnownState || vi.fn((id, callback) => callback(undefined)),
54
57
  waitForSync: opts.waitForSync || vi.fn().mockResolvedValue(undefined),
55
58
  trackCoValuesSyncState: opts.trackCoValuesSyncState || vi.fn(),
56
59
  getUnsyncedCoValueIDs: opts.getUnsyncedCoValueIDs || vi.fn(),