cojson 0.19.20 → 0.19.22

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 (159) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/dist/CojsonMessageChannel/CojsonMessageChannel.d.ts +42 -0
  4. package/dist/CojsonMessageChannel/CojsonMessageChannel.d.ts.map +1 -0
  5. package/dist/CojsonMessageChannel/CojsonMessageChannel.js +261 -0
  6. package/dist/CojsonMessageChannel/CojsonMessageChannel.js.map +1 -0
  7. package/dist/CojsonMessageChannel/MessagePortOutgoingChannel.d.ts +18 -0
  8. package/dist/CojsonMessageChannel/MessagePortOutgoingChannel.d.ts.map +1 -0
  9. package/dist/CojsonMessageChannel/MessagePortOutgoingChannel.js +37 -0
  10. package/dist/CojsonMessageChannel/MessagePortOutgoingChannel.js.map +1 -0
  11. package/dist/CojsonMessageChannel/index.d.ts +3 -0
  12. package/dist/CojsonMessageChannel/index.d.ts.map +1 -0
  13. package/dist/CojsonMessageChannel/index.js +2 -0
  14. package/dist/CojsonMessageChannel/index.js.map +1 -0
  15. package/dist/CojsonMessageChannel/types.d.ts +149 -0
  16. package/dist/CojsonMessageChannel/types.d.ts.map +1 -0
  17. package/dist/CojsonMessageChannel/types.js +36 -0
  18. package/dist/CojsonMessageChannel/types.js.map +1 -0
  19. package/dist/GarbageCollector.d.ts +4 -2
  20. package/dist/GarbageCollector.d.ts.map +1 -1
  21. package/dist/GarbageCollector.js +5 -3
  22. package/dist/GarbageCollector.js.map +1 -1
  23. package/dist/SyncStateManager.d.ts +3 -3
  24. package/dist/SyncStateManager.d.ts.map +1 -1
  25. package/dist/SyncStateManager.js +4 -4
  26. package/dist/SyncStateManager.js.map +1 -1
  27. package/dist/coValueCore/coValueCore.d.ts +28 -1
  28. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  29. package/dist/coValueCore/coValueCore.js +50 -5
  30. package/dist/coValueCore/coValueCore.js.map +1 -1
  31. package/dist/coValues/account.d.ts.map +1 -1
  32. package/dist/coValues/account.js +10 -10
  33. package/dist/coValues/account.js.map +1 -1
  34. package/dist/exports.d.ts +1 -0
  35. package/dist/exports.d.ts.map +1 -1
  36. package/dist/exports.js +1 -0
  37. package/dist/exports.js.map +1 -1
  38. package/dist/ids.d.ts +1 -1
  39. package/dist/ids.d.ts.map +1 -1
  40. package/dist/ids.js.map +1 -1
  41. package/dist/knownState.d.ts +5 -0
  42. package/dist/knownState.d.ts.map +1 -1
  43. package/dist/knownState.js +15 -0
  44. package/dist/knownState.js.map +1 -1
  45. package/dist/localNode.d.ts +1 -3
  46. package/dist/localNode.d.ts.map +1 -1
  47. package/dist/localNode.js +11 -4
  48. package/dist/localNode.js.map +1 -1
  49. package/dist/storage/knownState.d.ts +5 -0
  50. package/dist/storage/knownState.d.ts.map +1 -1
  51. package/dist/storage/knownState.js +11 -0
  52. package/dist/storage/knownState.js.map +1 -1
  53. package/dist/storage/sqlite/client.d.ts +2 -0
  54. package/dist/storage/sqlite/client.d.ts.map +1 -1
  55. package/dist/storage/sqlite/client.js +18 -0
  56. package/dist/storage/sqlite/client.js.map +1 -1
  57. package/dist/storage/sqliteAsync/client.d.ts +2 -0
  58. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  59. package/dist/storage/sqliteAsync/client.js +20 -0
  60. package/dist/storage/sqliteAsync/client.js.map +1 -1
  61. package/dist/storage/storageAsync.d.ts +10 -3
  62. package/dist/storage/storageAsync.d.ts.map +1 -1
  63. package/dist/storage/storageAsync.js +52 -3
  64. package/dist/storage/storageAsync.js.map +1 -1
  65. package/dist/storage/storageSync.d.ts +9 -3
  66. package/dist/storage/storageSync.d.ts.map +1 -1
  67. package/dist/storage/storageSync.js +27 -3
  68. package/dist/storage/storageSync.js.map +1 -1
  69. package/dist/storage/types.d.ts +23 -0
  70. package/dist/storage/types.d.ts.map +1 -1
  71. package/dist/sync.d.ts +23 -0
  72. package/dist/sync.d.ts.map +1 -1
  73. package/dist/sync.js +136 -45
  74. package/dist/sync.js.map +1 -1
  75. package/dist/tests/CojsonMessageChannel.test.d.ts +2 -0
  76. package/dist/tests/CojsonMessageChannel.test.d.ts.map +1 -0
  77. package/dist/tests/CojsonMessageChannel.test.js +236 -0
  78. package/dist/tests/CojsonMessageChannel.test.js.map +1 -0
  79. package/dist/tests/GarbageCollector.test.js +87 -13
  80. package/dist/tests/GarbageCollector.test.js.map +1 -1
  81. package/dist/tests/StorageApiAsync.test.js +124 -1
  82. package/dist/tests/StorageApiAsync.test.js.map +1 -1
  83. package/dist/tests/StorageApiSync.test.js +123 -0
  84. package/dist/tests/StorageApiSync.test.js.map +1 -1
  85. package/dist/tests/SyncManager.processQueues.test.js +1 -1
  86. package/dist/tests/SyncManager.processQueues.test.js.map +1 -1
  87. package/dist/tests/SyncStateManager.test.js +1 -1
  88. package/dist/tests/SyncStateManager.test.js.map +1 -1
  89. package/dist/tests/coPlainText.test.js +1 -1
  90. package/dist/tests/coPlainText.test.js.map +1 -1
  91. package/dist/tests/coValueCore.loadFromStorage.test.js +2 -0
  92. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  93. package/dist/tests/knownState.lazyLoading.test.d.ts +2 -0
  94. package/dist/tests/knownState.lazyLoading.test.d.ts.map +1 -0
  95. package/dist/tests/knownState.lazyLoading.test.js +167 -0
  96. package/dist/tests/knownState.lazyLoading.test.js.map +1 -0
  97. package/dist/tests/messagesTestUtils.d.ts +5 -2
  98. package/dist/tests/messagesTestUtils.d.ts.map +1 -1
  99. package/dist/tests/messagesTestUtils.js +4 -0
  100. package/dist/tests/messagesTestUtils.js.map +1 -1
  101. package/dist/tests/sync.garbageCollection.test.js +56 -32
  102. package/dist/tests/sync.garbageCollection.test.js.map +1 -1
  103. package/dist/tests/sync.load.test.js +387 -1
  104. package/dist/tests/sync.load.test.js.map +1 -1
  105. package/dist/tests/sync.mesh.test.js +5 -5
  106. package/dist/tests/sync.mesh.test.js.map +1 -1
  107. package/dist/tests/sync.peerReconciliation.test.js +3 -3
  108. package/dist/tests/sync.peerReconciliation.test.js.map +1 -1
  109. package/dist/tests/sync.storage.test.js +9 -9
  110. package/dist/tests/sync.storage.test.js.map +1 -1
  111. package/dist/tests/sync.storageAsync.test.js +7 -7
  112. package/dist/tests/sync.storageAsync.test.js.map +1 -1
  113. package/dist/tests/sync.tracking.test.js +35 -4
  114. package/dist/tests/sync.tracking.test.js.map +1 -1
  115. package/dist/tests/testStorage.js +38 -2
  116. package/dist/tests/testStorage.js.map +1 -1
  117. package/dist/tests/testUtils.d.ts +38 -4
  118. package/dist/tests/testUtils.d.ts.map +1 -1
  119. package/dist/tests/testUtils.js +68 -7
  120. package/dist/tests/testUtils.js.map +1 -1
  121. package/package.json +4 -4
  122. package/src/CojsonMessageChannel/CojsonMessageChannel.ts +332 -0
  123. package/src/CojsonMessageChannel/MessagePortOutgoingChannel.ts +52 -0
  124. package/src/CojsonMessageChannel/index.ts +9 -0
  125. package/src/CojsonMessageChannel/types.ts +200 -0
  126. package/src/GarbageCollector.ts +5 -5
  127. package/src/SyncStateManager.ts +6 -6
  128. package/src/coValueCore/coValueCore.ts +56 -7
  129. package/src/coValues/account.ts +12 -14
  130. package/src/exports.ts +1 -0
  131. package/src/ids.ts +1 -1
  132. package/src/knownState.ts +24 -0
  133. package/src/localNode.ts +12 -7
  134. package/src/storage/knownState.ts +12 -0
  135. package/src/storage/sqlite/client.ts +31 -0
  136. package/src/storage/sqliteAsync/client.ts +35 -0
  137. package/src/storage/storageAsync.ts +66 -4
  138. package/src/storage/storageSync.ts +37 -4
  139. package/src/storage/types.ts +32 -0
  140. package/src/sync.ts +159 -46
  141. package/src/tests/CojsonMessageChannel.test.ts +306 -0
  142. package/src/tests/GarbageCollector.test.ts +114 -13
  143. package/src/tests/StorageApiAsync.test.ts +186 -1
  144. package/src/tests/StorageApiSync.test.ts +181 -0
  145. package/src/tests/SyncManager.processQueues.test.ts +1 -1
  146. package/src/tests/SyncStateManager.test.ts +1 -1
  147. package/src/tests/coPlainText.test.ts +1 -1
  148. package/src/tests/coValueCore.loadFromStorage.test.ts +5 -0
  149. package/src/tests/knownState.lazyLoading.test.ts +219 -0
  150. package/src/tests/messagesTestUtils.ts +10 -3
  151. package/src/tests/sync.garbageCollection.test.ts +69 -36
  152. package/src/tests/sync.load.test.ts +482 -2
  153. package/src/tests/sync.mesh.test.ts +5 -5
  154. package/src/tests/sync.peerReconciliation.test.ts +3 -3
  155. package/src/tests/sync.storage.test.ts +9 -9
  156. package/src/tests/sync.storageAsync.test.ts +7 -7
  157. package/src/tests/sync.tracking.test.ts +54 -4
  158. package/src/tests/testStorage.ts +40 -2
  159. package/src/tests/testUtils.ts +99 -8
