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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +9 -0
- package/dist/PeerState.d.ts.map +1 -1
- package/dist/SyncStateManager.d.ts +5 -2
- package/dist/SyncStateManager.d.ts.map +1 -1
- package/dist/SyncStateManager.js +49 -12
- package/dist/SyncStateManager.js.map +1 -1
- package/dist/UnsyncedCoValuesTracker.d.ts +81 -0
- package/dist/UnsyncedCoValuesTracker.d.ts.map +1 -0
- package/dist/UnsyncedCoValuesTracker.js +209 -0
- package/dist/UnsyncedCoValuesTracker.js.map +1 -0
- package/dist/exports.d.ts +4 -2
- package/dist/exports.d.ts.map +1 -1
- package/dist/exports.js +2 -0
- package/dist/exports.js.map +1 -1
- package/dist/localNode.d.ts +9 -5
- package/dist/localNode.d.ts.map +1 -1
- package/dist/localNode.js +12 -8
- package/dist/localNode.js.map +1 -1
- package/dist/storage/knownState.d.ts +1 -1
- package/dist/storage/knownState.js +4 -4
- package/dist/storage/sqlite/client.d.ts +8 -0
- package/dist/storage/sqlite/client.d.ts.map +1 -1
- package/dist/storage/sqlite/client.js +17 -0
- package/dist/storage/sqlite/client.js.map +1 -1
- package/dist/storage/sqlite/sqliteMigrations.d.ts.map +1 -1
- package/dist/storage/sqlite/sqliteMigrations.js +9 -0
- package/dist/storage/sqlite/sqliteMigrations.js.map +1 -1
- package/dist/storage/sqliteAsync/client.d.ts +8 -0
- package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
- package/dist/storage/sqliteAsync/client.js +19 -0
- package/dist/storage/sqliteAsync/client.js.map +1 -1
- package/dist/storage/storageAsync.d.ts +9 -2
- package/dist/storage/storageAsync.d.ts.map +1 -1
- package/dist/storage/storageAsync.js +9 -0
- package/dist/storage/storageAsync.js.map +1 -1
- package/dist/storage/storageSync.d.ts +9 -2
- package/dist/storage/storageSync.d.ts.map +1 -1
- package/dist/storage/storageSync.js +11 -0
- package/dist/storage/storageSync.js.map +1 -1
- package/dist/storage/types.d.ts +33 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/sync.d.ts +21 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +107 -2
- package/dist/sync.js.map +1 -1
- package/dist/tests/SyncStateManager.test.js +3 -3
- package/dist/tests/SyncStateManager.test.js.map +1 -1
- package/dist/tests/coValueCore.loadFromStorage.test.js +3 -0
- package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
- package/dist/tests/sync.tracking.test.d.ts +2 -0
- package/dist/tests/sync.tracking.test.d.ts.map +1 -0
- package/dist/tests/sync.tracking.test.js +261 -0
- package/dist/tests/sync.tracking.test.js.map +1 -0
- package/dist/tests/testUtils.d.ts +2 -1
- package/dist/tests/testUtils.d.ts.map +1 -1
- package/dist/tests/testUtils.js +2 -2
- package/dist/tests/testUtils.js.map +1 -1
- package/package.json +4 -4
- package/src/PeerState.ts +2 -2
- package/src/SyncStateManager.ts +63 -12
- package/src/UnsyncedCoValuesTracker.ts +272 -0
- package/src/exports.ts +4 -1
- package/src/localNode.ts +15 -3
- package/src/storage/knownState.ts +4 -4
- package/src/storage/sqlite/client.ts +31 -0
- package/src/storage/sqlite/sqliteMigrations.ts +9 -0
- package/src/storage/sqliteAsync/client.ts +35 -0
- package/src/storage/storageAsync.ts +18 -1
- package/src/storage/storageSync.ts +20 -1
- package/src/storage/types.ts +39 -0
- package/src/sync.ts +151 -3
- package/src/tests/SyncStateManager.test.ts +3 -0
- package/src/tests/coValueCore.loadFromStorage.test.ts +11 -0
- package/src/tests/sync.tracking.test.ts +396 -0
- 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
|
-
|
|
864
|
-
|
|
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
|
-
|
|
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
|
+
});
|