cojson 0.19.21 → 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 (124) 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 +19 -1
  28. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  29. package/dist/coValueCore/coValueCore.js +29 -5
  30. package/dist/coValueCore/coValueCore.js.map +1 -1
  31. package/dist/exports.d.ts +1 -0
  32. package/dist/exports.d.ts.map +1 -1
  33. package/dist/exports.js +1 -0
  34. package/dist/exports.js.map +1 -1
  35. package/dist/localNode.d.ts +1 -3
  36. package/dist/localNode.d.ts.map +1 -1
  37. package/dist/localNode.js +3 -2
  38. package/dist/localNode.js.map +1 -1
  39. package/dist/storage/storageAsync.d.ts +8 -3
  40. package/dist/storage/storageAsync.d.ts.map +1 -1
  41. package/dist/storage/storageAsync.js +12 -3
  42. package/dist/storage/storageAsync.js.map +1 -1
  43. package/dist/storage/storageSync.d.ts +8 -3
  44. package/dist/storage/storageSync.d.ts.map +1 -1
  45. package/dist/storage/storageSync.js +12 -3
  46. package/dist/storage/storageSync.js.map +1 -1
  47. package/dist/storage/types.d.ts +5 -0
  48. package/dist/storage/types.d.ts.map +1 -1
  49. package/dist/sync.d.ts +6 -0
  50. package/dist/sync.d.ts.map +1 -1
  51. package/dist/sync.js +25 -4
  52. package/dist/sync.js.map +1 -1
  53. package/dist/tests/CojsonMessageChannel.test.d.ts +2 -0
  54. package/dist/tests/CojsonMessageChannel.test.d.ts.map +1 -0
  55. package/dist/tests/CojsonMessageChannel.test.js +236 -0
  56. package/dist/tests/CojsonMessageChannel.test.js.map +1 -0
  57. package/dist/tests/GarbageCollector.test.js +87 -13
  58. package/dist/tests/GarbageCollector.test.js.map +1 -1
  59. package/dist/tests/StorageApiAsync.test.js +33 -1
  60. package/dist/tests/StorageApiAsync.test.js.map +1 -1
  61. package/dist/tests/StorageApiSync.test.js +32 -0
  62. package/dist/tests/StorageApiSync.test.js.map +1 -1
  63. package/dist/tests/SyncManager.processQueues.test.js +1 -1
  64. package/dist/tests/SyncManager.processQueues.test.js.map +1 -1
  65. package/dist/tests/SyncStateManager.test.js +1 -1
  66. package/dist/tests/SyncStateManager.test.js.map +1 -1
  67. package/dist/tests/coPlainText.test.js +1 -1
  68. package/dist/tests/coPlainText.test.js.map +1 -1
  69. package/dist/tests/coValueCore.loadFromStorage.test.js +1 -0
  70. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  71. package/dist/tests/knownState.lazyLoading.test.js +1 -0
  72. package/dist/tests/knownState.lazyLoading.test.js.map +1 -1
  73. package/dist/tests/sync.garbageCollection.test.js +56 -32
  74. package/dist/tests/sync.garbageCollection.test.js.map +1 -1
  75. package/dist/tests/sync.load.test.js +3 -5
  76. package/dist/tests/sync.load.test.js.map +1 -1
  77. package/dist/tests/sync.mesh.test.js +1 -1
  78. package/dist/tests/sync.mesh.test.js.map +1 -1
  79. package/dist/tests/sync.peerReconciliation.test.js +3 -3
  80. package/dist/tests/sync.peerReconciliation.test.js.map +1 -1
  81. package/dist/tests/sync.storage.test.js +9 -9
  82. package/dist/tests/sync.storage.test.js.map +1 -1
  83. package/dist/tests/sync.storageAsync.test.js +7 -7
  84. package/dist/tests/sync.storageAsync.test.js.map +1 -1
  85. package/dist/tests/sync.tracking.test.js +35 -4
  86. package/dist/tests/sync.tracking.test.js.map +1 -1
  87. package/dist/tests/testStorage.js +2 -2
  88. package/dist/tests/testStorage.js.map +1 -1
  89. package/dist/tests/testUtils.d.ts +24 -2
  90. package/dist/tests/testUtils.d.ts.map +1 -1
  91. package/dist/tests/testUtils.js +68 -7
  92. package/dist/tests/testUtils.js.map +1 -1
  93. package/package.json +4 -4
  94. package/src/CojsonMessageChannel/CojsonMessageChannel.ts +332 -0
  95. package/src/CojsonMessageChannel/MessagePortOutgoingChannel.ts +52 -0
  96. package/src/CojsonMessageChannel/index.ts +9 -0
  97. package/src/CojsonMessageChannel/types.ts +200 -0
  98. package/src/GarbageCollector.ts +5 -5
  99. package/src/SyncStateManager.ts +6 -6
  100. package/src/coValueCore/coValueCore.ts +30 -7
  101. package/src/exports.ts +1 -0
  102. package/src/localNode.ts +3 -5
  103. package/src/storage/storageAsync.ts +15 -4
  104. package/src/storage/storageSync.ts +15 -4
  105. package/src/storage/types.ts +6 -0
  106. package/src/sync.ts +33 -4
  107. package/src/tests/CojsonMessageChannel.test.ts +306 -0
  108. package/src/tests/GarbageCollector.test.ts +114 -13
  109. package/src/tests/StorageApiAsync.test.ts +50 -1
  110. package/src/tests/StorageApiSync.test.ts +49 -0
  111. package/src/tests/SyncManager.processQueues.test.ts +1 -1
  112. package/src/tests/SyncStateManager.test.ts +1 -1
  113. package/src/tests/coPlainText.test.ts +1 -1
  114. package/src/tests/coValueCore.loadFromStorage.test.ts +2 -0
  115. package/src/tests/knownState.lazyLoading.test.ts +2 -0
  116. package/src/tests/sync.garbageCollection.test.ts +69 -36
  117. package/src/tests/sync.load.test.ts +3 -5
  118. package/src/tests/sync.mesh.test.ts +1 -1
  119. package/src/tests/sync.peerReconciliation.test.ts +3 -3
  120. package/src/tests/sync.storage.test.ts +9 -9
  121. package/src/tests/sync.storageAsync.test.ts +7 -7
  122. package/src/tests/sync.tracking.test.ts +54 -4
  123. package/src/tests/testStorage.ts +2 -2
  124. package/src/tests/testUtils.ts +85 -6
