cojson 0.20.9 → 0.20.10

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 (169) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +20 -0
  3. package/dist/PeerState.d.ts +2 -2
  4. package/dist/PeerState.d.ts.map +1 -1
  5. package/dist/PeerState.js +3 -3
  6. package/dist/PeerState.js.map +1 -1
  7. package/dist/StorageReconciliationAckTracker.d.ts +14 -0
  8. package/dist/StorageReconciliationAckTracker.d.ts.map +1 -0
  9. package/dist/StorageReconciliationAckTracker.js +72 -0
  10. package/dist/StorageReconciliationAckTracker.js.map +1 -0
  11. package/dist/SyncStateManager.js +2 -2
  12. package/dist/SyncStateManager.js.map +1 -1
  13. package/dist/coValueCore/coValueCore.d.ts +2 -1
  14. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  15. package/dist/coValueCore/coValueCore.js +43 -10
  16. package/dist/coValueCore/coValueCore.js.map +1 -1
  17. package/dist/coValues/coList.d.ts +2 -0
  18. package/dist/coValues/coList.d.ts.map +1 -1
  19. package/dist/coValues/coList.js +28 -0
  20. package/dist/coValues/coList.js.map +1 -1
  21. package/dist/coValues/group.d.ts +4 -1
  22. package/dist/coValues/group.d.ts.map +1 -1
  23. package/dist/coValues/group.js +15 -1
  24. package/dist/coValues/group.js.map +1 -1
  25. package/dist/config.d.ts +8 -0
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +14 -0
  28. package/dist/config.js.map +1 -1
  29. package/dist/exports.d.ts +9 -1
  30. package/dist/exports.d.ts.map +1 -1
  31. package/dist/exports.js +5 -1
  32. package/dist/exports.js.map +1 -1
  33. package/dist/localNode.d.ts +7 -3
  34. package/dist/localNode.d.ts.map +1 -1
  35. package/dist/localNode.js +13 -5
  36. package/dist/localNode.js.map +1 -1
  37. package/dist/permissions.d.ts +1 -0
  38. package/dist/permissions.d.ts.map +1 -1
  39. package/dist/queue/LinkedList.d.ts +2 -0
  40. package/dist/queue/LinkedList.d.ts.map +1 -1
  41. package/dist/queue/LinkedList.js +7 -0
  42. package/dist/queue/LinkedList.js.map +1 -1
  43. package/dist/queue/OutgoingLoadQueue.d.ts +4 -1
  44. package/dist/queue/OutgoingLoadQueue.d.ts.map +1 -1
  45. package/dist/queue/OutgoingLoadQueue.js +41 -13
  46. package/dist/queue/OutgoingLoadQueue.js.map +1 -1
  47. package/dist/queue/PriorityBasedMessageQueue.d.ts +1 -0
  48. package/dist/queue/PriorityBasedMessageQueue.d.ts.map +1 -1
  49. package/dist/queue/PriorityBasedMessageQueue.js +11 -1
  50. package/dist/queue/PriorityBasedMessageQueue.js.map +1 -1
  51. package/dist/storage/knownState.d.ts +2 -0
  52. package/dist/storage/knownState.d.ts.map +1 -1
  53. package/dist/storage/knownState.js +11 -0
  54. package/dist/storage/knownState.js.map +1 -1
  55. package/dist/storage/sqlite/client.d.ts +10 -1
  56. package/dist/storage/sqlite/client.d.ts.map +1 -1
  57. package/dist/storage/sqlite/client.js +84 -0
  58. package/dist/storage/sqlite/client.js.map +1 -1
  59. package/dist/storage/sqlite/sqliteMigrations.d.ts.map +1 -1
  60. package/dist/storage/sqlite/sqliteMigrations.js +11 -0
  61. package/dist/storage/sqlite/sqliteMigrations.js.map +1 -1
  62. package/dist/storage/sqliteAsync/client.d.ts +10 -1
  63. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  64. package/dist/storage/sqliteAsync/client.js +86 -0
  65. package/dist/storage/sqliteAsync/client.js.map +1 -1
  66. package/dist/storage/storageAsync.d.ts +9 -2
  67. package/dist/storage/storageAsync.d.ts.map +1 -1
  68. package/dist/storage/storageAsync.js +19 -0
  69. package/dist/storage/storageAsync.js.map +1 -1
  70. package/dist/storage/storageSync.d.ts +9 -2
  71. package/dist/storage/storageSync.d.ts.map +1 -1
  72. package/dist/storage/storageSync.js +20 -13
  73. package/dist/storage/storageSync.js.map +1 -1
  74. package/dist/storage/types.d.ts +64 -0
  75. package/dist/storage/types.d.ts.map +1 -1
  76. package/dist/storage/types.js.map +1 -1
  77. package/dist/sync.d.ts +44 -2
  78. package/dist/sync.d.ts.map +1 -1
  79. package/dist/sync.js +268 -44
  80. package/dist/sync.js.map +1 -1
  81. package/dist/tests/OutgoingLoadQueue.test.js +137 -39
  82. package/dist/tests/OutgoingLoadQueue.test.js.map +1 -1
  83. package/dist/tests/SQLiteClientAsync.test.js +1 -1
  84. package/dist/tests/SQLiteClientAsync.test.js.map +1 -1
  85. package/dist/tests/StorageApiAsync.test.js +138 -0
  86. package/dist/tests/StorageApiAsync.test.js.map +1 -1
  87. package/dist/tests/StorageApiSync.test.js +154 -0
  88. package/dist/tests/StorageApiSync.test.js.map +1 -1
  89. package/dist/tests/StorageReconciliationAckTracker.test.d.ts +2 -0
  90. package/dist/tests/StorageReconciliationAckTracker.test.d.ts.map +1 -0
  91. package/dist/tests/StorageReconciliationAckTracker.test.js +74 -0
  92. package/dist/tests/StorageReconciliationAckTracker.test.js.map +1 -0
  93. package/dist/tests/SyncStateManager.test.js +18 -0
  94. package/dist/tests/SyncStateManager.test.js.map +1 -1
  95. package/dist/tests/coList.test.js +112 -1
  96. package/dist/tests/coList.test.js.map +1 -1
  97. package/dist/tests/coValueCore.loadFromStorage.test.js +36 -0
  98. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  99. package/dist/tests/group.test.js +44 -0
  100. package/dist/tests/group.test.js.map +1 -1
  101. package/dist/tests/knownState.lazyLoading.test.js +6 -0
  102. package/dist/tests/knownState.lazyLoading.test.js.map +1 -1
  103. package/dist/tests/messagesTestUtils.d.ts.map +1 -1
  104. package/dist/tests/messagesTestUtils.js +4 -0
  105. package/dist/tests/messagesTestUtils.js.map +1 -1
  106. package/dist/tests/sync.concurrentLoad.test.js +333 -1
  107. package/dist/tests/sync.concurrentLoad.test.js.map +1 -1
  108. package/dist/tests/sync.garbageCollection.test.js +4 -0
  109. package/dist/tests/sync.garbageCollection.test.js.map +1 -1
  110. package/dist/tests/sync.load.test.js +19 -0
  111. package/dist/tests/sync.load.test.js.map +1 -1
  112. package/dist/tests/sync.mesh.test.js +1 -0
  113. package/dist/tests/sync.mesh.test.js.map +1 -1
  114. package/dist/tests/sync.multipleServers.test.js +41 -3
  115. package/dist/tests/sync.multipleServers.test.js.map +1 -1
  116. package/dist/tests/sync.storage.test.js +2 -0
  117. package/dist/tests/sync.storage.test.js.map +1 -1
  118. package/dist/tests/sync.storageAsync.test.js +1 -0
  119. package/dist/tests/sync.storageAsync.test.js.map +1 -1
  120. package/dist/tests/sync.storageReconciliation.test.d.ts +2 -0
  121. package/dist/tests/sync.storageReconciliation.test.d.ts.map +1 -0
  122. package/dist/tests/sync.storageReconciliation.test.js +501 -0
  123. package/dist/tests/sync.storageReconciliation.test.js.map +1 -0
  124. package/dist/tests/testUtils.d.ts +1 -0
  125. package/dist/tests/testUtils.d.ts.map +1 -1
  126. package/dist/tests/testUtils.js +3 -2
  127. package/dist/tests/testUtils.js.map +1 -1
  128. package/package.json +4 -4
  129. package/src/PeerState.ts +10 -3
  130. package/src/StorageReconciliationAckTracker.ts +83 -0
  131. package/src/SyncStateManager.ts +3 -3
  132. package/src/coValueCore/coValueCore.ts +47 -16
  133. package/src/coValues/coList.ts +23 -0
  134. package/src/coValues/group.ts +18 -0
  135. package/src/config.ts +18 -0
  136. package/src/exports.ts +8 -0
  137. package/src/localNode.ts +18 -0
  138. package/src/permissions.ts +1 -1
  139. package/src/queue/LinkedList.ts +10 -0
  140. package/src/queue/OutgoingLoadQueue.ts +57 -15
  141. package/src/queue/PriorityBasedMessageQueue.ts +15 -1
  142. package/src/storage/knownState.ts +14 -0
  143. package/src/storage/sqlite/client.ts +128 -0
  144. package/src/storage/sqlite/sqliteMigrations.ts +11 -0
  145. package/src/storage/sqliteAsync/client.ts +139 -0
  146. package/src/storage/storageAsync.ts +37 -0
  147. package/src/storage/storageSync.ts +41 -16
  148. package/src/storage/types.ts +110 -0
  149. package/src/sync.ts +311 -14
  150. package/src/tests/OutgoingLoadQueue.test.ts +226 -59
  151. package/src/tests/SQLiteClientAsync.test.ts +1 -1
  152. package/src/tests/StorageApiAsync.test.ts +161 -1
  153. package/src/tests/StorageApiSync.test.ts +176 -0
  154. package/src/tests/StorageReconciliationAckTracker.test.ts +99 -0
  155. package/src/tests/SyncStateManager.test.ts +25 -0
  156. package/src/tests/coList.test.ts +138 -0
  157. package/src/tests/coValueCore.loadFromStorage.test.ts +72 -1
  158. package/src/tests/group.test.ts +87 -0
  159. package/src/tests/knownState.lazyLoading.test.ts +36 -1
  160. package/src/tests/messagesTestUtils.ts +4 -0
  161. package/src/tests/sync.concurrentLoad.test.ts +491 -0
  162. package/src/tests/sync.garbageCollection.test.ts +4 -0
  163. package/src/tests/sync.load.test.ts +26 -0
  164. package/src/tests/sync.mesh.test.ts +1 -0
  165. package/src/tests/sync.multipleServers.test.ts +60 -2
  166. package/src/tests/sync.storage.test.ts +2 -0
  167. package/src/tests/sync.storageAsync.test.ts +1 -0
  168. package/src/tests/sync.storageReconciliation.test.ts +697 -0
  169. package/src/tests/testUtils.ts +10 -1
