cojson 0.19.18 → 0.19.19

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 (76) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +9 -0
  3. package/dist/PeerState.d.ts.map +1 -1
  4. package/dist/SyncStateManager.d.ts +5 -2
  5. package/dist/SyncStateManager.d.ts.map +1 -1
  6. package/dist/SyncStateManager.js +49 -12
  7. package/dist/SyncStateManager.js.map +1 -1
  8. package/dist/UnsyncedCoValuesTracker.d.ts +81 -0
  9. package/dist/UnsyncedCoValuesTracker.d.ts.map +1 -0
  10. package/dist/UnsyncedCoValuesTracker.js +209 -0
  11. package/dist/UnsyncedCoValuesTracker.js.map +1 -0
  12. package/dist/exports.d.ts +4 -2
  13. package/dist/exports.d.ts.map +1 -1
  14. package/dist/exports.js +2 -0
  15. package/dist/exports.js.map +1 -1
  16. package/dist/localNode.d.ts +9 -5
  17. package/dist/localNode.d.ts.map +1 -1
  18. package/dist/localNode.js +12 -8
  19. package/dist/localNode.js.map +1 -1
  20. package/dist/storage/knownState.d.ts +1 -1
  21. package/dist/storage/knownState.js +4 -4
  22. package/dist/storage/sqlite/client.d.ts +8 -0
  23. package/dist/storage/sqlite/client.d.ts.map +1 -1
  24. package/dist/storage/sqlite/client.js +17 -0
  25. package/dist/storage/sqlite/client.js.map +1 -1
  26. package/dist/storage/sqlite/sqliteMigrations.d.ts.map +1 -1
  27. package/dist/storage/sqlite/sqliteMigrations.js +9 -0
  28. package/dist/storage/sqlite/sqliteMigrations.js.map +1 -1
  29. package/dist/storage/sqliteAsync/client.d.ts +8 -0
  30. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  31. package/dist/storage/sqliteAsync/client.js +19 -0
  32. package/dist/storage/sqliteAsync/client.js.map +1 -1
  33. package/dist/storage/storageAsync.d.ts +9 -2
  34. package/dist/storage/storageAsync.d.ts.map +1 -1
  35. package/dist/storage/storageAsync.js +9 -0
  36. package/dist/storage/storageAsync.js.map +1 -1
  37. package/dist/storage/storageSync.d.ts +9 -2
  38. package/dist/storage/storageSync.d.ts.map +1 -1
  39. package/dist/storage/storageSync.js +11 -0
  40. package/dist/storage/storageSync.js.map +1 -1
  41. package/dist/storage/types.d.ts +33 -0
  42. package/dist/storage/types.d.ts.map +1 -1
  43. package/dist/sync.d.ts +21 -1
  44. package/dist/sync.d.ts.map +1 -1
  45. package/dist/sync.js +107 -2
  46. package/dist/sync.js.map +1 -1
  47. package/dist/tests/SyncStateManager.test.js +3 -3
  48. package/dist/tests/SyncStateManager.test.js.map +1 -1
  49. package/dist/tests/coValueCore.loadFromStorage.test.js +3 -0
  50. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  51. package/dist/tests/sync.tracking.test.d.ts +2 -0
  52. package/dist/tests/sync.tracking.test.d.ts.map +1 -0
  53. package/dist/tests/sync.tracking.test.js +261 -0
  54. package/dist/tests/sync.tracking.test.js.map +1 -0
  55. package/dist/tests/testUtils.d.ts +2 -1
  56. package/dist/tests/testUtils.d.ts.map +1 -1
  57. package/dist/tests/testUtils.js +2 -2
  58. package/dist/tests/testUtils.js.map +1 -1
  59. package/package.json +4 -4
  60. package/src/PeerState.ts +2 -2
  61. package/src/SyncStateManager.ts +63 -12
  62. package/src/UnsyncedCoValuesTracker.ts +272 -0
  63. package/src/exports.ts +4 -1
  64. package/src/localNode.ts +15 -3
  65. package/src/storage/knownState.ts +4 -4
  66. package/src/storage/sqlite/client.ts +31 -0
  67. package/src/storage/sqlite/sqliteMigrations.ts +9 -0
  68. package/src/storage/sqliteAsync/client.ts +35 -0
  69. package/src/storage/storageAsync.ts +18 -1
  70. package/src/storage/storageSync.ts +20 -1
  71. package/src/storage/types.ts +39 -0
  72. package/src/sync.ts +151 -3
  73. package/src/tests/SyncStateManager.test.ts +3 -0
  74. package/src/tests/coValueCore.loadFromStorage.test.ts +11 -0
  75. package/src/tests/sync.tracking.test.ts +396 -0
  76. package/src/tests/testUtils.ts +9 -3