@@ -1,7 +1,13 @@
1
1
  import { assert, beforeEach, describe, expect, test, vi } from "vitest";
2
2
 
3
3
  import { setGarbageCollectorMaxAge } from "../config";
4
- import { TEST_NODE_CONFIG, setupTestAccount, setupTestNode } from "./testUtils";
4
+ import {
5
+ blockMessageTypeOnOutgoingPeer,
6
+ TEST_NODE_CONFIG,
7
+ setupTestAccount,
8
+ setupTestNode,
9
+ } from "./testUtils";
10
+ import { createSyncStorage } from "./testStorage.js";
5
11
 
6
12
  // We want to simulate a real world communication that happens asynchronously
7
13
  TEST_NODE_CONFIG.withAsyncPeers = true;
@@ -10,6 +16,8 @@ beforeEach(() => {
10
16
  // We want to test what happens when the garbage collector kicks in and removes a coValue
11
17
  // We set the max age to -1 to make it remove everything
12
18
  setGarbageCollectorMaxAge(-1);
19
+
20
+ setupTestNode({ isSyncServer: true });
13
21
  });
14
22
 
15
23
  describe("garbage collector", () => {
@@ -19,13 +27,14 @@ describe("garbage collector", () => {
19
27
  client.addStorage({
20
28
  ourName: "client",
21
29
  });
30
+ client.connectToSyncServer();
22
31
  client.node.enableGarbageCollector();
23
32
 
24
33
  const group = client.node.createGroup();
25
34
  const map = group.createMap();
26
35
  map.set("hello", "world", "trusting");
27
36
 
28
- await new Promise((resolve) => setTimeout(resolve, 10));
37
+ await client.node.syncManager.waitForAllCoValuesSync();
29
38
 
30
39
  client.node.garbageCollector?.collect();
31
40
 
@@ -40,6 +49,7 @@ describe("garbage collector", () => {
40
49
  client.addStorage({
41
50
  ourName: "client",
42
51
  });
52
+ client.connectToSyncServer();
43
53
  client.node.enableGarbageCollector();
44
54
 
45
55
  const group = client.node.createGroup();
@@ -51,7 +61,7 @@ describe("garbage collector", () => {
51
61
  // This listener keeps the coValue alive
52
62
  });
53
63
 
54
- await new Promise((resolve) => setTimeout(resolve, 10));
64
+ await client.node.syncManager.waitForAllCoValuesSync();
55
65
 
56
66
  client.node.garbageCollector?.collect();
57
67
 
@@ -66,42 +76,132 @@ describe("garbage collector", () => {
66
76
  expect(client.node.getCoValue(map.id).isAvailable()).toBe(false);
67
77
  });
68
78
 
69
- test("coValues are not garbage collected if they are a group or account", async () => {
70
- const client = await setupTestAccount();
79
+ test("coValues are not garbage collected if they are not synced with server peers", async () => {
80
+ const client = setupTestNode();
71
81
 
72
82
  client.addStorage({
73
83
  ourName: "client",
74
84
  });
75
- client.node.enableGarbageCollector({
76
- garbageCollectGroups: true,
77
- });
85
+ client.node.enableGarbageCollector();
86
+ const { peer: serverPeer } = client.connectToSyncServer();
87
+ // Block sync with server
88
+ const blocker = blockMessageTypeOnOutgoingPeer(serverPeer, "content", {});
78
89
 
79
90
  const group = client.node.createGroup();
91
+ const map = group.createMap();
92
+ map.set("hello", "world", "trusting");
80
93
 
81
94
  await new Promise((resolve) => setTimeout(resolve, 10));
82
95
 
83
96
  client.node.garbageCollector?.collect();
84
97
 
85
- expect(client.node.getCoValue(group.id).isAvailable()).toBe(false);
98
+ expect(client.node.getCoValue(map.id).isAvailable()).toBe(true);
99
+
100
+ // Resume sync with server
101
+ blocker.sendBlockedMessages();
102
+ blocker.unblock();
103
+ await client.node.syncManager.waitForAllCoValuesSync();
104
+
105
+ // The coValue should now be collected
106
+ client.node.garbageCollector?.collect();
107
+
108
+ expect(client.node.getCoValue(map.id).isAvailable()).toBe(false);
109
+ });
110
+
111
+ test("coValues are garbage collected if there are no server peers", async () => {
112
+ const client = setupTestNode();
113
+
114
+ client.addStorage({
115
+ ourName: "client",
116
+ });
117
+ client.node.enableGarbageCollector();
118
+ // Client is not connected to the sync server
119
+
120
+ const group = client.node.createGroup();
121
+ const map = group.createMap();
122
+ map.set("hello", "world", "trusting");
123
+
124
+ await client.node.syncManager.waitForAllCoValuesSync();
125
+
126
+ client.node.garbageCollector?.collect();
127
+
128
+ expect(client.node.getCoValue(map.id).isAvailable()).toBe(false);
129
+ });
130
+
131
+ test("account coValues are not garbage collected if they have dependencies", async () => {
132
+ const client = await setupTestAccount({
133
+ // Add storage before creating the account so it's persisted
134
+ storage: createSyncStorage({
135
+ nodeName: "client",
136
+ storageName: "storage",
137
+ }),
138
+ });
139
+ // The account is created along with its profile, and the group that owns the profile
140
+ const profile = client.node.expectProfileLoaded(client.accountID);
141
+ const profileId = profile.id;
142
+ const profileOwnerId = profile.group.id;
143
+
144
+ client.connectToSyncServer();
145
+ client.node.enableGarbageCollector();
146
+
147
+ await client.node.syncManager.waitForAllCoValuesSync();
148
+
149
+ // First collect removes the profile
150
+ client.node.garbageCollector?.collect();
151
+ expect(client.node.getCoValue(profileId).isAvailable()).toBe(false);
152
+ expect(client.node.getCoValue(profileOwnerId).isAvailable()).toBe(true);
153
+ expect(client.node.getCoValue(client.accountID).isAvailable()).toBe(true);
154
+
155
+ // Second collect removes the profile owner
156
+ client.node.garbageCollector?.collect();
157
+ expect(client.node.getCoValue(profileOwnerId).isAvailable()).toBe(false);
158
+ expect(client.node.getCoValue(client.accountID).isAvailable()).toBe(true);
159
+
160
+ // Third collect removes the account
161
+ client.node.garbageCollector?.collect();
86
162
  expect(client.node.getCoValue(client.accountID).isAvailable()).toBe(false);
87
163
  });
88
164
 
89
- test("group or account coValues are garbage collected if garbageCollectGroups is true", async () => {
90
- const client = await setupTestAccount();
165
+ test("group coValues are garbage collected if they have no dependencies", async () => {
166
+ const client = setupTestNode();
91
167
 
92
168
  client.addStorage({
93
169
  ourName: "client",
94
170
  });
171
+ client.connectToSyncServer();
95
172
  client.node.enableGarbageCollector();
96
173
 
97
174
  const group = client.node.createGroup();
98
175
 
99
- await new Promise((resolve) => setTimeout(resolve, 10));
176
+ await client.node.syncManager.waitForAllCoValuesSync();
100
177
 
101
178
  client.node.garbageCollector?.collect();
102
179
 
180
+ expect(client.node.getCoValue(group.id).isAvailable()).toBe(false);
181
+ });
182
+
183
+ test("group coValues are not garbage collected if they have dependencies", async () => {
184
+ const client = setupTestNode();
185
+
186
+ client.addStorage({
187
+ ourName: "client",
188
+ });
189
+ client.node.enableGarbageCollector();
190
+
191
+ const group = client.node.createGroup();
192
+ const map = group.createMap();
193
+ map.set("hello", "world", "trusting");
194
+
195
+ await client.node.syncManager.waitForAllCoValuesSync();
196
+
197
+ // First collect removes the map
198
+ client.node.garbageCollector?.collect();
103
199
  expect(client.node.getCoValue(group.id).isAvailable()).toBe(true);
104
- expect(client.node.getCoValue(client.accountID).isAvailable()).toBe(true);
200
+ expect(client.node.getCoValue(map.id).isAvailable()).toBe(false);
201
+
202
+ // Second collect removes the group
203
+ client.node.garbageCollector?.collect();
204
+ expect(client.node.getCoValue(group.id).isAvailable()).toBe(false);
105
205
  });
106
206
 
107
207
  test("coValues are not garbage collected if the maxAge is not reached", async () => {
@@ -112,6 +212,7 @@ describe("garbage collector", () => {
112
212
  client.addStorage({
113
213
  ourName: "client",
114
214
  });
215
+ client.connectToSyncServer();
115
216
  client.node.enableGarbageCollector();
116
217
 
117
218
  const garbageCollector = client.node.garbageCollector;
@@ -81,7 +81,7 @@ async function createTestNode(dbPath?: string) {
81
81
  });
82
82
 
83
83
  onTestFinished(async () => {
84
- node.gracefulShutdown();
84
+ await node.gracefulShutdown();
85
85
  await storage.close();
86
86
  });
87
87
 
@@ -782,6 +782,55 @@ describe("StorageApiAsync", () => {
782
782
  const mapOnNode = await loadCoValueOrFail(node, map.id);
783
783
  expect(mapOnNode.get("test")).toEqual("value");
784
784
  });
785
+
786
+ test("should load dependencies again if they were unmounted", async () => {
787
+ const { fixturesNode, dbPath } = await createFixturesNode();
788
+ const { node, storage } = await createTestNode(dbPath);
789
+
790
+ // Create a group and a map owned by that group
791
+ const group = fixturesNode.createGroup();
792
+ group.addMember("everyone", "reader");
793
+ const map = group.createMap({ test: "value" });
794
+ await group.core.waitForSync();
795
+ await map.core.waitForSync();
796
+
797
+ const callback = vi.fn((content) =>
798
+ node.syncManager.handleNewContent(content, "storage"),
799
+ );
800
+ const done = vi.fn();
801
+
802
+ // Load the map (and its group)
803
+ await storage.load(map.id, callback, done);
804
+ callback.mockClear();
805
+ done.mockClear();
806
+
807
+ // Unmount the map and its group
808
+ storage.onCoValueUnmounted(map.id);
809
+ storage.onCoValueUnmounted(group.id);
810
+
811
+ // Load the map. The group dependency should be loaded again
812
+ await storage.load(map.id, callback, done);
813
+
814
+ expect(callback).toHaveBeenCalledTimes(2);
815
+ expect(callback).toHaveBeenNthCalledWith(
816
+ 1,
817
+ expect.objectContaining({
818
+ id: group.id,
819
+ }),
820
+ );
821
+ expect(callback).toHaveBeenNthCalledWith(
822
+ 2,
823
+ expect.objectContaining({
824
+ id: map.id,
825
+ }),
826
+ );
827
+
828
+ expect(done).toHaveBeenCalledWith(true);
829
+
830
+ node.setStorage(storage);
831
+ const mapOnNode = await loadCoValueOrFail(node, map.id);
832
+ expect(mapOnNode.get("test")).toEqual("value");
833
+ });
785
834
  });
786
835
 
787
836
  describe("waitForSync", () => {
@@ -823,4 +872,140 @@ describe("StorageApiAsync", () => {
823
872
  expect(() => storage.close()).not.toThrow();
824
873
  });
825
874
  });
875
+
876
+ describe("loadKnownState", () => {
877
+ test("should return cached knownState if available", async () => {
878
+ const { fixturesNode, dbPath } = await createFixturesNode();
879
+ const { storage } = await createTestNode(dbPath);
880
+
881
+ // Create a group to have data in the database
882
+ const group = fixturesNode.createGroup();
883
+ group.addMember("everyone", "reader");
884
+ await group.core.waitForSync();
885
+
886
+ // First call should hit the database and cache the result
887
+ const result1 = await new Promise<CoValueKnownState | undefined>(
888
+ (resolve) => {
889
+ storage.loadKnownState(group.id, resolve);
890
+ },
891
+ );
892
+
893
+ expect(result1).toBeDefined();
894
+ expect(result1?.id).toBe(group.id);
895
+ expect(result1?.header).toBe(true);
896
+
897
+ // Second call should return from cache
898
+ const result2 = await new Promise<CoValueKnownState | undefined>(
899
+ (resolve) => {
900
+ storage.loadKnownState(group.id, resolve);
901
+ },
902
+ );
903
+
904
+ expect(result2).toEqual(result1);
905
+ });
906
+
907
+ test("should return undefined for non-existent CoValue", async () => {
908
+ const { storage } = await createTestNode();
909
+
910
+ const result = await new Promise<CoValueKnownState | undefined>(
911
+ (resolve) => {
912
+ storage.loadKnownState("co_nonexistent" as any, resolve);
913
+ },
914
+ );
915
+
916
+ expect(result).toBeUndefined();
917
+ });
918
+
919
+ test("should deduplicate concurrent requests for the same ID", async () => {
920
+ const { fixturesNode, dbPath } = await createFixturesNode();
921
+ const { storage } = await createTestNode(dbPath);
922
+
923
+ // Create a group to have data in the database
924
+ const group = fixturesNode.createGroup();
925
+ group.addMember("everyone", "reader");
926
+ await group.core.waitForSync();
927
+
928
+ // Clear the cache to force database access
929
+ storage.knownStates.knownStates.clear();
930
+
931
+ // Spy on the database client to track how many times it's called
932
+ const dbClientSpy = vi.spyOn(
933
+ (storage as any).dbClient,
934
+ "getCoValueKnownState",
935
+ );
936
+
937
+ // Make multiple concurrent requests for the same ID
938
+ const promises = [
939
+ new Promise<CoValueKnownState | undefined>((resolve) => {
940
+ storage.loadKnownState(group.id, resolve);
941
+ }),
942
+ new Promise<CoValueKnownState | undefined>((resolve) => {
943
+ storage.loadKnownState(group.id, resolve);
944
+ }),
945
+ new Promise<CoValueKnownState | undefined>((resolve) => {
946
+ storage.loadKnownState(group.id, resolve);
947
+ }),
948
+ ];
949
+
950
+ const results = await Promise.all(promises);
951
+
952
+ // All results should be the same
953
+ expect(results[0]).toEqual(results[1]);
954
+ expect(results[1]).toEqual(results[2]);
955
+ expect(results[0]?.id).toBe(group.id);
956
+
957
+ // Database should only be called once due to deduplication
958
+ expect(dbClientSpy).toHaveBeenCalledTimes(1);
959
+ });
960
+
961
+ test("should use cache and not query database when cache is populated", async () => {
962
+ const { fixturesNode, dbPath } = await createFixturesNode();
963
+ const { storage } = await createTestNode(dbPath);
964
+
965
+ // Create a group to have data in the database
966
+ const group = fixturesNode.createGroup();
967
+ group.addMember("everyone", "reader");
968
+ await group.core.waitForSync();
969
+
970
+ // Spy on the database client to track calls
971
+ const dbClientSpy = vi.spyOn(
972
+ (storage as any).dbClient,
973
+ "getCoValueKnownState",
974
+ );
975
+
976
+ // First call - should hit the database
977
+ const result1 = await new Promise<CoValueKnownState | undefined>(
978
+ (resolve) => {
979
+ storage.loadKnownState(group.id, resolve);
980
+ },
981
+ );
982
+
983
+ expect(result1).toBeDefined();
984
+ expect(dbClientSpy).toHaveBeenCalledTimes(1);
985
+
986
+ // Clear the spy to reset call count
987
+ dbClientSpy.mockClear();
988
+
989
+ // Second call - should use cache, not database
990
+ const result2 = await new Promise<CoValueKnownState | undefined>(
991
+ (resolve) => {
992
+ storage.loadKnownState(group.id, resolve);
993
+ },
994
+ );
995
+
996
+ expect(result2).toEqual(result1);
997
+ // Database should NOT be called since cache was hit
998
+ expect(dbClientSpy).toHaveBeenCalledTimes(0);
999
+
1000
+ // Third call - also from cache
1001
+ const result3 = await new Promise<CoValueKnownState | undefined>(
1002
+ (resolve) => {
1003
+ storage.loadKnownState(group.id, resolve);
1004
+ },
1005
+ );
1006
+
1007
+ expect(result3).toEqual(result1);
1008
+ expect(dbClientSpy).toHaveBeenCalledTimes(0);
1009
+ });
1010
+ });
826
1011
  });
@@ -578,6 +578,55 @@ describe("StorageApiSync", () => {
578
578
  const mapOnNode = await loadCoValueOrFail(node, map.id);
579
579
  expect(mapOnNode.get("test")).toEqual("value");
580
580
  });
581
+
582
+ test("should load dependencies again if they were unmounted", async () => {
583
+ const { fixturesNode, dbPath } = await createFixturesNode();
584
+ const { node, storage } = await createTestNode(dbPath);
585
+
586
+ // Create a group and a map owned by that group
587
+ const group = fixturesNode.createGroup();
588
+ group.addMember("everyone", "reader");
589
+ const map = group.createMap({ test: "value" });
590
+ await group.core.waitForSync();
591
+ await map.core.waitForSync();
592
+
593
+ const callback = vi.fn((content) =>
594
+ node.syncManager.handleNewContent(content, "storage"),
595
+ );
596
+ const done = vi.fn();
597
+
598
+ // Load the map (and its group)
599
+ await storage.load(map.id, callback, done);
600
+ callback.mockClear();
601
+ done.mockClear();
602
+
603
+ // Unmount the map and its group
604
+ storage.onCoValueUnmounted(map.id);
605
+ storage.onCoValueUnmounted(group.id);
606
+
607
+ // Load the map. The group dependency should be loaded again
608
+ await storage.load(map.id, callback, done);
609
+
610
+ expect(callback).toHaveBeenCalledTimes(2);
611
+ expect(callback).toHaveBeenNthCalledWith(
612
+ 1,
613
+ expect.objectContaining({
614
+ id: group.id,
615
+ }),
616
+ );
617
+ expect(callback).toHaveBeenNthCalledWith(
618
+ 2,
619
+ expect.objectContaining({
620
+ id: map.id,
621
+ }),
622
+ );
623
+
624
+ expect(done).toHaveBeenCalledWith(true);
625
+
626
+ node.setStorage(storage);
627
+ const mapOnNode = await loadCoValueOrFail(node, map.id);
628
+ expect(mapOnNode.get("test")).toEqual("value");
629
+ });
581
630
  });
582
631
 
583
632
  describe("waitForSync", () => {
@@ -619,4 +668,136 @@ describe("StorageApiSync", () => {
619
668
  expect(() => storage.close()).not.toThrow();
620
669
  });
621
670
  });
671
+
672
+ describe("loadKnownState", () => {
673
+ test("should return correct knownState structure for existing CoValue", async () => {
674
+ const { fixturesNode, dbPath } = await createFixturesNode();
675
+ const { storage } = await createTestNode(dbPath);
676
+
677
+ // Create a group to have data in the database
678
+ const group = fixturesNode.createGroup();
679
+ group.addMember("everyone", "reader");
680
+ await group.core.waitForSync();
681
+
682
+ const result = await new Promise<CoValueKnownState | undefined>(
683
+ (resolve) => {
684
+ storage.loadKnownState(group.id, resolve);
685
+ },
686
+ );
687
+
688
+ expect(result).toBeDefined();
689
+ expect(result?.id).toBe(group.id);
690
+ expect(result?.header).toBe(true);
691
+ expect(result?.sessions).toEqual(group.core.knownState().sessions);
692
+ });
693
+
694
+ test("should return undefined for non-existent CoValue", async () => {
695
+ const { storage } = await createTestNode();
696
+
697
+ const result = await new Promise<CoValueKnownState | undefined>(
698
+ (resolve) => {
699
+ storage.loadKnownState("co_nonexistent" as any, resolve);
700
+ },
701
+ );
702
+
703
+ expect(result).toBeUndefined();
704
+ });
705
+
706
+ test("should handle CoValue with no sessions (header only)", async () => {
707
+ const { fixturesNode, dbPath } = await createFixturesNode();
708
+ const { storage } = await createTestNode(dbPath);
709
+
710
+ // Create a CoValue with just a header (no transactions yet)
711
+ const coValue = fixturesNode.createCoValue({
712
+ type: "comap",
713
+ ruleset: { type: "unsafeAllowAll" },
714
+ meta: null,
715
+ ...crypto.createdNowUnique(),
716
+ });
717
+ await coValue.waitForSync();
718
+
719
+ const result = await new Promise<CoValueKnownState | undefined>(
720
+ (resolve) => {
721
+ storage.loadKnownState(coValue.id, resolve);
722
+ },
723
+ );
724
+
725
+ expect(result).toBeDefined();
726
+ expect(result?.id).toBe(coValue.id);
727
+ expect(result?.header).toBe(true);
728
+ // The sessions should have one entry with lastIdx = 0 (just header)
729
+ expect(Object.keys(result?.sessions || {}).length).toBe(0);
730
+ });
731
+
732
+ test("should handle CoValue with multiple sessions", async () => {
733
+ const { fixturesNode, dbPath } = await createFixturesNode();
734
+ const { fixturesNode: fixturesNode2 } = await createFixturesNode(dbPath);
735
+ const { storage } = await createTestNode(dbPath);
736
+
737
+ // Create a CoValue and have two nodes make transactions
738
+ const coValue = fixturesNode.createCoValue({
739
+ type: "comap",
740
+ ruleset: { type: "unsafeAllowAll" },
741
+ meta: null,
742
+ ...crypto.createdNowUnique(),
743
+ });
744
+
745
+ coValue.makeTransaction([{ key1: "value1" }], "trusting");
746
+ await coValue.waitForSync();
747
+
748
+ const coValueOnNode2 = await loadCoValueOrFail(
749
+ fixturesNode2,
750
+ coValue.id as CoID<RawCoMap>,
751
+ );
752
+
753
+ coValueOnNode2.set("key2", "value2", "trusting");
754
+ await coValueOnNode2.core.waitForSync();
755
+
756
+ const result = await new Promise<CoValueKnownState | undefined>(
757
+ (resolve) => {
758
+ storage.loadKnownState(coValue.id, resolve);
759
+ },
760
+ );
761
+
762
+ expect(result).toBeDefined();
763
+ expect(result?.id).toBe(coValue.id);
764
+ expect(result?.header).toBe(true);
765
+ // Should have two sessions
766
+ expect(Object.keys(result?.sessions || {}).length).toBe(2);
767
+ // Verify sessions match the expected state
768
+ expect(result?.sessions).toEqual(
769
+ coValueOnNode2.core.knownState().sessions,
770
+ );
771
+ });
772
+
773
+ test("should use cache when knownState is cached", async () => {
774
+ const { fixturesNode, dbPath } = await createFixturesNode();
775
+ const { storage } = await createTestNode(dbPath);
776
+
777
+ // Create a group to have data in the database
778
+ const group = fixturesNode.createGroup();
779
+ group.addMember("everyone", "reader");
780
+ await group.core.waitForSync();
781
+
782
+ // First call should hit the database and cache the result
783
+ const result1 = await new Promise<CoValueKnownState | undefined>(
784
+ (resolve) => {
785
+ storage.loadKnownState(group.id, resolve);
786
+ },
787
+ );
788
+
789
+ expect(result1).toBeDefined();
790
+ expect(result1?.id).toBe(group.id);
791
+ expect(result1?.header).toBe(true);
792
+
793
+ // Second call should return from cache
794
+ const result2 = await new Promise<CoValueKnownState | undefined>(
795
+ (resolve) => {
796
+ storage.loadKnownState(group.id, resolve);
797
+ },
798
+ );
799
+
800
+ expect(result2).toEqual(result1);
801
+ });
802
+ });
622
803
  });
@@ -70,7 +70,7 @@ describe("SyncManager.processQueues", () => {
70
70
  await loadCoValueOrFail(client.node, map.id);
71
71
 
72
72
  // Restart and load from storage
73
- client.restart();
73
+ await client.restart();
74
74
  client.connectToSyncServer();
75
75
  client.addStorage({ storage });
76
76
 
@@ -46,7 +46,7 @@ describe("SyncStateManager", () => {
46
46
  const newPeerState = client.node.syncManager.peers[peerState.id]!;
47
47
 
48
48
  expect(updateSpy).toHaveBeenCalledWith(
49
- peerState.id,
49
+ expect.objectContaining({ id: peerState.id }),
50
50
  newPeerState.getKnownState(map.core.id)!,
51
51
  { uploaded: true },
52
52
  );
@@ -355,7 +355,7 @@ test("chunks transactions when when the chars are longer than MAX_RECOMMENDED_TX
355
355
 
356
356
  await coValue.waitForSync();
357
357
 
358
- client.restart();
358
+ await client.restart();
359
359
  client.addStorage({
360
360
  storage,
361
361
  });
@@ -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 }>,
@@ -44,6 +45,7 @@ function createMockStorage(
44
45
  callback: (unsyncedCoValueIDs: RawCoID[]) => void,
45
46
  ) => void;
46
47
  stopTrackingSyncState?: (id: RawCoID) => void;
48
+ onCoValueUnmounted?: (id: RawCoID) => void;
47
49
  close?: () => Promise<unknown> | undefined;
48
50
  } = {},
49
51
  ): StorageAPI {
@@ -51,10 +53,13 @@ function createMockStorage(
51
53
  load: opts.load || vi.fn(),
52
54
  store: opts.store || vi.fn(),
53
55
  getKnownState: opts.getKnownState || vi.fn(),
56
+ loadKnownState:
57
+ opts.loadKnownState || vi.fn((id, callback) => callback(undefined)),
54
58
  waitForSync: opts.waitForSync || vi.fn().mockResolvedValue(undefined),
55
59
  trackCoValuesSyncState: opts.trackCoValuesSyncState || vi.fn(),
56
60
  getUnsyncedCoValueIDs: opts.getUnsyncedCoValueIDs || vi.fn(),
57
61
  stopTrackingSyncState: opts.stopTrackingSyncState || vi.fn(),
62
+ onCoValueUnmounted: opts.onCoValueUnmounted || vi.fn(),
58
63
  close: opts.close || vi.fn().mockResolvedValue(undefined),
59
64
  };
60
65
  }