@@ -1,6 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
2
  import {
3
3
  CO_VALUE_LOADING_CONFIG,
4
+ GARBAGE_COLLECTOR_CONFIG,
5
+ setGarbageCollectorMaxAge,
4
6
  setMaxInFlightLoadsPerPeer,
5
7
  } from "../config.js";
6
8
  import {
@@ -19,6 +21,7 @@ let jazzCloud: ReturnType<typeof setupTestNode>;
19
21
  // Store original config values
20
22
  let originalMaxInFlightLoads: number;
21
23
  let originalTimeout: number;
24
+ let originalGarbageCollectorMaxAge: number;
22
25
 
23
26
  beforeEach(async () => {
24
27
  // We want to simulate a real world communication that happens asynchronously
@@ -27,6 +30,7 @@ beforeEach(async () => {
27
30
  originalMaxInFlightLoads =
28
31
  CO_VALUE_LOADING_CONFIG.MAX_IN_FLIGHT_LOADS_PER_PEER;
29
32
  originalTimeout = CO_VALUE_LOADING_CONFIG.TIMEOUT;
33
+ originalGarbageCollectorMaxAge = GARBAGE_COLLECTOR_CONFIG.MAX_AGE;
30
34
 
31
35
  SyncMessagesLog.clear();
32
36
  jazzCloud = setupTestNode({ isSyncServer: true });
@@ -36,6 +40,7 @@ afterEach(() => {
36
40
  // Restore original config
37
41
  setMaxInFlightLoadsPerPeer(originalMaxInFlightLoads);
38
42
  CO_VALUE_LOADING_CONFIG.TIMEOUT = originalTimeout;
43
+ setGarbageCollectorMaxAge(originalGarbageCollectorMaxAge);
39
44
  vi.useRealTimers();
40
45
  });
41
46
 
@@ -474,6 +479,88 @@ describe("concurrent load", () => {
474
479
  `);
475
480
  });
476
481
 
482
+ test("should forward client LOAD to core even when edge is at concurrency limit", async () => {
483
+ setMaxInFlightLoadsPerPeer(1);
484
+ CO_VALUE_LOADING_CONFIG.TIMEOUT = 60_000;
485
+
486
+ const core = jazzCloud;
487
+ const edge = setupTestNode({ connected: false });
488
+
489
+ const { peerOnServer: edgePeerOnCore, peerState: corePeerOnEdge } =
490
+ edge.connectToSyncServer({
491
+ ourName: "edge",
492
+ syncServerName: "core",
493
+ syncServer: core.node,
494
+ persistent: true,
495
+ });
496
+
497
+ const client = setupTestNode({ connected: false });
498
+ client.connectToSyncServer({
499
+ ourName: "client",
500
+ syncServerName: "edge",
501
+ syncServer: edge.node,
502
+ });
503
+
504
+ // Create two CoValues on core so edge has to forward LOADs to core.
505
+ const group = core.node.createGroup();
506
+ const map1 = group.createMap();
507
+ const map2 = group.createMap();
508
+
509
+ map1.set("key", "value1", "trusting");
510
+ map2.set("key", "value2", "trusting");
511
+
512
+ // Keep the first edge->core load in-flight to saturate the concurrency limit.
513
+ const blocker = blockMessageTypeOnOutgoingPeer(
514
+ edgePeerOnCore,
515
+ "content",
516
+ {},
517
+ );
518
+
519
+ const edgeLoadPromise = edge.node.loadCoValueCore(map1.id);
520
+
521
+ await waitFor(() => {
522
+ const simplified = SyncMessagesLog.getMessages({
523
+ Group: group.core,
524
+ Map1: map1.core,
525
+ Map2: map2.core,
526
+ });
527
+ return simplified.some(
528
+ (m) => m === "edge -> core | LOAD Map1 sessions: empty",
529
+ );
530
+ });
531
+
532
+ // Ensure the edge->core peer is already at its concurrency limit (1 in-flight load).
533
+ // @ts-expect-error loadQueue is private
534
+ expect(corePeerOnEdge.loadQueue.inFlightCount).toBe(1);
535
+
536
+ SyncMessagesLog.clear();
537
+
538
+ // Now the client asks the edge for map2. Edge must forward the LOAD to core
539
+ // even though it already has an in-flight load to core and the limit is 1.
540
+ const clientLoadPromise = client.node.loadCoValueCore(map2.id);
541
+
542
+ await waitFor(() => {
543
+ const simplified = SyncMessagesLog.getMessages({
544
+ Group: group.core,
545
+ Map1: map1.core,
546
+ Map2: map2.core,
547
+ });
548
+ expect(simplified).toContain("edge -> core | LOAD Map2 sessions: empty");
549
+ return true;
550
+ });
551
+
552
+ blocker.unblock();
553
+ blocker.sendBlockedMessages();
554
+
555
+ const [map1OnEdge, map2OnClient] = await Promise.all([
556
+ edgeLoadPromise,
557
+ clientLoadPromise,
558
+ ]);
559
+
560
+ expect(map1OnEdge.isAvailable()).toBe(true);
561
+ expect(map2OnClient.isAvailable()).toBe(true);
562
+ });
563
+
477
564
  test("should keep load slot occupied while streaming large CoValues", async () => {
478
565
  setMaxInFlightLoadsPerPeer(1);
479
566
 
@@ -647,4 +734,408 @@ describe("concurrent load", () => {
647
734
  ]
648
735
  `);
649
736
  });
737
+
738
+ test("should consider garbageCollected load requests processed when server replies with KNOWN", async () => {
739
+ setMaxInFlightLoadsPerPeer(1);
740
+ setGarbageCollectorMaxAge(-1);
741
+
742
+ const client = setupTestNode({
743
+ connected: false,
744
+ });
745
+ client.addStorage({ ourName: "client" });
746
+ client.node.enableGarbageCollector();
747
+
748
+ const group = client.node.createGroup();
749
+ const map1 = group.createMap();
750
+ const map2 = group.createMap();
751
+
752
+ map1.set("key", "value1", "trusting");
753
+ map2.set("key", "value2", "trusting");
754
+
755
+ const { peerState } = client.connectToSyncServer();
756
+ await client.node.syncManager.waitForAllCoValuesSync();
757
+
758
+ // Disconnect and GC so the node keeps only garbageCollected shells with cached knownState.
759
+ peerState.gracefulShutdown();
760
+ client.node.garbageCollector?.collect();
761
+ client.node.garbageCollector?.collect();
762
+
763
+ const gcGroup = client.node.getCoValue(group.id);
764
+ const gcMap1 = client.node.getCoValue(map1.id);
765
+ const gcMap2 = client.node.getCoValue(map2.id);
766
+
767
+ expect(gcGroup.loadingState).toBe("garbageCollected");
768
+ expect(gcMap1.loadingState).toBe("garbageCollected");
769
+ expect(gcMap2.loadingState).toBe("garbageCollected");
770
+
771
+ SyncMessagesLog.clear();
772
+
773
+ client.connectToSyncServer();
774
+
775
+ await waitFor(() => {
776
+ const messages = SyncMessagesLog.getMessages({
777
+ Group: gcGroup,
778
+ Map1: gcMap1,
779
+ Map2: gcMap2,
780
+ });
781
+
782
+ expect(messages).toMatchInlineSnapshot(`
783
+ [
784
+ "client -> server | LOAD Group sessions: header/4",
785
+ "server -> client | KNOWN Group sessions: header/4",
786
+ "client -> server | LOAD Map1 sessions: header/1",
787
+ "server -> client | KNOWN Map1 sessions: header/1",
788
+ "client -> server | LOAD Map2 sessions: header/1",
789
+ "server -> client | KNOWN Map2 sessions: header/1",
790
+ ]
791
+ `);
792
+ return true;
793
+ });
794
+
795
+ // Create a new group to test that the load queue is now empty
796
+ const groupToTestTheLoadQueue = await loadCoValueOrFail(
797
+ client.node,
798
+ jazzCloud.node.createGroup().id,
799
+ );
800
+ expect(groupToTestTheLoadQueue.core.isAvailable()).toBe(true);
801
+ });
802
+
803
+ test("should load garbageCollected CoValues when receiving KNOWN from a peer", async () => {
804
+ setGarbageCollectorMaxAge(-1);
805
+
806
+ const client = setupTestNode({
807
+ connected: false,
808
+ });
809
+ client.addStorage({ ourName: "client" });
810
+ client.node.enableGarbageCollector();
811
+
812
+ const group = client.node.createGroup();
813
+ const map = group.createMap();
814
+ map.set("key", "value", "trusting");
815
+
816
+ client.node.garbageCollector?.collect();
817
+ client.node.garbageCollector?.collect();
818
+
819
+ const gcMap = client.node.getCoValue(map.id);
820
+ expect(gcMap.loadingState).toBe("garbageCollected");
821
+
822
+ const { peerState } = client.connectToSyncServer({
823
+ skipReconciliation: true,
824
+ });
825
+
826
+ const loadSpy = vi.spyOn(client.node, "loadCoValueCore");
827
+
828
+ client.node.syncManager.handleKnownState(
829
+ {
830
+ action: "known",
831
+ id: map.id,
832
+ header: false,
833
+ sessions: {},
834
+ },
835
+ peerState,
836
+ );
837
+
838
+ expect(loadSpy).toHaveBeenCalledWith(map.id);
839
+
840
+ loadSpy.mockRestore();
841
+ });
842
+
843
+ test("should consider onlyKnownState load requests processed when server replies with KNOWN", async () => {
844
+ setMaxInFlightLoadsPerPeer(1);
845
+
846
+ const client = setupTestNode({
847
+ connected: false,
848
+ });
849
+ const { storage } = client.addStorage({ ourName: "client" });
850
+
851
+ const group = client.node.createGroup();
852
+ const map1 = group.createMap();
853
+ const map2 = group.createMap();
854
+
855
+ map1.set("key", "value1", "trusting");
856
+ map2.set("key", "value2", "trusting");
857
+
858
+ const { peerState } = client.connectToSyncServer();
859
+ await client.node.syncManager.waitForAllCoValuesSync();
860
+ peerState.gracefulShutdown();
861
+
862
+ await client.restart();
863
+ client.addStorage({ storage });
864
+
865
+ const onlyKnownGroup = client.node.getCoValue(group.id);
866
+ const onlyKnownMap1 = client.node.getCoValue(map1.id);
867
+ const onlyKnownMap2 = client.node.getCoValue(map2.id);
868
+
869
+ await Promise.all([
870
+ new Promise<void>((resolve) =>
871
+ onlyKnownGroup.getKnownStateFromStorage(() => resolve()),
872
+ ),
873
+ new Promise<void>((resolve) =>
874
+ onlyKnownMap1.getKnownStateFromStorage(() => resolve()),
875
+ ),
876
+ new Promise<void>((resolve) =>
877
+ onlyKnownMap2.getKnownStateFromStorage(() => resolve()),
878
+ ),
879
+ ]);
880
+
881
+ expect(onlyKnownGroup.loadingState).toBe("onlyKnownState");
882
+ expect(onlyKnownMap1.loadingState).toBe("onlyKnownState");
883
+ expect(onlyKnownMap2.loadingState).toBe("onlyKnownState");
884
+
885
+ SyncMessagesLog.clear();
886
+
887
+ client.connectToSyncServer();
888
+
889
+ await waitFor(() => {
890
+ const messages = SyncMessagesLog.getMessages({
891
+ Group: onlyKnownGroup,
892
+ Map1: onlyKnownMap1,
893
+ Map2: onlyKnownMap2,
894
+ });
895
+
896
+ expect(messages).toMatchInlineSnapshot(`
897
+ [
898
+ "client -> server | LOAD Group sessions: header/4",
899
+ "server -> client | KNOWN Group sessions: header/4",
900
+ "client -> server | LOAD Map1 sessions: header/1",
901
+ "server -> client | KNOWN Map1 sessions: header/1",
902
+ "client -> server | LOAD Map2 sessions: header/1",
903
+ "server -> client | KNOWN Map2 sessions: header/1",
904
+ ]
905
+ `);
906
+ return true;
907
+ });
908
+
909
+ // Create a new group to test that the load queue is now empty
910
+ const groupToTestTheLoadQueue = await loadCoValueOrFail(
911
+ client.node,
912
+ jazzCloud.node.createGroup().id,
913
+ );
914
+ expect(groupToTestTheLoadQueue.core.isAvailable()).toBe(true);
915
+ });
916
+
917
+ test("should keep onlyKnownState while peer load is pending and KNOWN replies arrive", async () => {
918
+ const client = setupTestNode({
919
+ connected: false,
920
+ });
921
+ const { storage } = client.addStorage({ ourName: "client" });
922
+
923
+ const group = client.node.createGroup();
924
+ const map = group.createMap();
925
+ map.set("key", "value", "trusting");
926
+
927
+ const initialConnection = client.connectToSyncServer();
928
+ await client.node.syncManager.waitForAllCoValuesSync();
929
+ initialConnection.peerState.gracefulShutdown();
930
+
931
+ await client.restart();
932
+ client.addStorage({ storage });
933
+
934
+ const onlyKnownMap = client.node.getCoValue(map.id);
935
+ await new Promise<void>((resolve) =>
936
+ onlyKnownMap.getKnownStateFromStorage(() => resolve()),
937
+ );
938
+ expect(onlyKnownMap.loadingState).toBe("onlyKnownState");
939
+
940
+ // Force explicit loads to use peers (not local full-content storage).
941
+ vi.spyOn(storage, "load").mockImplementation(
942
+ async (_id: unknown, _cb: unknown, done: (result: boolean) => void) =>
943
+ done(false),
944
+ );
945
+
946
+ const { peerState } = client.connectToSyncServer({
947
+ skipReconciliation: true,
948
+ });
949
+
950
+ SyncMessagesLog.clear();
951
+
952
+ onlyKnownMap.load([peerState]);
953
+
954
+ await waitFor(() => {
955
+ const messages = SyncMessagesLog.getMessages({
956
+ Group: client.node.getCoValue(group.id),
957
+ Map: onlyKnownMap,
958
+ });
959
+
960
+ expect(messages).toContain(
961
+ "client -> server | LOAD Map sessions: header/1",
962
+ );
963
+ expect(messages).toContain(
964
+ "server -> client | KNOWN Map sessions: header/1",
965
+ );
966
+ return true;
967
+ });
968
+
969
+ expect(onlyKnownMap.getLoadingStateForPeer(peerState.id)).toBe("pending");
970
+ expect(onlyKnownMap.loadingState).toBe("onlyKnownState");
971
+ });
972
+
973
+ /**
974
+ * This test covers the case where the client is streaming a value from storage and since the value is already on the server
975
+ * the server sends only a KNOWN message.
976
+ *
977
+ * Without a specialized logic the value results inStreaming and the "load" would be considered in-flight indefinitely.
978
+ */
979
+ test("should process queued loads when KNOWN arrives while first CoValue is streaming from storage", async () => {
980
+ setMaxInFlightLoadsPerPeer(1);
981
+
982
+ const client = setupTestNode({
983
+ connected: true,
984
+ });
985
+ const { storage } = await client.addAsyncStorage({ ourName: "client" });
986
+
987
+ const group = jazzCloud.node.createGroup();
988
+ const streamingMap = group.createMap();
989
+ fillCoMapWithLargeData(streamingMap);
990
+
991
+ const queuedMap = group.createMap();
992
+ queuedMap.set("key", "value", "trusting");
993
+
994
+ const mapOnClient = await loadCoValueOrFail(client.node, streamingMap.id);
995
+ await mapOnClient.core.waitForFullStreaming();
996
+
997
+ await client.restart();
998
+ client.addStorage({ storage });
999
+ client.connectToSyncServer();
1000
+
1001
+ SyncMessagesLog.clear();
1002
+
1003
+ const originalLoad = storage.load.bind(storage);
1004
+ let firstChunk = true;
1005
+ const pausedOps: (() => void)[] = [];
1006
+
1007
+ vi.spyOn(storage, "load").mockImplementation(async (id, callback, done) => {
1008
+ if (id !== streamingMap.id) {
1009
+ return originalLoad(id, callback, done);
1010
+ }
1011
+
1012
+ return originalLoad(
1013
+ id,
1014
+ (chunk) => {
1015
+ if (firstChunk) {
1016
+ firstChunk = false;
1017
+ callback(chunk);
1018
+ } else {
1019
+ pausedOps.push(() => callback(chunk));
1020
+ }
1021
+ },
1022
+ (found) => {
1023
+ pausedOps.push(() => done(found));
1024
+ },
1025
+ );
1026
+ });
1027
+
1028
+ const streamingMapOnClientPromise = client.node.loadCoValueCore(
1029
+ streamingMap.id,
1030
+ );
1031
+
1032
+ await waitFor(() => {
1033
+ expect(firstChunk).toBe(false);
1034
+ });
1035
+
1036
+ const queuedMapOnClient = await client.node.loadCoValueCore(queuedMap.id);
1037
+
1038
+ expect(queuedMapOnClient.isAvailable()).toBe(true);
1039
+
1040
+ for (const op of pausedOps) {
1041
+ op();
1042
+ }
1043
+
1044
+ const streamingMapOnClient = await streamingMapOnClientPromise;
1045
+ expect(streamingMapOnClient.isStreaming()).toBe(false);
1046
+ });
1047
+
1048
+ test("should process queued loads when CoValue instance changes while in-flight", async () => {
1049
+ setMaxInFlightLoadsPerPeer(1);
1050
+
1051
+ const client = setupTestNode({
1052
+ connected: false,
1053
+ });
1054
+ const { storage } = client.addStorage({ ourName: "client" });
1055
+
1056
+ const { peerOnServer } = client.connectToSyncServer();
1057
+
1058
+ const group = jazzCloud.node.createGroup();
1059
+ const map1 = group.createMap();
1060
+ const map2 = group.createMap();
1061
+
1062
+ map1.set("key", "value1", "trusting");
1063
+ map2.set("key", "value2", "trusting");
1064
+
1065
+ // Prime map1 locally so GC leaves a shell with knownState.
1066
+ await loadCoValueOrFail(client.node, map1.id);
1067
+
1068
+ // Force load attempts to go through peers instead of satisfying from storage.
1069
+ vi.spyOn(storage, "load").mockImplementation(
1070
+ async (_id: unknown, _cb: unknown, done: (result: boolean) => void) =>
1071
+ done(false),
1072
+ );
1073
+
1074
+ const unmounted = client.node.internalUnmountCoValue(map1.id);
1075
+ expect(unmounted).toBe(true);
1076
+ expect(client.node.getCoValue(map1.id).loadingState).toBe(
1077
+ "garbageCollected",
1078
+ );
1079
+
1080
+ const blockedKnown = blockMessageTypeOnOutgoingPeer(peerOnServer, "known", {
1081
+ id: map1.id,
1082
+ });
1083
+
1084
+ SyncMessagesLog.clear();
1085
+
1086
+ const map1LoadPromise = client.node.loadCoValueCore(map1.id);
1087
+ const map2LoadPromise = client.node.loadCoValueCore(map2.id);
1088
+
1089
+ await waitFor(() => {
1090
+ expect(
1091
+ SyncMessagesLog.messages.some(
1092
+ (m) => m.msg.action === "load" && m.msg.id === map1.id,
1093
+ ),
1094
+ ).toBe(true);
1095
+ return true;
1096
+ });
1097
+
1098
+ // Queue is saturated by map1 while KNOWN(Map1) is blocked.
1099
+ expect(
1100
+ SyncMessagesLog.messages.some(
1101
+ (m) => m.msg.action === "load" && m.msg.id === map2.id,
1102
+ ),
1103
+ ).toBe(false);
1104
+
1105
+ // Replace the in-flight CoValue instance with a new one (same id).
1106
+ const oldMap1Core = client.node.getCoValue(map1.id);
1107
+ const oldMap1KnownState = oldMap1Core.knownState();
1108
+ client.node.internalDeleteCoValue(map1.id);
1109
+
1110
+ const newMap1Core = client.node.getCoValue(map1.id);
1111
+ expect(newMap1Core).not.toBe(oldMap1Core);
1112
+ newMap1Core.setGarbageCollectedState(oldMap1KnownState);
1113
+ expect(newMap1Core.loadingState).toBe("garbageCollected");
1114
+
1115
+ // Deliver KNOWN(Map1). With ID-based tracking, this should free the slot and send LOAD(Map2).
1116
+ blockedKnown.unblock();
1117
+ blockedKnown.sendBlockedMessages();
1118
+
1119
+ await waitFor(() => {
1120
+ expect(
1121
+ SyncMessagesLog.messages.some(
1122
+ (m) => m.msg.action === "known" && m.msg.id === map1.id,
1123
+ ),
1124
+ ).toBe(true);
1125
+ expect(
1126
+ SyncMessagesLog.messages.some(
1127
+ (m) => m.msg.action === "load" && m.msg.id === map2.id,
1128
+ ),
1129
+ ).toBe(true);
1130
+ return true;
1131
+ });
1132
+
1133
+ // The critical behavior is that map2 starts loading after KNOWN(map1).
1134
+ const map2OnClient = await loadCoValueOrFail(client.node, map2.id);
1135
+ expect(map2OnClient.get("key")).toBe("value2");
1136
+
1137
+ // Avoid unhandled promise rejection in case these earlier promises resolve later.
1138
+ void map1LoadPromise;
1139
+ void map2LoadPromise;
1140
+ });
650
1141
  });
@@ -63,6 +63,7 @@ describe("sync after the garbage collector has run", () => {
63
63
  "storage -> server | CONTENT Map header: true new: After: 0 New: 1",
64
64
  "server -> client | CONTENT Group header: true new: After: 0 New: 4",
65
65
  "server -> client | CONTENT Map header: true new: After: 0 New: 1",
66
+ "server -> client | KNOWN Map sessions: header/1",
66
67
  "client -> server | KNOWN Group sessions: header/4",
67
68
  "client -> server | KNOWN Map sessions: header/1",
68
69
  ]
@@ -105,6 +106,7 @@ describe("sync after the garbage collector has run", () => {
105
106
  "storage -> server | CONTENT Map header: true new: After: 0 New: 1",
106
107
  "server -> client | CONTENT Group header: true new: After: 0 New: 4",
107
108
  "server -> client | CONTENT Map header: true new: After: 0 New: 1",
109
+ "server -> client | KNOWN Map sessions: header/1",
108
110
  "client -> server | KNOWN Group sessions: header/4",
109
111
  "client -> server | KNOWN Map sessions: header/1",
110
112
  ]
@@ -203,6 +205,8 @@ describe("sync after the garbage collector has run", () => {
203
205
  "server -> storage | CONTENT Group header: true new: After: 0 New: 4",
204
206
  "server -> client | KNOWN Map sessions: header/1",
205
207
  "server -> storage | CONTENT Map header: true new: After: 0 New: 1",
208
+ "client -> storage | LOAD Map sessions: empty",
209
+ "storage -> client | CONTENT Map header: true new: After: 0 New: 1",
206
210
  ]
207
211
  `);
208
212
  });
@@ -656,6 +656,31 @@ describe("loading coValues from server", () => {
656
656
  `);
657
657
  });
658
658
 
659
+ test("should mark closed persistent peers as unavailable after grace timeout", async () => {
660
+ vi.useFakeTimers();
661
+
662
+ const client = setupTestNode();
663
+ const connection = client.connectToSyncServer({
664
+ persistent: true,
665
+ });
666
+ connection.peerState.gracefulShutdown();
667
+
668
+ const group = jazzCloud.node.createGroup();
669
+ group.addMember("everyone", "writer");
670
+ const map = group.createMap({
671
+ test: "value",
672
+ });
673
+
674
+ const loadPromise = client.node.load(map.id, true);
675
+
676
+ await vi.advanceTimersByTimeAsync(CO_VALUE_LOADING_CONFIG.TIMEOUT + 10);
677
+
678
+ const coValue = await loadPromise;
679
+ expect(coValue).toBe("unavailable");
680
+
681
+ vi.useRealTimers();
682
+ });
683
+
659
684
  test("should handle reconnections in the middle of a load with a persistent peer", async () => {
660
685
  TEST_NODE_CONFIG.withAsyncPeers = false; // To avoid flakiness
661
686
 
@@ -1550,6 +1575,7 @@ describe("lazy storage load optimization", () => {
1550
1575
  "storage -> server | CONTENT Map header: true new: After: 0 New: 1",
1551
1576
  "server -> client | CONTENT Group header: true new: After: 0 New: 4",
1552
1577
  "server -> client | CONTENT Map header: true new: After: 0 New: 1",
1578
+ "server -> client | KNOWN Map sessions: header/1",
1553
1579
  "client -> server | KNOWN Group sessions: header/4",
1554
1580
  "client -> server | KNOWN Map sessions: header/1",
1555
1581
  ]
@@ -556,6 +556,7 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
556
556
  "edge -> core | LOAD Map sessions: header/100",
557
557
  "edge -> client | CONTENT Group header: true new: After: 0 New: 6",
558
558
  "edge -> client | CONTENT Map header: true new: After: 0 New: 21 expectContentUntil: header/100",
559
+ "edge -> client | KNOWN Map sessions: header/100",
559
560
  "storage -> edge | CONTENT Map header: true new: After: 21 New: 21",
560
561
  "edge -> client | CONTENT Map header: false new: After: 21 New: 21 expectContentUntil: header/100",
561
562
  "storage -> edge | CONTENT Map header: true new: After: 42 New: 21",
@@ -2,9 +2,11 @@ import { assert, beforeEach, describe, expect, test } from "vitest";
2
2
 
3
3
  import { expectList, expectMap } from "../coValue";
4
4
  import { WasmCrypto } from "../crypto/WasmCrypto";
5
+ import { CO_VALUE_LOADING_CONFIG, setCoValueLoadingTimeout } from "../config";
5
6
  import {
6
7
  SyncMessagesLog,
7
8
  TEST_NODE_CONFIG,
9
+ blockMessageTypeOnOutgoingPeer,
8
10
  fillCoMapWithLargeData,
9
11
  loadCoValueOrFail,
10
12
  setupTestNode,
@@ -26,16 +28,18 @@ beforeEach(async () => {
26
28
  });
27
29
 
28
30
  function connectServers(client: ReturnType<typeof setupTestNode>) {
29
- client.connectToSyncServer({
31
+ const server1Connection = client.connectToSyncServer({
30
32
  ourName: "client",
31
33
  syncServerName: "server1",
32
34
  syncServer: server1.node,
33
35
  });
34
- client.connectToSyncServer({
36
+ const server2Connection = client.connectToSyncServer({
35
37
  ourName: "client",
36
38
  syncServerName: "server2",
37
39
  syncServer: server2.node,
38
40
  });
41
+
42
+ return { server1Connection, server2Connection };
39
43
  }
40
44
 
41
45
  describe("multiple servers peers", () => {
@@ -394,6 +398,7 @@ describe("multiple servers peers", () => {
394
398
  "client -> server2 | KNOWN Group sessions: header/6",
395
399
  "client -> server2 | CONTENT Group header: false new: After: 0 New: 6",
396
400
  "client -> server2 | KNOWN Map sessions: header/0",
401
+ "server2 -> client | KNOWN Group sessions: header/6",
397
402
  "server2 -> client | KNOWN Map sessions: header/0",
398
403
  "server1 -> client | KNOWN Map sessions: header/73",
399
404
  "server1 -> client | CONTENT Map header: false new: After: 0 New: 73",
@@ -423,4 +428,57 @@ describe("multiple servers peers", () => {
423
428
  ]
424
429
  `);
425
430
  });
431
+
432
+ test("coValue loading times out on both servers", async () => {
433
+ const previousTimeout = CO_VALUE_LOADING_CONFIG.TIMEOUT;
434
+ setCoValueLoadingTimeout(20);
435
+
436
+ try {
437
+ const creator = setupTestNode();
438
+ connectServers(creator);
439
+
440
+ const group = creator.node.createGroup();
441
+ const map = group.createMap();
442
+ map.set("hello", "world", "trusting");
443
+ await map.core.waitForSync();
444
+
445
+ const client = setupTestNode();
446
+ const { server1Connection, server2Connection } = connectServers(client);
447
+
448
+ blockMessageTypeOnOutgoingPeer(
449
+ server1Connection.peerOnServer,
450
+ "content",
451
+ {
452
+ id: map.id,
453
+ },
454
+ );
455
+ blockMessageTypeOnOutgoingPeer(
456
+ server2Connection.peerOnServer,
457
+ "content",
458
+ {
459
+ id: map.id,
460
+ },
461
+ );
462
+
463
+ const loadedMap = await client.node.load(map.id, true);
464
+
465
+ expect(loadedMap).toBe("unavailable");
466
+ } finally {
467
+ setCoValueLoadingTimeout(previousTimeout);
468
+ }
469
+ });
470
+
471
+ test("coValue loading is unavailable on both servers", async () => {
472
+ const disconnectedNode = setupTestNode();
473
+ const group = disconnectedNode.node.createGroup();
474
+ const map = group.createMap();
475
+ map.set("hello", "world", "trusting");
476
+
477
+ const client = setupTestNode();
478
+ connectServers(client);
479
+
480
+ const loadedMap = await client.node.load(map.id, true);
481
+
482
+ expect(loadedMap).toBe("unavailable");
483
+ });
426
484
  });