package/src/sync.ts CHANGED
@@ -2,6 +2,7 @@ import { md5 } from "@noble/hashes/legacy";
2
2
  import { Histogram, ValueType, metrics } from "@opentelemetry/api";
3
3
  import { PeerState } from "./PeerState.js";
4
4
  import { SyncStateManager } from "./SyncStateManager.js";
5
+ import { UnsyncedCoValuesTracker } from "./UnsyncedCoValuesTracker.js";
5
6
  import {
6
7
  getContenDebugInfo,
7
8
  getNewTransactionsFromContentMessage,
@@ -23,6 +24,7 @@ import {
23
24
  knownStateFrom,
24
25
  KnownStateSessions,
25
26
  } from "./knownState.js";
27
+ import { StorageAPI } from "./storage/index.js";
26
28
 
27
29
  export type SyncMessage =
28
30
  | LoadMessage
@@ -63,6 +65,15 @@ export type DoneMessage = {
63
65
  id: RawCoID;
64
66
  };
65
67
 
68
+ /**
69
+ * Determines when network sync is enabled.
70
+ * - "always": sync is enabled for both Anonymous Authentication and Authenticated Account
71
+ * - "signedUp": sync is enabled when the user is authenticated
72
+ * - "never": sync is disabled, content stays local
73
+ * Can be dynamically modified to control sync behavior at runtime.
74
+ */
75
+ export type SyncWhen = "always" | "signedUp" | "never";
76
+
66
77
  export type PeerID = string;
67
78
 
68
79
  export type DisconnectedError = "Disconnected";
@@ -121,6 +132,7 @@ export class SyncManager {
121
132
  constructor(local: LocalNode) {
122
133
  this.local = local;
123
134
  this.syncState = new SyncStateManager(this);
135
+ this.unsyncedTracker = new UnsyncedCoValuesTracker();
124
136
 
125
137
  this.transactionsSizeHistogram = metrics
126
138
  .getMeter("cojson")
@@ -132,6 +144,7 @@ export class SyncManager {
132
144
  }
133
145
 
134
146
  syncState: SyncStateManager;
147
+ unsyncedTracker: UnsyncedCoValuesTracker;
135
148
 
136
149
  disableTransactionVerification() {
137
150
  this.skipVerify = true;
@@ -154,6 +167,10 @@ export class SyncManager {
154
167
  : serverPeers;
155
168
  }
156
169
 
170
+ getPersistentServerPeers(id: RawCoID): PeerState[] {
171
+ return this.getServerPeers(id).filter((peer) => peer.persistent);
172
+ }
173
+
157
174
  handleSyncMessage(msg: SyncMessage, peer: PeerState) {
158
175
  if (!isRawCoID(msg.id)) {
159
176
  const errorType = msg.id ? "invalid" : "undefined";
@@ -259,7 +276,88 @@ export class SyncManager {
259
276
  }
260
277
  }
261
278
 
279
+ async resumeUnsyncedCoValues(): Promise<void> {
280
+ if (!this.local.storage) {
281
+ // No storage available, skip resumption
282
+ return;
283
+ }
284
+
285
+ await new Promise<void>((resolve, reject) => {
286
+ // Load all persisted unsynced CoValues from storage
287
+ this.local.storage?.getUnsyncedCoValueIDs((unsyncedCoValueIDs) => {
288
+ const coValuesToLoad = unsyncedCoValueIDs.filter(
289
+ (coValueId) => !this.local.hasCoValue(coValueId),
290
+ );
291
+ if (coValuesToLoad.length === 0) {
292
+ resolve();
293
+ return;
294
+ }
295
+
296
+ const BATCH_SIZE = 10;
297
+ let processed = 0;
298
+
299
+ const processBatch = async () => {
300
+ const batch = coValuesToLoad.slice(processed, processed + BATCH_SIZE);
301
+
302
+ await Promise.all(
303
+ batch.map(
304
+ async (coValueId) =>
305
+ new Promise<void>((resolve) => {
306
+ try {
307
+ // Clear previous tracking (as it may include outdated peers)
308
+ this.local.storage?.stopTrackingSyncState(coValueId);
309
+
310
+ // Resume tracking sync state for this CoValue
311
+ // This will add it back to the tracker and set up subscriptions
312
+ this.trackSyncState(coValueId);
313
+
314
+ // Load the CoValue from storage (this will trigger sync if peers are connected)
315
+ const coValue = this.local.getCoValue(coValueId);
316
+ coValue.loadFromStorage((found) => {
317
+ if (!found) {
318
+ // CoValue could not be loaded from storage, stop tracking
319
+ this.unsyncedTracker.removeAll(coValueId);
320
+ }
321
+ resolve();
322
+ });
323
+ } catch (error) {
324
+ // Handle errors gracefully - log but don't fail the entire resumption
325
+ logger.warn(
326
+ `Failed to resume sync for CoValue ${coValueId}:`,
327
+ {
328
+ err: error,
329
+ coValueId,
330
+ },
331
+ );
332
+ this.unsyncedTracker.removeAll(coValueId);
333
+ resolve();
334
+ }
335
+ }),
336
+ ),
337
+ );
338
+
339
+ processed += batch.length;
340
+
341
+ if (processed < coValuesToLoad.length) {
342
+ processBatch().catch(reject);
343
+ } else {
344
+ resolve();
345
+ }
346
+ };
347
+
348
+ processBatch().catch(reject);
349
+ });
350
+ });
351
+ }
352
+
262
353
  startPeerReconciliation(peer: PeerState) {
354
+ if (peer.role === "server" && peer.persistent) {
355
+ // Resume syncing unsynced CoValues asynchronously
356
+ this.resumeUnsyncedCoValues().catch((error) => {
357
+ logger.warn("Failed to resume unsynced CoValues:", error);
358
+ });
359
+ }
360
+
263
361
  const coValuesOrderedByDependency: CoValueCore[] = [];
264
362
 
265
363
  const seen = new Set<string>();
@@ -732,6 +830,9 @@ export class SyncManager {
732
830
 
733
831
  if (from !== "storage" && hasNewContent) {
734
832
  this.storeContent(validNewContent);
833
+ if (from === "import") {
834
+ this.trackSyncState(coValue.id);
835
+ }
735
836
  }
736
837
 
737
838
  for (const peer of this.getPeers(coValue.id)) {
@@ -787,6 +888,8 @@ export class SyncManager {
787
888
 
788
889
  this.storeContent(content);
789
890
 
891
+ this.trackSyncState(coValue.id);
892
+
790
893
  const contentKnownState = knownStateFromContent(content);
791
894
 
792
895
  for (const peer of this.getPeers(coValue.id)) {
@@ -811,6 +914,37 @@ export class SyncManager {
811
914
  }
812
915
  }
813
916
 
917
+ private trackSyncState(coValueId: RawCoID): void {
918
+ const peers = this.getPersistentServerPeers(coValueId);
919
+
920
+ const isSyncRequired = this.local.syncWhen !== "never";
921
+ if (isSyncRequired && peers.length === 0) {
922
+ this.unsyncedTracker.add(coValueId);
923
+ return;
924
+ }
925
+
926
+ for (const peer of peers) {
927
+ if (this.syncState.isSynced(peer, coValueId)) {
928
+ continue;
929
+ }
930
+ const alreadyTracked = this.unsyncedTracker.add(coValueId, peer.id);
931
+ if (alreadyTracked) {
932
+ continue;
933
+ }
934
+
935
+ const unsubscribe = this.syncState.subscribeToPeerUpdates(
936
+ peer.id,
937
+ coValueId,
938
+ (_knownState, syncState) => {
939
+ if (syncState.uploaded) {
940
+ this.unsyncedTracker.remove(coValueId, peer.id);
941
+ unsubscribe();
942
+ }
943
+ },
944
+ );
945
+ }
946
+ }
947
+
814
948
  private storeContent(content: NewContentMessage) {
815
949
  const storage = this.local.storage;
816
950
 
@@ -860,8 +994,9 @@ export class SyncManager {
860
994
  return new Promise((resolve, reject) => {
861
995
  const unsubscribe = this.syncState.subscribeToPeerUpdates(
862
996
  peerId,
863
- (knownState, syncState) => {
864
- if (syncState.uploaded && knownState.id === id) {
997
+ id,
998
+ (_knownState, syncState) => {
999
+ if (syncState.uploaded) {
865
1000
  resolve(true);
866
1001
  unsubscribe?.();
867
1002
  clearTimeout(timeoutId);
@@ -916,10 +1051,23 @@ export class SyncManager {
916
1051
  );
917
1052
  }
918
1053
 
919
- gracefulShutdown() {
1054
+ setStorage(storage: StorageAPI) {
1055
+ this.unsyncedTracker.setStorage(storage);
1056
+ }
1057
+
1058
+ removeStorage() {
1059
+ this.unsyncedTracker.removeStorage();
1060
+ }
1061
+
1062
+ /**
1063
+ * Closes all the peer connections and ensures the list of unsynced coValues is persisted to storage.
1064
+ * @returns Promise of the current pending store operation, if any.
1065
+ */
1066
+ gracefulShutdown(): Promise<void> | undefined {
920
1067
  for (const peer of Object.values(this.peers)) {
921
1068
  peer.gracefulShutdown();
922
1069
  }
1070
+ return this.unsyncedTracker.forcePersist();
923
1071
  }
924
1072
  }
925
1073
 
@@ -78,10 +78,12 @@ describe("SyncStateManager", () => {
78
78
  const updateToStorageSpy: PeerSyncStateListenerCallback = vi.fn();
79
79
  const unsubscribe1 = subscriptionManager.subscribeToPeerUpdates(
80
80
  peerState.id,
81
+ map.core.id,
81
82
  updateToJazzCloudSpy,
82
83
  );
83
84
  const unsubscribe2 = subscriptionManager.subscribeToPeerUpdates(
84
85
  serverPeer.id,
86
+ group.core.id,
85
87
  updateToStorageSpy,
86
88
  );
87
89
 
@@ -141,6 +143,7 @@ describe("SyncStateManager", () => {
141
143
  const unsubscribe1 = subscriptionManager.subscribeToUpdates(anyUpdateSpy);
142
144
  const unsubscribe2 = subscriptionManager.subscribeToPeerUpdates(
143
145
  peerState.id,
146
+ map.core.id,
144
147
  anyUpdateSpy,
145
148
  );
146
149
 
@@ -1,5 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
2
  import { RawCoID } from "../ids";
3
+ import { PeerID } from "../sync";
3
4
  import { StorageAPI } from "../storage/types";
4
5
  import {
5
6
  createTestMetricReader,
@@ -36,6 +37,13 @@ function createMockStorage(
36
37
  store?: (data: any, correctionCallback: any) => void;
37
38
  getKnownState?: (id: RawCoID) => any;
38
39
  waitForSync?: (id: string, coValue: any) => Promise<void>;
40
+ trackCoValuesSyncState?: (
41
+ operations: Array<{ id: RawCoID; peerId: PeerID; synced: boolean }>,
42
+ ) => void;
43
+ getUnsyncedCoValueIDs?: (
44
+ callback: (unsyncedCoValueIDs: RawCoID[]) => void,
45
+ ) => void;
46
+ stopTrackingSyncState?: (id: RawCoID) => void;
39
47
  close?: () => Promise<unknown> | undefined;
40
48
  } = {},
41
49
  ): StorageAPI {
@@ -44,6 +52,9 @@ function createMockStorage(
44
52
  store: opts.store || vi.fn(),
45
53
  getKnownState: opts.getKnownState || vi.fn(),
46
54
  waitForSync: opts.waitForSync || vi.fn().mockResolvedValue(undefined),
55
+ trackCoValuesSyncState: opts.trackCoValuesSyncState || vi.fn(),
56
+ getUnsyncedCoValueIDs: opts.getUnsyncedCoValueIDs || vi.fn(),
57
+ stopTrackingSyncState: opts.stopTrackingSyncState || vi.fn(),
47
58
  close: opts.close || vi.fn().mockResolvedValue(undefined),
48
59
  };
49
60
  }
@@ -0,0 +1,396 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
2
+ import { setSyncStateTrackingBatchDelay } from "../UnsyncedCoValuesTracker";
3
+ import {
4
+ blockMessageTypeOnOutgoingPeer,
5
+ SyncMessagesLog,
6
+ TEST_NODE_CONFIG,
7
+ setupTestNode,
8
+ waitFor,
9
+ } from "./testUtils";
10
+
11
+ let jazzCloud: ReturnType<typeof setupTestNode>;
12
+
13
+ beforeEach(async () => {
14
+ // We want to simulate a real world communication that happens asynchronously
15
+ TEST_NODE_CONFIG.withAsyncPeers = true;
16
+
17
+ SyncMessagesLog.clear();
18
+ jazzCloud = setupTestNode({ isSyncServer: true });
19
+
20
+ setSyncStateTrackingBatchDelay(0);
21
+ });
22
+
23
+ afterEach(() => {
24
+ setSyncStateTrackingBatchDelay(1000);
25
+ });
26
+
27
+ describe("coValue sync state tracking", () => {
28
+ test("coValues with unsynced local changes are tracked as unsynced", async () => {
29
+ const { node: client } = setupTestNode({ connected: true });
30
+
31
+ const group = client.createGroup();
32
+ const map = group.createMap();
33
+ map.set("key", "value");
34
+
35
+ // Wait for local transaction to trigger sync
36
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
37
+
38
+ const unsyncedTracker = client.syncManager.unsyncedTracker;
39
+ expect(unsyncedTracker.has(map.id)).toBe(true);
40
+ });
41
+
42
+ test("coValue is marked as synced when all persistent server peers have received the content", async () => {
43
+ const { node: client } = setupTestNode({ connected: true });
44
+
45
+ const group = client.createGroup();
46
+ const map = group.createMap();
47
+ map.set("key", "value");
48
+
49
+ // Wait for local transaction to trigger sync
50
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
51
+
52
+ const unsyncedTracker = client.syncManager.unsyncedTracker;
53
+ expect(unsyncedTracker.has(map.id)).toBe(true);
54
+
55
+ const serverPeer =
56
+ client.syncManager.peers[jazzCloud.node.currentSessionID]!;
57
+ await waitFor(() =>
58
+ client.syncManager.syncState.isSynced(serverPeer, map.id),
59
+ );
60
+ expect(unsyncedTracker.has(map.id)).toBe(false);
61
+ });
62
+
63
+ test("coValues are tracked as unsynced even if there are no persistent server peers", async () => {
64
+ const { node: client } = setupTestNode({ connected: false });
65
+
66
+ const group = client.createGroup();
67
+ const map = group.createMap();
68
+ map.set("key", "value");
69
+
70
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
71
+
72
+ const unsyncedTracker = client.syncManager.unsyncedTracker;
73
+ expect(unsyncedTracker.has(map.id)).toBe(true);
74
+ });
75
+
76
+ test("only tracks sync state for persistent servers peers", async () => {
77
+ const { node: client, connectToSyncServer } = setupTestNode({
78
+ connected: true,
79
+ });
80
+
81
+ // Add a second server peer that is NOT persistent
82
+ const server2 = setupTestNode({ isSyncServer: true });
83
+ const { peer: server2PeerOnClient, peerState: server2PeerStateOnClient } =
84
+ connectToSyncServer({
85
+ syncServer: server2.node,
86
+ syncServerName: "server2",
87
+ persistent: false,
88
+ });
89
+
90
+ // Do not deliver new content messages to the second server peer
91
+ blockMessageTypeOnOutgoingPeer(server2PeerOnClient, "content", {});
92
+
93
+ const group = client.createGroup();
94
+ const map = group.createMap();
95
+ map.set("key", "value");
96
+
97
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
98
+
99
+ const unsyncedTracker = client.syncManager.unsyncedTracker;
100
+ expect(unsyncedTracker.has(map.id)).toBe(true);
101
+
102
+ const serverPeer =
103
+ client.syncManager.peers[jazzCloud.node.currentSessionID]!;
104
+ await waitFor(() =>
105
+ client.syncManager.syncState.isSynced(serverPeer, map.id),
106
+ );
107
+
108
+ expect(
109
+ client.syncManager.syncState.isSynced(server2PeerStateOnClient, map.id),
110
+ ).toBe(false);
111
+ expect(unsyncedTracker.has(map.id)).toBe(false);
112
+ });
113
+
114
+ test("coValues are not tracked as unsynced if sync is disabled", async () => {
115
+ const { node: client } = setupTestNode({
116
+ connected: false,
117
+ syncWhen: "never",
118
+ });
119
+
120
+ const group = client.createGroup();
121
+ const map = group.createMap();
122
+ map.set("key", "value");
123
+
124
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
125
+
126
+ const unsyncedTracker = client.syncManager.unsyncedTracker;
127
+ expect(unsyncedTracker.has(map.id)).toBe(false);
128
+ });
129
+
130
+ test("already synced coValues are not tracked as unsynced when trackSyncState is called", async () => {
131
+ const { node: client } = setupTestNode({ connected: true });
132
+
133
+ const group = client.createGroup();
134
+ const map = group.createMap();
135
+ map.set("key", "value");
136
+
137
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
138
+
139
+ const unsyncedTracker = client.syncManager.unsyncedTracker;
140
+ expect(unsyncedTracker.has(map.id)).toBe(true);
141
+
142
+ const serverPeer =
143
+ client.syncManager.peers[jazzCloud.node.currentSessionID]!;
144
+ await waitFor(() =>
145
+ client.syncManager.syncState.isSynced(serverPeer, map.id),
146
+ );
147
+ expect(unsyncedTracker.has(map.id)).toBe(false);
148
+
149
+ // @ts-expect-error trackSyncState is private
150
+ client.syncManager.trackSyncState(map.id);
151
+ expect(unsyncedTracker.has(map.id)).toBe(false);
152
+ });
153
+
154
+ test("imported coValue content is tracked as unsynced", async () => {
155
+ const { node: client } = setupTestNode({ connected: true });
156
+ const { node: client2 } = setupTestNode({ connected: false });
157
+
158
+ const group = client2.createGroup();
159
+ const map = group.createMap();
160
+ map.set("key", "value");
161
+
162
+ // Export the content from client2 to client
163
+ const groupContent = group.core.newContentSince()![0]!;
164
+ const mapContent = map.core.newContentSince()![0]!;
165
+ client.syncManager.handleNewContent(groupContent, "import");
166
+ client.syncManager.handleNewContent(mapContent, "import");
167
+
168
+ const unsyncedTracker = client.syncManager.unsyncedTracker;
169
+
170
+ // The imported coValue should be tracked as unsynced since it hasn't been synced to the server yet
171
+ expect(unsyncedTracker.has(group.id)).toBe(true);
172
+ expect(unsyncedTracker.has(map.id)).toBe(true);
173
+
174
+ // Wait for the map to sync
175
+ const serverPeer =
176
+ client.syncManager.peers[jazzCloud.node.currentSessionID]!;
177
+ await waitFor(() =>
178
+ client.syncManager.syncState.isSynced(serverPeer, map.id),
179
+ );
180
+ expect(unsyncedTracker.has(map.id)).toBe(false);
181
+ });
182
+ });
183
+
184
+ describe("sync state persistence", () => {
185
+ test("unsynced coValues are asynchronously persisted to storage", async () => {
186
+ const { node: client, addStorage } = setupTestNode({ connected: false });
187
+ addStorage();
188
+
189
+ const group = client.createGroup();
190
+ const map = group.createMap();
191
+ map.set("key", "value");
192
+
193
+ // Wait for the unsynced coValues to be persisted to storage
194
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
195
+
196
+ const unsyncedCoValueIDs = await new Promise((resolve) =>
197
+ client.storage?.getUnsyncedCoValueIDs(resolve),
198
+ );
199
+ expect(unsyncedCoValueIDs).toHaveLength(2);
200
+ expect(unsyncedCoValueIDs).toContain(map.id);
201
+ expect(unsyncedCoValueIDs).toContain(group.id);
202
+ });
203
+
204
+ test("synced coValues are removed from storage", async () => {
205
+ const { node: client, addStorage } = setupTestNode({ connected: true });
206
+ addStorage();
207
+
208
+ const group = client.createGroup();
209
+ const map = group.createMap();
210
+ map.set("key", "value");
211
+
212
+ // Wait enough time for the coValue to be synced
213
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
214
+
215
+ const unsyncedCoValueIDs = await new Promise((resolve) =>
216
+ client.storage?.getUnsyncedCoValueIDs(resolve),
217
+ );
218
+ expect(unsyncedCoValueIDs).toHaveLength(0);
219
+ expect(client.syncManager.unsyncedTracker.has(map.id)).toBe(false);
220
+ });
221
+
222
+ test("unsynced coValues are persisted to storage when the node is shutdown", async () => {
223
+ const { node: client, addStorage } = setupTestNode({ connected: false });
224
+ addStorage();
225
+
226
+ const group = client.createGroup();
227
+ const map = group.createMap();
228
+ map.set("key", "value");
229
+
230
+ // Wait for local transaction to trigger sync
231
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
232
+
233
+ await client.gracefulShutdown();
234
+
235
+ const unsyncedCoValueIDs = await new Promise((resolve) =>
236
+ client.storage?.getUnsyncedCoValueIDs(resolve),
237
+ );
238
+ expect(unsyncedCoValueIDs).toHaveLength(2);
239
+ expect(unsyncedCoValueIDs).toContain(map.id);
240
+ expect(unsyncedCoValueIDs).toContain(group.id);
241
+ });
242
+ });
243
+
244
+ describe("sync resumption", () => {
245
+ test("unsynced coValues are resumed when the node is restarted", async () => {
246
+ const client = setupTestNode({ connected: false });
247
+ const { storage } = client.addStorage();
248
+
249
+ const getUnsyncedCoValueIDsFromStorage = async () =>
250
+ new Promise<string[]>((resolve) =>
251
+ client.node.storage?.getUnsyncedCoValueIDs(resolve),
252
+ );
253
+
254
+ const group = client.node.createGroup();
255
+ const map = group.createMap();
256
+ map.set("key", "value");
257
+
258
+ // Wait for the unsynced coValues to be persisted to storage
259
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
260
+
261
+ const unsyncedTracker = client.node.syncManager.unsyncedTracker;
262
+ expect(unsyncedTracker.has(map.id)).toBe(true);
263
+ expect(await getUnsyncedCoValueIDsFromStorage()).toHaveLength(2);
264
+
265
+ client.restart();
266
+ client.addStorage({ storage });
267
+ const { peerState: serverPeerState } = client.connectToSyncServer();
268
+
269
+ // Wait for sync to resume & complete
270
+ await waitFor(
271
+ async () => (await getUnsyncedCoValueIDsFromStorage()).length === 0,
272
+ );
273
+ expect(
274
+ client.node.syncManager.syncState.isSynced(serverPeerState, map.id),
275
+ ).toBe(true);
276
+ });
277
+
278
+ test("lots of unsynced coValues are resumed in batches when the node is restarted", async () => {
279
+ const client = setupTestNode({ connected: false });
280
+ const { storage } = client.addStorage();
281
+
282
+ const getUnsyncedCoValueIDsFromStorage = async () =>
283
+ new Promise<string[]>((resolve) =>
284
+ client.node.storage?.getUnsyncedCoValueIDs(resolve),
285
+ );
286
+
287
+ const group = client.node.createGroup();
288
+ const maps = Array.from({ length: 100 }, () => {
289
+ const map = group.createMap();
290
+ map.set("key", "value");
291
+ return map;
292
+ });
293
+
294
+ // Wait for the unsynced coValues to be persisted to storage
295
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
296
+
297
+ const unsyncedTracker = client.node.syncManager.unsyncedTracker;
298
+ for (const map of maps) {
299
+ expect(unsyncedTracker.has(map.id)).toBe(true);
300
+ }
301
+ expect(await getUnsyncedCoValueIDsFromStorage()).toHaveLength(101);
302
+
303
+ client.restart();
304
+ client.addStorage({ storage });
305
+ const { peerState: serverPeerState } = client.connectToSyncServer();
306
+
307
+ // Wait for sync to resume & complete
308
+ await waitFor(
309
+ async () => (await getUnsyncedCoValueIDsFromStorage()).length === 0,
310
+ );
311
+ for (const map of maps) {
312
+ expect(
313
+ client.node.syncManager.syncState.isSynced(serverPeerState, map.id),
314
+ ).toBe(true);
315
+ }
316
+ });
317
+
318
+ test("old peer entries are removed from storage when restarting with new peers", async () => {
319
+ const client = setupTestNode();
320
+ const { peer: serverPeer } = client.connectToSyncServer({
321
+ persistent: true,
322
+ });
323
+ const { storage } = client.addStorage();
324
+
325
+ // Do not deliver new content messages to the sync server
326
+ blockMessageTypeOnOutgoingPeer(serverPeer, "content", {});
327
+
328
+ const getUnsyncedCoValueIDsFromStorage = async () =>
329
+ new Promise<string[]>((resolve) =>
330
+ client.node.storage?.getUnsyncedCoValueIDs(resolve),
331
+ );
332
+
333
+ const group = client.node.createGroup();
334
+ const map = group.createMap();
335
+ map.set("key", "value");
336
+
337
+ // Wait for the unsynced coValues to be persisted to storage
338
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
339
+
340
+ expect(await getUnsyncedCoValueIDsFromStorage()).toHaveLength(2);
341
+
342
+ client.restart();
343
+ client.addStorage({ storage });
344
+ const newSyncServer = setupTestNode({ isSyncServer: true });
345
+ const { peerState: newServerPeerState } = client.connectToSyncServer({
346
+ syncServer: newSyncServer.node,
347
+ persistent: true,
348
+ });
349
+
350
+ // Wait for sync to resume & complete
351
+ await waitFor(
352
+ async () => (await getUnsyncedCoValueIDsFromStorage()).length === 0,
353
+ );
354
+ expect(
355
+ client.node.syncManager.syncState.isSynced(newServerPeerState, map.id),
356
+ ).toBe(true);
357
+ });
358
+
359
+ test("sync resumption is skipped when adding a peer that is not a persistent server", async () => {
360
+ const client = setupTestNode({ connected: false });
361
+ const { storage } = client.addStorage();
362
+
363
+ const getUnsyncedCoValueIDsFromStorage = async () =>
364
+ new Promise<string[]>((resolve) =>
365
+ client.node.storage?.getUnsyncedCoValueIDs(resolve),
366
+ );
367
+
368
+ const group = client.node.createGroup();
369
+ const map = group.createMap();
370
+ map.set("key", "value");
371
+
372
+ // Wait for the unsynced coValues to be persisted to storage
373
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
374
+
375
+ let unsyncedCoValueIDs = await getUnsyncedCoValueIDsFromStorage();
376
+ expect(unsyncedCoValueIDs).toHaveLength(2);
377
+ expect(unsyncedCoValueIDs).toContain(map.id);
378
+ expect(unsyncedCoValueIDs).toContain(group.id);
379
+
380
+ client.restart();
381
+ client.addStorage({ storage });
382
+ const newPeer = setupTestNode({ isSyncServer: true });
383
+ client.connectToSyncServer({
384
+ syncServer: newPeer.node,
385
+ persistent: false,
386
+ });
387
+
388
+ // Wait to confirm sync is not resumed
389
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
390
+
391
+ unsyncedCoValueIDs = await getUnsyncedCoValueIDsFromStorage();
392
+ expect(unsyncedCoValueIDs).toHaveLength(2);
393
+ expect(unsyncedCoValueIDs).toContain(map.id);
394
+ expect(unsyncedCoValueIDs).toContain(group.id);
395
+ });
396
+ });