cojson 0.19.21 → 0.20.0
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 +67 -0
- package/dist/CojsonMessageChannel/CojsonMessageChannel.d.ts +42 -0
- package/dist/CojsonMessageChannel/CojsonMessageChannel.d.ts.map +1 -0
- package/dist/CojsonMessageChannel/CojsonMessageChannel.js +261 -0
- package/dist/CojsonMessageChannel/CojsonMessageChannel.js.map +1 -0
- package/dist/CojsonMessageChannel/MessagePortOutgoingChannel.d.ts +18 -0
- package/dist/CojsonMessageChannel/MessagePortOutgoingChannel.d.ts.map +1 -0
- package/dist/CojsonMessageChannel/MessagePortOutgoingChannel.js +37 -0
- package/dist/CojsonMessageChannel/MessagePortOutgoingChannel.js.map +1 -0
- package/dist/CojsonMessageChannel/index.d.ts +3 -0
- package/dist/CojsonMessageChannel/index.d.ts.map +1 -0
- package/dist/CojsonMessageChannel/index.js +2 -0
- package/dist/CojsonMessageChannel/index.js.map +1 -0
- package/dist/CojsonMessageChannel/types.d.ts +149 -0
- package/dist/CojsonMessageChannel/types.d.ts.map +1 -0
- package/dist/CojsonMessageChannel/types.js +36 -0
- package/dist/CojsonMessageChannel/types.js.map +1 -0
- package/dist/GarbageCollector.d.ts +4 -2
- package/dist/GarbageCollector.d.ts.map +1 -1
- package/dist/GarbageCollector.js +5 -3
- package/dist/GarbageCollector.js.map +1 -1
- package/dist/SyncStateManager.d.ts +3 -3
- package/dist/SyncStateManager.d.ts.map +1 -1
- package/dist/SyncStateManager.js +4 -4
- package/dist/SyncStateManager.js.map +1 -1
- package/dist/coValueContentMessage.d.ts +0 -2
- package/dist/coValueContentMessage.d.ts.map +1 -1
- package/dist/coValueContentMessage.js +0 -8
- package/dist/coValueContentMessage.js.map +1 -1
- package/dist/coValueCore/SessionMap.d.ts +4 -2
- package/dist/coValueCore/SessionMap.d.ts.map +1 -1
- package/dist/coValueCore/SessionMap.js +30 -0
- package/dist/coValueCore/SessionMap.js.map +1 -1
- package/dist/coValueCore/coValueCore.d.ts +86 -4
- package/dist/coValueCore/coValueCore.d.ts.map +1 -1
- package/dist/coValueCore/coValueCore.js +318 -17
- package/dist/coValueCore/coValueCore.js.map +1 -1
- package/dist/coValueCore/verifiedState.d.ts +6 -1
- package/dist/coValueCore/verifiedState.d.ts.map +1 -1
- package/dist/coValueCore/verifiedState.js +9 -0
- package/dist/coValueCore/verifiedState.js.map +1 -1
- package/dist/coValues/coList.d.ts +3 -2
- package/dist/coValues/coList.d.ts.map +1 -1
- package/dist/coValues/coList.js.map +1 -1
- package/dist/coValues/group.d.ts.map +1 -1
- package/dist/coValues/group.js +3 -6
- package/dist/coValues/group.js.map +1 -1
- package/dist/config.d.ts +0 -6
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -8
- package/dist/config.js.map +1 -1
- package/dist/crypto/NapiCrypto.d.ts +1 -2
- package/dist/crypto/NapiCrypto.d.ts.map +1 -1
- package/dist/crypto/NapiCrypto.js +19 -4
- package/dist/crypto/NapiCrypto.js.map +1 -1
- package/dist/crypto/RNCrypto.d.ts.map +1 -1
- package/dist/crypto/RNCrypto.js +19 -4
- package/dist/crypto/RNCrypto.js.map +1 -1
- package/dist/crypto/WasmCrypto.d.ts +11 -4
- package/dist/crypto/WasmCrypto.d.ts.map +1 -1
- package/dist/crypto/WasmCrypto.js +52 -10
- package/dist/crypto/WasmCrypto.js.map +1 -1
- package/dist/crypto/WasmCryptoEdge.d.ts +1 -0
- package/dist/crypto/WasmCryptoEdge.d.ts.map +1 -1
- package/dist/crypto/WasmCryptoEdge.js +4 -1
- package/dist/crypto/WasmCryptoEdge.js.map +1 -1
- package/dist/crypto/crypto.d.ts +3 -3
- package/dist/crypto/crypto.d.ts.map +1 -1
- package/dist/crypto/crypto.js +6 -1
- package/dist/crypto/crypto.js.map +1 -1
- package/dist/exports.d.ts +3 -2
- package/dist/exports.d.ts.map +1 -1
- package/dist/exports.js +3 -1
- package/dist/exports.js.map +1 -1
- package/dist/ids.d.ts +4 -1
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +4 -0
- package/dist/ids.js.map +1 -1
- package/dist/knownState.d.ts +2 -0
- package/dist/knownState.d.ts.map +1 -1
- package/dist/localNode.d.ts +13 -3
- package/dist/localNode.d.ts.map +1 -1
- package/dist/localNode.js +17 -2
- package/dist/localNode.js.map +1 -1
- package/dist/platformUtils.d.ts +3 -0
- package/dist/platformUtils.d.ts.map +1 -0
- package/dist/platformUtils.js +24 -0
- package/dist/platformUtils.js.map +1 -0
- package/dist/storage/DeletedCoValuesEraserScheduler.d.ts +30 -0
- package/dist/storage/DeletedCoValuesEraserScheduler.d.ts.map +1 -0
- package/dist/storage/DeletedCoValuesEraserScheduler.js +84 -0
- package/dist/storage/DeletedCoValuesEraserScheduler.js.map +1 -0
- package/dist/storage/sqlite/client.d.ts +3 -0
- package/dist/storage/sqlite/client.d.ts.map +1 -1
- package/dist/storage/sqlite/client.js +44 -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 +7 -0
- package/dist/storage/sqlite/sqliteMigrations.js.map +1 -1
- package/dist/storage/sqliteAsync/client.d.ts +3 -0
- package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
- package/dist/storage/sqliteAsync/client.js +42 -0
- package/dist/storage/sqliteAsync/client.js.map +1 -1
- package/dist/storage/storageAsync.d.ts +15 -3
- package/dist/storage/storageAsync.d.ts.map +1 -1
- package/dist/storage/storageAsync.js +60 -3
- package/dist/storage/storageAsync.js.map +1 -1
- package/dist/storage/storageSync.d.ts +14 -3
- package/dist/storage/storageSync.d.ts.map +1 -1
- package/dist/storage/storageSync.js +54 -3
- package/dist/storage/storageSync.js.map +1 -1
- package/dist/storage/types.d.ts +64 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/storage/types.js +12 -1
- package/dist/storage/types.js.map +1 -1
- package/dist/sync.d.ts +6 -0
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +69 -15
- package/dist/sync.js.map +1 -1
- package/dist/tests/CojsonMessageChannel.test.d.ts +2 -0
- package/dist/tests/CojsonMessageChannel.test.d.ts.map +1 -0
- package/dist/tests/CojsonMessageChannel.test.js +236 -0
- package/dist/tests/CojsonMessageChannel.test.js.map +1 -0
- package/dist/tests/DeletedCoValuesEraserScheduler.test.d.ts +2 -0
- package/dist/tests/DeletedCoValuesEraserScheduler.test.d.ts.map +1 -0
- package/dist/tests/DeletedCoValuesEraserScheduler.test.js +149 -0
- package/dist/tests/DeletedCoValuesEraserScheduler.test.js.map +1 -0
- package/dist/tests/GarbageCollector.test.js +91 -18
- package/dist/tests/GarbageCollector.test.js.map +1 -1
- package/dist/tests/StorageApiAsync.test.js +510 -146
- package/dist/tests/StorageApiAsync.test.js.map +1 -1
- package/dist/tests/StorageApiSync.test.js +531 -130
- package/dist/tests/StorageApiSync.test.js.map +1 -1
- package/dist/tests/SyncManager.processQueues.test.js +1 -1
- package/dist/tests/SyncManager.processQueues.test.js.map +1 -1
- package/dist/tests/SyncStateManager.test.js +1 -1
- package/dist/tests/SyncStateManager.test.js.map +1 -1
- package/dist/tests/WasmCrypto.test.js +6 -3
- package/dist/tests/WasmCrypto.test.js.map +1 -1
- package/dist/tests/coPlainText.test.js +1 -1
- package/dist/tests/coPlainText.test.js.map +1 -1
- package/dist/tests/coValueCore.loadFromStorage.test.js +4 -0
- package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
- package/dist/tests/coValueCore.test.js +34 -13
- package/dist/tests/coValueCore.test.js.map +1 -1
- package/dist/tests/coreWasm.test.js +127 -4
- package/dist/tests/coreWasm.test.js.map +1 -1
- package/dist/tests/crypto.test.js +89 -93
- package/dist/tests/crypto.test.js.map +1 -1
- package/dist/tests/deleteCoValue.test.d.ts +2 -0
- package/dist/tests/deleteCoValue.test.d.ts.map +1 -0
- package/dist/tests/deleteCoValue.test.js +313 -0
- package/dist/tests/deleteCoValue.test.js.map +1 -0
- package/dist/tests/group.removeMember.test.js +18 -30
- package/dist/tests/group.removeMember.test.js.map +1 -1
- package/dist/tests/knownState.lazyLoading.test.js +4 -0
- package/dist/tests/knownState.lazyLoading.test.js.map +1 -1
- package/dist/tests/sync.deleted.test.d.ts +2 -0
- package/dist/tests/sync.deleted.test.d.ts.map +1 -0
- package/dist/tests/sync.deleted.test.js +214 -0
- package/dist/tests/sync.deleted.test.js.map +1 -0
- package/dist/tests/sync.garbageCollection.test.js +56 -32
- package/dist/tests/sync.garbageCollection.test.js.map +1 -1
- package/dist/tests/sync.load.test.js +3 -5
- package/dist/tests/sync.load.test.js.map +1 -1
- package/dist/tests/sync.mesh.test.js +4 -3
- package/dist/tests/sync.mesh.test.js.map +1 -1
- package/dist/tests/sync.peerReconciliation.test.js +3 -3
- package/dist/tests/sync.peerReconciliation.test.js.map +1 -1
- package/dist/tests/sync.storage.test.js +12 -11
- package/dist/tests/sync.storage.test.js.map +1 -1
- package/dist/tests/sync.storageAsync.test.js +7 -7
- package/dist/tests/sync.storageAsync.test.js.map +1 -1
- package/dist/tests/sync.test.js +3 -2
- package/dist/tests/sync.test.js.map +1 -1
- package/dist/tests/sync.tracking.test.js +35 -4
- package/dist/tests/sync.tracking.test.js.map +1 -1
- package/dist/tests/testStorage.d.ts +3 -0
- package/dist/tests/testStorage.d.ts.map +1 -1
- package/dist/tests/testStorage.js +16 -2
- package/dist/tests/testStorage.js.map +1 -1
- package/dist/tests/testUtils.d.ts +29 -4
- package/dist/tests/testUtils.d.ts.map +1 -1
- package/dist/tests/testUtils.js +84 -9
- package/dist/tests/testUtils.js.map +1 -1
- package/package.json +6 -16
- package/src/CojsonMessageChannel/CojsonMessageChannel.ts +332 -0
- package/src/CojsonMessageChannel/MessagePortOutgoingChannel.ts +52 -0
- package/src/CojsonMessageChannel/index.ts +9 -0
- package/src/CojsonMessageChannel/types.ts +200 -0
- package/src/GarbageCollector.ts +5 -5
- package/src/SyncStateManager.ts +6 -6
- package/src/coValueContentMessage.ts +0 -14
- package/src/coValueCore/SessionMap.ts +43 -1
- package/src/coValueCore/coValueCore.ts +430 -15
- package/src/coValueCore/verifiedState.ts +26 -3
- package/src/coValues/coList.ts +5 -3
- package/src/coValues/group.ts +5 -6
- package/src/config.ts +0 -9
- package/src/crypto/NapiCrypto.ts +29 -13
- package/src/crypto/RNCrypto.ts +29 -11
- package/src/crypto/WasmCrypto.ts +67 -20
- package/src/crypto/WasmCryptoEdge.ts +5 -1
- package/src/crypto/crypto.ts +16 -4
- package/src/exports.ts +3 -0
- package/src/ids.ts +11 -1
- package/src/localNode.ts +18 -5
- package/src/platformUtils.ts +26 -0
- package/src/storage/DeletedCoValuesEraserScheduler.ts +124 -0
- package/src/storage/sqlite/client.ts +77 -0
- package/src/storage/sqlite/sqliteMigrations.ts +7 -0
- package/src/storage/sqliteAsync/client.ts +75 -0
- package/src/storage/storageAsync.ts +77 -4
- package/src/storage/storageSync.ts +73 -4
- package/src/storage/types.ts +75 -0
- package/src/sync.ts +84 -15
- package/src/tests/CojsonMessageChannel.test.ts +306 -0
- package/src/tests/DeletedCoValuesEraserScheduler.test.ts +185 -0
- package/src/tests/GarbageCollector.test.ts +119 -22
- package/src/tests/StorageApiAsync.test.ts +615 -156
- package/src/tests/StorageApiSync.test.ts +623 -137
- package/src/tests/SyncManager.processQueues.test.ts +1 -1
- package/src/tests/SyncStateManager.test.ts +1 -1
- package/src/tests/WasmCrypto.test.ts +8 -3
- package/src/tests/coPlainText.test.ts +1 -1
- package/src/tests/coValueCore.loadFromStorage.test.ts +8 -0
- package/src/tests/coValueCore.test.ts +49 -14
- package/src/tests/coreWasm.test.ts +319 -10
- package/src/tests/crypto.test.ts +141 -150
- package/src/tests/deleteCoValue.test.ts +528 -0
- package/src/tests/group.removeMember.test.ts +35 -35
- package/src/tests/knownState.lazyLoading.test.ts +8 -0
- package/src/tests/sync.deleted.test.ts +294 -0
- package/src/tests/sync.garbageCollection.test.ts +69 -36
- package/src/tests/sync.load.test.ts +3 -5
- package/src/tests/sync.mesh.test.ts +6 -3
- package/src/tests/sync.peerReconciliation.test.ts +3 -3
- package/src/tests/sync.storage.test.ts +14 -11
- package/src/tests/sync.storageAsync.test.ts +7 -7
- package/src/tests/sync.test.ts +5 -2
- package/src/tests/sync.tracking.test.ts +54 -4
- package/src/tests/testStorage.ts +30 -3
- package/src/tests/testUtils.ts +113 -15
- package/dist/crypto/PureJSCrypto.d.ts +0 -77
- package/dist/crypto/PureJSCrypto.d.ts.map +0 -1
- package/dist/crypto/PureJSCrypto.js +0 -236
- package/dist/crypto/PureJSCrypto.js.map +0 -1
- package/dist/tests/PureJSCrypto.test.d.ts +0 -2
- package/dist/tests/PureJSCrypto.test.d.ts.map +0 -1
- package/dist/tests/PureJSCrypto.test.js +0 -145
- package/dist/tests/PureJSCrypto.test.js.map +0 -1
- package/src/crypto/PureJSCrypto.ts +0 -429
- package/src/tests/PureJSCrypto.test.ts +0 -217
package/src/sync.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { CoValueCore } from "./coValueCore/coValueCore.js";
|
|
15
15
|
import { CoValueHeader, Transaction } from "./coValueCore/verifiedState.js";
|
|
16
16
|
import { Signature } from "./crypto/crypto.js";
|
|
17
|
-
import { RawCoID, SessionID, isRawCoID } from "./ids.js";
|
|
17
|
+
import { isDeleteSessionID, RawCoID, SessionID, isRawCoID } from "./ids.js";
|
|
18
18
|
import { LocalNode } from "./localNode.js";
|
|
19
19
|
import { logger } from "./logger.js";
|
|
20
20
|
import { CoValuePriority } from "./priority.js";
|
|
@@ -102,6 +102,10 @@ export interface Peer {
|
|
|
102
102
|
persistent?: boolean;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
function isPersistentServerPeer(peer: Peer | PeerState): boolean {
|
|
106
|
+
return peer.role === "server" && (peer.persistent ?? false);
|
|
107
|
+
}
|
|
108
|
+
|
|
105
109
|
export type ServerPeerSelector = (
|
|
106
110
|
id: RawCoID,
|
|
107
111
|
serverPeers: PeerState[],
|
|
@@ -209,7 +213,6 @@ export class SyncManager {
|
|
|
209
213
|
return;
|
|
210
214
|
}
|
|
211
215
|
|
|
212
|
-
// TODO: validate
|
|
213
216
|
switch (msg.action) {
|
|
214
217
|
case "load":
|
|
215
218
|
return this.handleLoad(msg, peer);
|
|
@@ -261,10 +264,18 @@ export class SyncManager {
|
|
|
261
264
|
|
|
262
265
|
peer.combineOptimisticWith(id, coValue.knownState());
|
|
263
266
|
} else if (!peer.toldKnownState.has(id)) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
267
|
+
if (coValue.isDeleted) {
|
|
268
|
+
// This way we make the peer believe that we've always ingested all the content they sent, even though we skipped it because the coValue is deleted
|
|
269
|
+
this.trySendToPeer(
|
|
270
|
+
peer,
|
|
271
|
+
coValue.stopSyncingKnownStateMessage(peer.getKnownState(id)),
|
|
272
|
+
);
|
|
273
|
+
} else {
|
|
274
|
+
this.trySendToPeer(peer, {
|
|
275
|
+
action: "known",
|
|
276
|
+
...coValue.knownStateWithStreaming(),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
268
279
|
}
|
|
269
280
|
|
|
270
281
|
peer.trackToldKnownState(id);
|
|
@@ -354,7 +365,7 @@ export class SyncManager {
|
|
|
354
365
|
}
|
|
355
366
|
|
|
356
367
|
startPeerReconciliation(peer: PeerState) {
|
|
357
|
-
if (peer
|
|
368
|
+
if (isPersistentServerPeer(peer)) {
|
|
358
369
|
// Resume syncing unsynced CoValues asynchronously
|
|
359
370
|
this.resumeUnsyncedCoValues().catch((error) => {
|
|
360
371
|
logger.warn("Failed to resume unsynced CoValues:", error);
|
|
@@ -525,7 +536,7 @@ export class SyncManager {
|
|
|
525
536
|
|
|
526
537
|
const unsubscribeFromKnownStatesUpdates =
|
|
527
538
|
peerState.subscribeToKnownStatesUpdates((id, knownState) => {
|
|
528
|
-
this.syncState.triggerUpdate(peer
|
|
539
|
+
this.syncState.triggerUpdate(peer, id, knownState.value());
|
|
529
540
|
});
|
|
530
541
|
|
|
531
542
|
if (!skipReconciliation && peerState.role === "server") {
|
|
@@ -711,8 +722,8 @@ export class SyncManager {
|
|
|
711
722
|
|
|
712
723
|
peer.combineWith(msg.id, knownStateFrom(msg));
|
|
713
724
|
|
|
714
|
-
// The header is a boolean value that tells us if the other peer
|
|
715
|
-
// If it's false
|
|
725
|
+
// The header is a boolean value that tells us if the other peer has information about the header.
|
|
726
|
+
// If it's false at this point it means that the coValue is unavailable on the other peer.
|
|
716
727
|
const availableOnPeer = peer.getOptimisticKnownState(msg.id)?.header;
|
|
717
728
|
|
|
718
729
|
if (!availableOnPeer) {
|
|
@@ -871,6 +882,8 @@ export class SyncManager {
|
|
|
871
882
|
new: {},
|
|
872
883
|
};
|
|
873
884
|
|
|
885
|
+
let wasAlreadyDeleted = coValue.isDeleted;
|
|
886
|
+
|
|
874
887
|
/**
|
|
875
888
|
* The coValue is in memory, load the transactions from the content message
|
|
876
889
|
*/
|
|
@@ -878,6 +891,10 @@ export class SyncManager {
|
|
|
878
891
|
sessionID,
|
|
879
892
|
newContentForSession,
|
|
880
893
|
] of getSessionEntriesFromContentMessage(msg)) {
|
|
894
|
+
if (wasAlreadyDeleted && !isDeleteSessionID(sessionID)) {
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
|
|
881
898
|
const newTransactions = getNewTransactionsFromContentMessage(
|
|
882
899
|
newContentForSession,
|
|
883
900
|
coValue.knownState(),
|
|
@@ -932,12 +949,25 @@ export class SyncManager {
|
|
|
932
949
|
this.recordTransactionsSize(newTransactions, sourceRole);
|
|
933
950
|
}
|
|
934
951
|
|
|
952
|
+
// We reset the new content for the deleted coValue
|
|
953
|
+
// because we want to store only the delete session/transaction
|
|
954
|
+
if (!wasAlreadyDeleted && coValue.isDeleted) {
|
|
955
|
+
wasAlreadyDeleted = true;
|
|
956
|
+
validNewContent.new = {};
|
|
957
|
+
}
|
|
958
|
+
|
|
935
959
|
// The new content for this session has been verified, so we can store it
|
|
936
960
|
validNewContent.new[sessionID] = newContentForSession;
|
|
937
961
|
}
|
|
938
962
|
|
|
939
963
|
if (peer) {
|
|
940
|
-
|
|
964
|
+
if (coValue.isDeleted) {
|
|
965
|
+
// In case of deleted coValues, we combine the known state with the content message
|
|
966
|
+
// to avoid that clients that don't support deleted coValues try to sync their own content indefinitely
|
|
967
|
+
peer.combineWith(msg.id, knownStateFromContent(msg));
|
|
968
|
+
} else {
|
|
969
|
+
peer.combineWith(msg.id, knownStateFromContent(validNewContent));
|
|
970
|
+
}
|
|
941
971
|
}
|
|
942
972
|
|
|
943
973
|
/**
|
|
@@ -969,10 +999,18 @@ export class SyncManager {
|
|
|
969
999
|
* This way the sender knows that the content has been received and applied
|
|
970
1000
|
* and can update their peer's knownState accordingly.
|
|
971
1001
|
*/
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1002
|
+
if (coValue.isDeleted) {
|
|
1003
|
+
// This way we make the peer believe that we've ingested all the content, even though we skipped it because the coValue is deleted
|
|
1004
|
+
this.trySendToPeer(
|
|
1005
|
+
peer,
|
|
1006
|
+
coValue.stopSyncingKnownStateMessage(peer.getKnownState(msg.id)),
|
|
1007
|
+
);
|
|
1008
|
+
} else {
|
|
1009
|
+
this.trySendToPeer(peer, {
|
|
1010
|
+
action: "known",
|
|
1011
|
+
...coValue.knownState(),
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
976
1014
|
peer.trackToldKnownState(msg.id);
|
|
977
1015
|
}
|
|
978
1016
|
|
|
@@ -1076,6 +1114,18 @@ export class SyncManager {
|
|
|
1076
1114
|
const isSyncRequired = this.local.syncWhen !== "never";
|
|
1077
1115
|
if (isSyncRequired && peers.length === 0) {
|
|
1078
1116
|
this.unsyncedTracker.add(coValueId);
|
|
1117
|
+
|
|
1118
|
+
// Mark CoValue as synced once a persistent server peer is added and
|
|
1119
|
+
// the CoValue is synced
|
|
1120
|
+
const unsubscribe = this.syncState.subscribeToCoValueUpdates(
|
|
1121
|
+
coValueId,
|
|
1122
|
+
(peer, _knownState, syncState) => {
|
|
1123
|
+
if (isPersistentServerPeer(peer) && syncState.uploaded) {
|
|
1124
|
+
this.unsyncedTracker.remove(coValueId);
|
|
1125
|
+
unsubscribe();
|
|
1126
|
+
}
|
|
1127
|
+
},
|
|
1128
|
+
);
|
|
1079
1129
|
return;
|
|
1080
1130
|
}
|
|
1081
1131
|
|
|
@@ -1108,6 +1158,12 @@ export class SyncManager {
|
|
|
1108
1158
|
|
|
1109
1159
|
const value = this.local.getCoValue(content.id);
|
|
1110
1160
|
|
|
1161
|
+
if (value.isDeleted) {
|
|
1162
|
+
// This doesn't persist the delete flag, it only signals the storage
|
|
1163
|
+
// API that the delete transaction is valid
|
|
1164
|
+
storage.markDeleteAsValid(value.id);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1111
1167
|
// Try to store the content as-is for performance
|
|
1112
1168
|
// In case that some transactions are missing, a correction will be requested, but it's an edge case
|
|
1113
1169
|
storage.store(content, (correction) => {
|
|
@@ -1128,6 +1184,19 @@ export class SyncManager {
|
|
|
1128
1184
|
});
|
|
1129
1185
|
}
|
|
1130
1186
|
|
|
1187
|
+
/**
|
|
1188
|
+
* Returns true if the local CoValue changes have been synced to all persistent server peers.
|
|
1189
|
+
*
|
|
1190
|
+
* Used during garbage collection to determine if the coValue is pending sync.
|
|
1191
|
+
*/
|
|
1192
|
+
isSyncedToServerPeers(id: RawCoID): boolean {
|
|
1193
|
+
// If there are currently no server peers, go ahead with GC.
|
|
1194
|
+
// The CoValue will be reloaded into memory and synced when a peer is added.
|
|
1195
|
+
return this.getPersistentServerPeers(id).every((peer) =>
|
|
1196
|
+
this.syncState.isSynced(peer, id),
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1131
1200
|
waitForSyncWithPeer(peerId: PeerID, id: RawCoID, timeout: number) {
|
|
1132
1201
|
const peerState = this.peers[peerId];
|
|
1133
1202
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { logger } from "../logger.js";
|
|
3
|
+
import { DeletedCoValuesEraserScheduler } from "../storage/DeletedCoValuesEraserScheduler.js";
|
|
4
|
+
|
|
5
|
+
describe("DeletedCoValuesEraserScheduler", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.clearAllTimers();
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("scheduleStartupDrain runs once after startupDelayMs (when idle)", async () => {
|
|
16
|
+
let runs = 0;
|
|
17
|
+
const scheduler = new DeletedCoValuesEraserScheduler({
|
|
18
|
+
run: async () => {
|
|
19
|
+
runs += 1;
|
|
20
|
+
return { hasMore: false };
|
|
21
|
+
},
|
|
22
|
+
opts: { throttleMs: 50, startupDelayMs: 10, followUpDelayMs: 10 },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
scheduler.scheduleStartupDrain();
|
|
26
|
+
|
|
27
|
+
expect(runs).toBe(0);
|
|
28
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
29
|
+
expect(runs).toBe(1);
|
|
30
|
+
scheduler.dispose();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("onEnqueueDeletedCoValue is throttled (multiple enqueues -> one run)", async () => {
|
|
34
|
+
let runs = 0;
|
|
35
|
+
const scheduler = new DeletedCoValuesEraserScheduler({
|
|
36
|
+
run: async () => {
|
|
37
|
+
runs += 1;
|
|
38
|
+
return { hasMore: false };
|
|
39
|
+
},
|
|
40
|
+
opts: { throttleMs: 30, startupDelayMs: 10, followUpDelayMs: 10 },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
scheduler.onEnqueueDeletedCoValue();
|
|
44
|
+
scheduler.onEnqueueDeletedCoValue();
|
|
45
|
+
scheduler.onEnqueueDeletedCoValue();
|
|
46
|
+
|
|
47
|
+
expect(runs).toBe(0);
|
|
48
|
+
await vi.advanceTimersByTimeAsync(29);
|
|
49
|
+
expect(runs).toBe(0);
|
|
50
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
51
|
+
expect(runs).toBe(1);
|
|
52
|
+
|
|
53
|
+
// Ensure no second run was scheduled by repeated enqueues in the same throttle window.
|
|
54
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
55
|
+
expect(runs).toBe(1);
|
|
56
|
+
|
|
57
|
+
scheduler.dispose();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("schedules follow-up phases while run reports hasMore=true", async () => {
|
|
61
|
+
let remaining = 3;
|
|
62
|
+
let runs = 0;
|
|
63
|
+
const scheduler = new DeletedCoValuesEraserScheduler({
|
|
64
|
+
run: async () => {
|
|
65
|
+
runs += 1;
|
|
66
|
+
remaining -= 1;
|
|
67
|
+
return { hasMore: remaining > 0 };
|
|
68
|
+
},
|
|
69
|
+
opts: { throttleMs: 10, startupDelayMs: 10, followUpDelayMs: 10 },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
scheduler.onEnqueueDeletedCoValue();
|
|
73
|
+
|
|
74
|
+
await vi.runAllTimersAsync();
|
|
75
|
+
expect(runs).toBe(3);
|
|
76
|
+
scheduler.dispose();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("never runs run concurrently (re-entrancy guard via internal state machine)", async () => {
|
|
80
|
+
let concurrent = 0;
|
|
81
|
+
let maxConcurrent = 0;
|
|
82
|
+
let remaining = 2;
|
|
83
|
+
|
|
84
|
+
const scheduler = new DeletedCoValuesEraserScheduler({
|
|
85
|
+
run: async () => {
|
|
86
|
+
concurrent += 1;
|
|
87
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
88
|
+
|
|
89
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 30));
|
|
90
|
+
remaining -= 1;
|
|
91
|
+
|
|
92
|
+
concurrent -= 1;
|
|
93
|
+
return { hasMore: remaining > 0 };
|
|
94
|
+
},
|
|
95
|
+
opts: { throttleMs: 10, startupDelayMs: 10, followUpDelayMs: 10 },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
scheduler.onEnqueueDeletedCoValue();
|
|
99
|
+
await vi.advanceTimersByTimeAsync(10); // start first run
|
|
100
|
+
|
|
101
|
+
// Even if we spam enqueues while active, they should be ignored.
|
|
102
|
+
scheduler.onEnqueueDeletedCoValue();
|
|
103
|
+
scheduler.onEnqueueDeletedCoValue();
|
|
104
|
+
|
|
105
|
+
await vi.runAllTimersAsync();
|
|
106
|
+
expect(remaining).toBe(0);
|
|
107
|
+
expect(maxConcurrent).toBe(1);
|
|
108
|
+
|
|
109
|
+
scheduler.dispose();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("ignores enqueues while not idle, but schedules again once idle", async () => {
|
|
113
|
+
let runs = 0;
|
|
114
|
+
const scheduler = new DeletedCoValuesEraserScheduler({
|
|
115
|
+
run: async () => {
|
|
116
|
+
runs += 1;
|
|
117
|
+
return { hasMore: false };
|
|
118
|
+
},
|
|
119
|
+
opts: { throttleMs: 30, startupDelayMs: 10, followUpDelayMs: 10 },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
scheduler.onEnqueueDeletedCoValue(); // schedules first run
|
|
123
|
+
await vi.advanceTimersByTimeAsync(5);
|
|
124
|
+
scheduler.onEnqueueDeletedCoValue(); // should be ignored (not idle)
|
|
125
|
+
|
|
126
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
127
|
+
expect(runs).toBe(1);
|
|
128
|
+
|
|
129
|
+
// Now idle again; next enqueue should schedule another run.
|
|
130
|
+
scheduler.onEnqueueDeletedCoValue();
|
|
131
|
+
await vi.advanceTimersByTimeAsync(30);
|
|
132
|
+
expect(runs).toBe(2);
|
|
133
|
+
|
|
134
|
+
scheduler.dispose();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("dispose cancels any scheduled run", async () => {
|
|
138
|
+
let runs = 0;
|
|
139
|
+
const scheduler = new DeletedCoValuesEraserScheduler({
|
|
140
|
+
run: async () => {
|
|
141
|
+
runs += 1;
|
|
142
|
+
return { hasMore: false };
|
|
143
|
+
},
|
|
144
|
+
opts: { throttleMs: 30, startupDelayMs: 10, followUpDelayMs: 10 },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
scheduler.onEnqueueDeletedCoValue();
|
|
148
|
+
scheduler.dispose();
|
|
149
|
+
|
|
150
|
+
await vi.advanceTimersByTimeAsync(60);
|
|
151
|
+
expect(runs).toBe(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("recovers when run throws (logs error and returns to idle so it can run again)", async () => {
|
|
155
|
+
const err = new Error("boom");
|
|
156
|
+
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
|
|
157
|
+
|
|
158
|
+
let runs = 0;
|
|
159
|
+
const scheduler = new DeletedCoValuesEraserScheduler({
|
|
160
|
+
run: async () => {
|
|
161
|
+
runs += 1;
|
|
162
|
+
if (runs === 1) throw err;
|
|
163
|
+
return { hasMore: false };
|
|
164
|
+
},
|
|
165
|
+
opts: { throttleMs: 10, startupDelayMs: 10, followUpDelayMs: 10 },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
scheduler.onEnqueueDeletedCoValue();
|
|
169
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
170
|
+
expect(runs).toBe(1);
|
|
171
|
+
|
|
172
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
173
|
+
"Error running deleted co values eraser scheduler",
|
|
174
|
+
expect.objectContaining({ err }),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// If the scheduler didn't reset back to idle after the error, this enqueue would be ignored.
|
|
178
|
+
scheduler.onEnqueueDeletedCoValue();
|
|
179
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
180
|
+
expect(runs).toBe(2);
|
|
181
|
+
|
|
182
|
+
scheduler.dispose();
|
|
183
|
+
errorSpy.mockRestore();
|
|
184
|
+
});
|
|
185
|
+
});
|