@@ -0,0 +1,306 @@
1
+ import { describe, test, expect, beforeEach, assert } from "vitest";
2
+ import { CojsonMessageChannel } from "../CojsonMessageChannel";
3
+ import type { Peer } from "../sync.js";
4
+ import type { RawCoMap } from "../coValues/coMap.js";
5
+ import {
6
+ setupTestNode,
7
+ SyncMessagesLog,
8
+ waitFor,
9
+ createTrackedMessageChannel,
10
+ createMockWorkerWithAccept,
11
+ loadCoValueOrFail,
12
+ } from "./testUtils";
13
+
14
+ describe("CojsonMessageChannel", () => {
15
+ beforeEach(() => {
16
+ SyncMessagesLog.clear();
17
+ });
18
+
19
+ test("should sync data between two contexts via MessageChannel", async () => {
20
+ // Create two nodes using setupTestNode (handles cleanup automatically)
21
+ const { node: node1 } = setupTestNode();
22
+ const { node: node2 } = setupTestNode();
23
+
24
+ const mockWorker = createMockWorkerWithAccept(async (port) => {
25
+ // This runs in the "worker" context
26
+ const peer = await CojsonMessageChannel.acceptFromPort(port, {
27
+ role: "server",
28
+ });
29
+ node2.syncManager.addPeer(peer);
30
+ });
31
+
32
+ // Host side: expose to the mock worker
33
+ const peer1 = await CojsonMessageChannel.expose(mockWorker, {
34
+ role: "client",
35
+ messageChannel: createTrackedMessageChannel({
36
+ port1Name: "client",
37
+ port2Name: "server",
38
+ }),
39
+ });
40
+ node1.syncManager.addPeer(peer1);
41
+
42
+ // Create data on node1
43
+ const group = node1.createGroup();
44
+ group.addMember("everyone", "writer");
45
+ const map = group.createMap();
46
+ map.set("key", "value", "trusting");
47
+
48
+ // Verify data synced
49
+ const mapOnNode2 = await loadCoValueOrFail<RawCoMap>(node2, map.id);
50
+ expect(mapOnNode2.get("key")).toBe("value");
51
+
52
+ expect(
53
+ SyncMessagesLog.getMessages({
54
+ Map: map.core,
55
+ Group: group.core,
56
+ }),
57
+ ).toMatchInlineSnapshot(`
58
+ [
59
+ "server -> client | LOAD Map sessions: empty",
60
+ "client -> server | CONTENT Group header: true new: After: 0 New: 5",
61
+ "client -> server | CONTENT Map header: true new: After: 0 New: 1",
62
+ "server -> client | KNOWN Group sessions: header/5",
63
+ "server -> client | KNOWN Map sessions: header/1",
64
+ ]
65
+ `);
66
+ });
67
+
68
+ test("should handle disconnection correctly", async () => {
69
+ const { node: node1 } = setupTestNode();
70
+ const { node: node2 } = setupTestNode();
71
+
72
+ const peerId = "disconnect-test-peer";
73
+ let peer2: Peer | null = null;
74
+
75
+ const mockWorker = createMockWorkerWithAccept(async (port) => {
76
+ peer2 = await CojsonMessageChannel.acceptFromPort(port, {
77
+ id: peerId,
78
+ role: "server",
79
+ });
80
+ node2.syncManager.addPeer(peer2);
81
+ });
82
+
83
+ const peer1 = await CojsonMessageChannel.expose(mockWorker, {
84
+ id: peerId,
85
+ role: "client",
86
+ });
87
+ node1.syncManager.addPeer(peer1);
88
+
89
+ // Verify peers are connected (same ID on both sides)
90
+ expect(node1.syncManager.peers["disconnect-test-peer"]).toBeDefined();
91
+ expect(node2.syncManager.peers["disconnect-test-peer"]).toBeDefined();
92
+
93
+ peer1.outgoing.close();
94
+
95
+ expect(node1.syncManager.peers["disconnect-test-peer"]).toBeUndefined();
96
+
97
+ await waitFor(() => {
98
+ expect(node2.syncManager.peers["disconnect-test-peer"]).toBeUndefined();
99
+ });
100
+ });
101
+
102
+ test("should ignore mismatched peer IDs in waitForConnection() when id filter is provided", async () => {
103
+ const { node } = setupTestNode();
104
+
105
+ const hostPeerId = "host-peer-id";
106
+ const wrongPeerId = "wrong-peer-id";
107
+
108
+ let acceptPromiseResolved = false;
109
+
110
+ // Mock worker that expects a different ID
111
+ const mockWorker = createMockWorkerWithAccept(async (port) => {
112
+ // This should not resolve because the ID doesn't match
113
+ const acceptPromise = CojsonMessageChannel.acceptFromPort(port, {
114
+ id: wrongPeerId, // Expecting a different ID
115
+ role: "server",
116
+ });
117
+
118
+ // Set a timeout to detect if it's waiting
119
+ const timeoutPromise = new Promise<null>((resolve) =>
120
+ setTimeout(() => resolve(null), 100),
121
+ );
122
+
123
+ const result = await Promise.race([acceptPromise, timeoutPromise]);
124
+ if (result !== null) {
125
+ acceptPromiseResolved = true;
126
+ node.syncManager.addPeer(result);
127
+ }
128
+ });
129
+
130
+ // Expose with a different ID than what accept expects
131
+ CojsonMessageChannel.expose(mockWorker, {
132
+ id: hostPeerId,
133
+ role: "client",
134
+ });
135
+
136
+ // Wait a bit to ensure the accept didn't resolve
137
+ await new Promise((resolve) => setTimeout(resolve, 150));
138
+
139
+ // The accept should not have resolved because IDs don't match
140
+ expect(acceptPromiseResolved).toBe(false);
141
+ });
142
+
143
+ test("should sync data bidirectionally", async () => {
144
+ const { node: node1 } = setupTestNode();
145
+ const { node: node2 } = setupTestNode();
146
+
147
+ const mockWorker = createMockWorkerWithAccept(async (port) => {
148
+ const peer = await CojsonMessageChannel.acceptFromPort(port, {
149
+ role: "server",
150
+ });
151
+ node2.syncManager.addPeer(peer);
152
+ });
153
+
154
+ const peer1 = await CojsonMessageChannel.expose(mockWorker, {
155
+ role: "client",
156
+ });
157
+ node1.syncManager.addPeer(peer1);
158
+
159
+ // Create data on node1
160
+ const group1 = node1.createGroup();
161
+ group1.addMember("everyone", "writer");
162
+ const map1 = group1.createMap();
163
+ map1.set("from", "node1", "trusting");
164
+
165
+ // Create data on node2
166
+ const group2 = node2.createGroup();
167
+ group2.addMember("everyone", "writer");
168
+ const map2 = group2.createMap();
169
+ map2.set("from", "node2", "trusting");
170
+
171
+ // Verify data synced in both directions
172
+ const map1OnNode2 = await loadCoValueOrFail<RawCoMap>(node2, map1.id);
173
+ expect(map1OnNode2.get("from")).toBe("node1");
174
+
175
+ const map2OnNode1 = await loadCoValueOrFail<RawCoMap>(node1, map2.id);
176
+ expect(map2OnNode1.get("from")).toBe("node2");
177
+ });
178
+
179
+ test("should invoke onClose callback when connection closes", async () => {
180
+ const { node: node1 } = setupTestNode();
181
+ const { node: node2 } = setupTestNode();
182
+
183
+ let onCloseCalledOnHost = false;
184
+ let onCloseCalledOnWorker = false;
185
+
186
+ const mockWorker = createMockWorkerWithAccept(async (port) => {
187
+ const peer = await CojsonMessageChannel.acceptFromPort(port, {
188
+ role: "server",
189
+ onClose: () => {
190
+ onCloseCalledOnWorker = true;
191
+ },
192
+ });
193
+ node2.syncManager.addPeer(peer);
194
+ });
195
+
196
+ const peer1 = await CojsonMessageChannel.expose(mockWorker, {
197
+ role: "client",
198
+ onClose: () => {
199
+ onCloseCalledOnHost = true;
200
+ },
201
+ });
202
+ node1.syncManager.addPeer(peer1);
203
+
204
+ // Close the connection
205
+ peer1.outgoing.close();
206
+
207
+ // Wait for close to propagate
208
+ await waitFor(() => {
209
+ expect(onCloseCalledOnHost).toBe(true);
210
+ });
211
+
212
+ await waitFor(() => {
213
+ expect(onCloseCalledOnWorker).toBe(true);
214
+ });
215
+ });
216
+
217
+ test("should apply role configuration correctly", async () => {
218
+ const { node: node1 } = setupTestNode();
219
+ const { node: node2 } = setupTestNode();
220
+
221
+ let peer2: Peer | null = null;
222
+
223
+ const mockWorker = createMockWorkerWithAccept(async (port) => {
224
+ peer2 = await CojsonMessageChannel.acceptFromPort(port, {
225
+ role: "server",
226
+ });
227
+ node2.syncManager.addPeer(peer2);
228
+ });
229
+
230
+ const peer1 = await CojsonMessageChannel.expose(mockWorker, {
231
+ role: "client",
232
+ });
233
+ node1.syncManager.addPeer(peer1);
234
+
235
+ // Verify roles are correctly set
236
+ expect(peer1.role).toBe("client");
237
+ expect(peer2).not.toBeNull();
238
+ expect(peer2!.role).toBe("server");
239
+ });
240
+
241
+ test("should generate and use the same peer ID on both sides when not provided", async () => {
242
+ const { node: node1 } = setupTestNode();
243
+ const { node: node2 } = setupTestNode();
244
+
245
+ let peer2: Peer | null = null;
246
+
247
+ const mockWorker = createMockWorkerWithAccept(async (port) => {
248
+ peer2 = await CojsonMessageChannel.acceptFromPort(port, {
249
+ role: "server",
250
+ });
251
+ node2.syncManager.addPeer(peer2);
252
+ });
253
+
254
+ // Don't provide an id - it should be auto-generated
255
+ const peer1 = await CojsonMessageChannel.expose(mockWorker, {
256
+ role: "client",
257
+ });
258
+ node1.syncManager.addPeer(peer1);
259
+
260
+ // Verify peer1 has an auto-generated ID
261
+ expect(peer1.id).toMatch(/^channel_/);
262
+
263
+ // Verify both peers have the same ID
264
+ expect(peer2).not.toBeNull();
265
+ expect(peer2!.id).toBe(peer1.id);
266
+
267
+ // Verify the peer is accessible in both sync managers with the same ID
268
+ expect(node1.syncManager.peers[peer1.id]).toBeDefined();
269
+ expect(node2.syncManager.peers[peer1.id]).toBeDefined();
270
+ });
271
+
272
+ test("should handle delayed addPeer on accept side", async () => {
273
+ const { node: node1 } = setupTestNode();
274
+ const { node: node2 } = setupTestNode();
275
+
276
+ let peer2: Peer | null = null;
277
+
278
+ const delay = new Promise((resolve) => setTimeout(resolve, 50));
279
+
280
+ const mockWorker = createMockWorkerWithAccept(async (port) => {
281
+ peer2 = await CojsonMessageChannel.acceptFromPort(port, {
282
+ role: "server",
283
+ });
284
+ // Deliberately delay adding the peer
285
+ await delay;
286
+ node2.syncManager.addPeer(peer2);
287
+ });
288
+
289
+ const peer1 = await CojsonMessageChannel.expose(mockWorker, {
290
+ role: "client",
291
+ });
292
+ node1.syncManager.addPeer(peer1);
293
+
294
+ // Create data on node1 immediately (before node2 has added the peer)
295
+ const group = node1.createGroup();
296
+ group.addMember("everyone", "writer");
297
+ const map = group.createMap();
298
+ map.set("key", "value", "trusting");
299
+
300
+ await delay;
301
+
302
+ // Verify data synced despite the delay
303
+ const mapOnNode2 = await loadCoValueOrFail<RawCoMap>(node2, map.id);
304
+ expect(mapOnNode2.get("key")).toBe("value");
305
+ });
306
+ });
@@ -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", () => {
@@ -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", () => {
@@ -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
  });
@@ -45,6 +45,7 @@ function createMockStorage(
45
45
  callback: (unsyncedCoValueIDs: RawCoID[]) => void,
46
46
  ) => void;
47
47
  stopTrackingSyncState?: (id: RawCoID) => void;
48
+ onCoValueUnmounted?: (id: RawCoID) => void;
48
49
  close?: () => Promise<unknown> | undefined;
49
50
  } = {},
50
51
  ): StorageAPI {
@@ -58,6 +59,7 @@ function createMockStorage(
58
59
  trackCoValuesSyncState: opts.trackCoValuesSyncState || vi.fn(),
59
60
  getUnsyncedCoValueIDs: opts.getUnsyncedCoValueIDs || vi.fn(),
60
61
  stopTrackingSyncState: opts.stopTrackingSyncState || vi.fn(),
62
+ onCoValueUnmounted: opts.onCoValueUnmounted || vi.fn(),
61
63
  close: opts.close || vi.fn().mockResolvedValue(undefined),
62
64
  };
63
65
  }
@@ -23,6 +23,7 @@ function createMockStorage(
23
23
  callback: (unsyncedCoValueIDs: RawCoID[]) => void,
24
24
  ) => void;
25
25
  stopTrackingSyncState?: (id: RawCoID) => void;
26
+ onCoValueUnmounted?: (id: RawCoID) => void;
26
27
  close?: () => Promise<unknown> | undefined;
27
28
  } = {},
28
29
  ): StorageAPI {
@@ -36,6 +37,7 @@ function createMockStorage(
36
37
  trackCoValuesSyncState: opts.trackCoValuesSyncState || vi.fn(),
37
38
  getUnsyncedCoValueIDs: opts.getUnsyncedCoValueIDs || vi.fn(),
38
39
  stopTrackingSyncState: opts.stopTrackingSyncState || vi.fn(),
40
+ onCoValueUnmounted: opts.onCoValueUnmounted || vi.fn(),
39
41
  close: opts.close || vi.fn().mockResolvedValue(undefined),
40
42
  };
41
43
  }