cojson 0.19.20 → 0.19.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +13 -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/coValueCore/coValueCore.d.ts +28 -1
- package/dist/coValueCore/coValueCore.d.ts.map +1 -1
- package/dist/coValueCore/coValueCore.js +50 -5
- package/dist/coValueCore/coValueCore.js.map +1 -1
- package/dist/coValues/account.d.ts.map +1 -1
- package/dist/coValues/account.js +10 -10
- package/dist/coValues/account.js.map +1 -1
- package/dist/exports.d.ts +1 -0
- package/dist/exports.d.ts.map +1 -1
- package/dist/exports.js +1 -0
- package/dist/exports.js.map +1 -1
- package/dist/ids.d.ts +1 -1
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js.map +1 -1
- package/dist/knownState.d.ts +5 -0
- package/dist/knownState.d.ts.map +1 -1
- package/dist/knownState.js +15 -0
- package/dist/knownState.js.map +1 -1
- package/dist/localNode.d.ts +1 -3
- package/dist/localNode.d.ts.map +1 -1
- package/dist/localNode.js +11 -4
- package/dist/localNode.js.map +1 -1
- package/dist/storage/knownState.d.ts +5 -0
- package/dist/storage/knownState.d.ts.map +1 -1
- package/dist/storage/knownState.js +11 -0
- package/dist/storage/knownState.js.map +1 -1
- package/dist/storage/sqlite/client.d.ts +2 -0
- package/dist/storage/sqlite/client.d.ts.map +1 -1
- package/dist/storage/sqlite/client.js +18 -0
- package/dist/storage/sqlite/client.js.map +1 -1
- package/dist/storage/sqliteAsync/client.d.ts +2 -0
- package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
- package/dist/storage/sqliteAsync/client.js +20 -0
- package/dist/storage/sqliteAsync/client.js.map +1 -1
- package/dist/storage/storageAsync.d.ts +10 -3
- package/dist/storage/storageAsync.d.ts.map +1 -1
- package/dist/storage/storageAsync.js +52 -3
- package/dist/storage/storageAsync.js.map +1 -1
- package/dist/storage/storageSync.d.ts +9 -3
- package/dist/storage/storageSync.d.ts.map +1 -1
- package/dist/storage/storageSync.js +27 -3
- package/dist/storage/storageSync.js.map +1 -1
- package/dist/storage/types.d.ts +23 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/sync.d.ts +23 -0
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +136 -45
- 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/GarbageCollector.test.js +87 -13
- package/dist/tests/GarbageCollector.test.js.map +1 -1
- package/dist/tests/StorageApiAsync.test.js +124 -1
- package/dist/tests/StorageApiAsync.test.js.map +1 -1
- package/dist/tests/StorageApiSync.test.js +123 -0
- 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/coPlainText.test.js +1 -1
- package/dist/tests/coPlainText.test.js.map +1 -1
- package/dist/tests/coValueCore.loadFromStorage.test.js +2 -0
- package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
- package/dist/tests/knownState.lazyLoading.test.d.ts +2 -0
- package/dist/tests/knownState.lazyLoading.test.d.ts.map +1 -0
- package/dist/tests/knownState.lazyLoading.test.js +167 -0
- package/dist/tests/knownState.lazyLoading.test.js.map +1 -0
- package/dist/tests/messagesTestUtils.d.ts +5 -2
- package/dist/tests/messagesTestUtils.d.ts.map +1 -1
- package/dist/tests/messagesTestUtils.js +4 -0
- package/dist/tests/messagesTestUtils.js.map +1 -1
- 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 +387 -1
- package/dist/tests/sync.load.test.js.map +1 -1
- package/dist/tests/sync.mesh.test.js +5 -5
- 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 +9 -9
- 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.tracking.test.js +35 -4
- package/dist/tests/sync.tracking.test.js.map +1 -1
- package/dist/tests/testStorage.js +38 -2
- package/dist/tests/testStorage.js.map +1 -1
- package/dist/tests/testUtils.d.ts +38 -4
- package/dist/tests/testUtils.d.ts.map +1 -1
- package/dist/tests/testUtils.js +68 -7
- package/dist/tests/testUtils.js.map +1 -1
- package/package.json +4 -4
- 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/coValueCore/coValueCore.ts +56 -7
- package/src/coValues/account.ts +12 -14
- package/src/exports.ts +1 -0
- package/src/ids.ts +1 -1
- package/src/knownState.ts +24 -0
- package/src/localNode.ts +12 -7
- package/src/storage/knownState.ts +12 -0
- package/src/storage/sqlite/client.ts +31 -0
- package/src/storage/sqliteAsync/client.ts +35 -0
- package/src/storage/storageAsync.ts +66 -4
- package/src/storage/storageSync.ts +37 -4
- package/src/storage/types.ts +32 -0
- package/src/sync.ts +159 -46
- package/src/tests/CojsonMessageChannel.test.ts +306 -0
- package/src/tests/GarbageCollector.test.ts +114 -13
- package/src/tests/StorageApiAsync.test.ts +186 -1
- package/src/tests/StorageApiSync.test.ts +181 -0
- package/src/tests/SyncManager.processQueues.test.ts +1 -1
- package/src/tests/SyncStateManager.test.ts +1 -1
- package/src/tests/coPlainText.test.ts +1 -1
- package/src/tests/coValueCore.loadFromStorage.test.ts +5 -0
- package/src/tests/knownState.lazyLoading.test.ts +219 -0
- package/src/tests/messagesTestUtils.ts +10 -3
- package/src/tests/sync.garbageCollection.test.ts +69 -36
- package/src/tests/sync.load.test.ts +482 -2
- package/src/tests/sync.mesh.test.ts +5 -5
- package/src/tests/sync.peerReconciliation.test.ts +3 -3
- package/src/tests/sync.storage.test.ts +9 -9
- package/src/tests/sync.storageAsync.test.ts +7 -7
- package/src/tests/sync.tracking.test.ts +54 -4
- package/src/tests/testStorage.ts +40 -2
- package/src/tests/testUtils.ts +99 -8
package/src/sync.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
CoValueKnownState,
|
|
26
26
|
knownStateFrom,
|
|
27
27
|
KnownStateSessions,
|
|
28
|
+
peerHasAllContent,
|
|
28
29
|
} from "./knownState.js";
|
|
29
30
|
import { StorageAPI } from "./storage/index.js";
|
|
30
31
|
|
|
@@ -101,6 +102,10 @@ export interface Peer {
|
|
|
101
102
|
persistent?: boolean;
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
function isPersistentServerPeer(peer: Peer | PeerState): boolean {
|
|
106
|
+
return peer.role === "server" && (peer.persistent ?? false);
|
|
107
|
+
}
|
|
108
|
+
|
|
104
109
|
export type ServerPeerSelector = (
|
|
105
110
|
id: RawCoID,
|
|
106
111
|
serverPeers: PeerState[],
|
|
@@ -353,7 +358,7 @@ export class SyncManager {
|
|
|
353
358
|
}
|
|
354
359
|
|
|
355
360
|
startPeerReconciliation(peer: PeerState) {
|
|
356
|
-
if (peer
|
|
361
|
+
if (isPersistentServerPeer(peer)) {
|
|
357
362
|
// Resume syncing unsynced CoValues asynchronously
|
|
358
363
|
this.resumeUnsyncedCoValues().catch((error) => {
|
|
359
364
|
logger.warn("Failed to resume unsynced CoValues:", error);
|
|
@@ -524,7 +529,7 @@ export class SyncManager {
|
|
|
524
529
|
|
|
525
530
|
const unsubscribeFromKnownStatesUpdates =
|
|
526
531
|
peerState.subscribeToKnownStatesUpdates((id, knownState) => {
|
|
527
|
-
this.syncState.triggerUpdate(peer
|
|
532
|
+
this.syncState.triggerUpdate(peer, id, knownState.value());
|
|
528
533
|
});
|
|
529
534
|
|
|
530
535
|
if (!skipReconciliation && peerState.role === "server") {
|
|
@@ -583,33 +588,125 @@ export class SyncManager {
|
|
|
583
588
|
peer.setKnownState(msg.id, knownStateFrom(msg));
|
|
584
589
|
const coValue = this.local.getCoValue(msg.id);
|
|
585
590
|
|
|
591
|
+
// Fast path: CoValue is already in memory
|
|
586
592
|
if (coValue.isAvailable()) {
|
|
587
593
|
this.sendNewContent(msg.id, peer);
|
|
588
594
|
return;
|
|
589
595
|
}
|
|
590
596
|
|
|
591
|
-
const
|
|
597
|
+
const peerKnownState = peer.getOptimisticKnownState(msg.id);
|
|
598
|
+
|
|
599
|
+
// Fast path: Peer has no content at all - skip lazy load check, just load directly
|
|
600
|
+
if (!peerKnownState?.header) {
|
|
601
|
+
this.loadFromStorageAndRespond(msg.id, peer, coValue);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Check storage knownState before doing full load (lazy load optimization)
|
|
606
|
+
coValue.getKnownStateFromStorage((storageKnownState) => {
|
|
607
|
+
// Race condition: CoValue might have been loaded while we were waiting for storage
|
|
608
|
+
if (coValue.isAvailable()) {
|
|
609
|
+
this.sendNewContent(msg.id, peer);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (!storageKnownState) {
|
|
614
|
+
// Not in storage, try loading from peers
|
|
615
|
+
this.loadFromPeersAndRespond(msg.id, peer, coValue);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Check if peer already has all content
|
|
620
|
+
if (peerHasAllContent(storageKnownState, peerKnownState)) {
|
|
621
|
+
// Peer already has everything - reply with known message, no full load needed
|
|
622
|
+
peer.trackToldKnownState(msg.id);
|
|
623
|
+
this.trySendToPeer(peer, {
|
|
624
|
+
action: "known",
|
|
625
|
+
...storageKnownState,
|
|
626
|
+
});
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
592
629
|
|
|
593
|
-
|
|
630
|
+
// Peer needs content - do full load from storage
|
|
631
|
+
this.loadFromStorageAndRespond(msg.id, peer, coValue);
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Helper to load from storage and respond appropriately.
|
|
637
|
+
* Falls back to peers if not found in storage.
|
|
638
|
+
*/
|
|
639
|
+
private loadFromStorageAndRespond(
|
|
640
|
+
id: RawCoID,
|
|
641
|
+
peer: PeerState,
|
|
642
|
+
coValue: CoValueCore,
|
|
643
|
+
) {
|
|
644
|
+
coValue.loadFromStorage((found) => {
|
|
645
|
+
if (found && coValue.isAvailable()) {
|
|
646
|
+
this.sendNewContent(id, peer);
|
|
647
|
+
} else {
|
|
648
|
+
this.loadFromPeersAndRespond(id, peer, coValue);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Helper to load from peers and respond appropriately.
|
|
655
|
+
*/
|
|
656
|
+
private loadFromPeersAndRespond(
|
|
657
|
+
id: RawCoID,
|
|
658
|
+
peer: PeerState,
|
|
659
|
+
coValue: CoValueCore,
|
|
660
|
+
) {
|
|
661
|
+
const peers = this.getServerPeers(id, peer.id);
|
|
662
|
+
coValue.loadFromPeers(peers);
|
|
594
663
|
|
|
595
664
|
const handleLoadResult = () => {
|
|
596
665
|
if (coValue.isAvailable()) {
|
|
666
|
+
this.sendNewContent(id, peer);
|
|
597
667
|
return;
|
|
598
668
|
}
|
|
669
|
+
this.handleLoadNotFound(id, peer);
|
|
670
|
+
};
|
|
599
671
|
|
|
600
|
-
|
|
672
|
+
if (peers.length > 0) {
|
|
673
|
+
coValue.waitForAvailableOrUnavailable().then(handleLoadResult);
|
|
674
|
+
} else {
|
|
675
|
+
handleLoadResult();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Handle case when CoValue is not found.
|
|
681
|
+
*/
|
|
682
|
+
private handleLoadNotFound(id: RawCoID, peer: PeerState) {
|
|
683
|
+
peer.trackToldKnownState(id);
|
|
684
|
+
this.trySendToPeer(peer, {
|
|
685
|
+
action: "known",
|
|
686
|
+
id,
|
|
687
|
+
header: false,
|
|
688
|
+
sessions: {},
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Request full content from a peer when we don't have the CoValue.
|
|
694
|
+
*/
|
|
695
|
+
private requestFullContent(id: RawCoID, peer: PeerState | undefined) {
|
|
696
|
+
if (peer) {
|
|
601
697
|
this.trySendToPeer(peer, {
|
|
602
698
|
action: "known",
|
|
603
|
-
|
|
699
|
+
isCorrection: true,
|
|
700
|
+
id,
|
|
604
701
|
header: false,
|
|
605
702
|
sessions: {},
|
|
606
703
|
});
|
|
607
|
-
};
|
|
608
|
-
|
|
609
|
-
if (peers.length > 0 || this.local.storage) {
|
|
610
|
-
coValue.waitForAvailableOrUnavailable().then(handleLoadResult);
|
|
611
704
|
} else {
|
|
612
|
-
|
|
705
|
+
// The wrong assumption has been made by storage or import, we don't have a recovery mechanism
|
|
706
|
+
// Should never happen
|
|
707
|
+
logger.error("Received new content with no header on a missing CoValue", {
|
|
708
|
+
id,
|
|
709
|
+
});
|
|
613
710
|
}
|
|
614
711
|
}
|
|
615
712
|
|
|
@@ -618,8 +715,8 @@ export class SyncManager {
|
|
|
618
715
|
|
|
619
716
|
peer.combineWith(msg.id, knownStateFrom(msg));
|
|
620
717
|
|
|
621
|
-
// The header is a boolean value that tells us if the other peer
|
|
622
|
-
// If it's false
|
|
718
|
+
// The header is a boolean value that tells us if the other peer has information about the header.
|
|
719
|
+
// If it's false at this point it means that the coValue is unavailable on the other peer.
|
|
623
720
|
const availableOnPeer = peer.getOptimisticKnownState(msg.id)?.header;
|
|
624
721
|
|
|
625
722
|
if (!availableOnPeer) {
|
|
@@ -694,46 +791,37 @@ export class SyncManager {
|
|
|
694
791
|
*/
|
|
695
792
|
if (!coValue.hasVerifiedContent()) {
|
|
696
793
|
/**
|
|
697
|
-
* The peer has assumed we already have the CoValue
|
|
794
|
+
* The peer/import has assumed we already have the CoValue
|
|
698
795
|
*/
|
|
699
796
|
if (!msg.header) {
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
logger.error("Known CoValue not found in storage", {
|
|
711
|
-
id: msg.id,
|
|
712
|
-
});
|
|
713
|
-
}
|
|
714
|
-
});
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// The peer assumption is not correct, so we ask for the full CoValue
|
|
719
|
-
if (peer) {
|
|
720
|
-
this.trySendToPeer(peer, {
|
|
721
|
-
action: "known",
|
|
722
|
-
isCorrection: true,
|
|
723
|
-
id: msg.id,
|
|
724
|
-
header: false,
|
|
725
|
-
sessions: {},
|
|
726
|
-
});
|
|
727
|
-
} else {
|
|
728
|
-
// The wrong assumption has been made by storage or import, we don't have a recovery mechanism
|
|
729
|
-
// Should never happen
|
|
730
|
-
logger.error(
|
|
731
|
-
"Received new content with no header on a missing CoValue",
|
|
797
|
+
// Content from storage without header - this can happen if:
|
|
798
|
+
// 1. Storage is streaming a large CoValue in chunks
|
|
799
|
+
// 2. Server is under heavy load, so a chunk isn't processed for a long time
|
|
800
|
+
// 3. GC cleanup unmounts the CoValue while streaming is in progress
|
|
801
|
+
// 4. The chunk is finally processed, but the CoValue is no longer available
|
|
802
|
+
// TODO: Fix this by either not unmounting CoValues with active streaming,
|
|
803
|
+
// or by cleaning up the streaming queue on unmount
|
|
804
|
+
if (from === "storage") {
|
|
805
|
+
logger.warn(
|
|
806
|
+
"Received content from storage without header - CoValue may have been garbage collected mid-stream",
|
|
732
807
|
{
|
|
733
808
|
id: msg.id,
|
|
809
|
+
from,
|
|
734
810
|
},
|
|
735
811
|
);
|
|
812
|
+
return;
|
|
736
813
|
}
|
|
814
|
+
|
|
815
|
+
// Try to load from storage - the CoValue might have been garbage collected from memory
|
|
816
|
+
coValue.loadFromStorage((found) => {
|
|
817
|
+
if (found) {
|
|
818
|
+
// CoValue was in storage, process the new content
|
|
819
|
+
this.handleNewContent(msg, from);
|
|
820
|
+
} else {
|
|
821
|
+
// CoValue not in storage, ask peer for full content
|
|
822
|
+
this.requestFullContent(msg.id, peer);
|
|
823
|
+
}
|
|
824
|
+
});
|
|
737
825
|
return;
|
|
738
826
|
}
|
|
739
827
|
|
|
@@ -992,6 +1080,18 @@ export class SyncManager {
|
|
|
992
1080
|
const isSyncRequired = this.local.syncWhen !== "never";
|
|
993
1081
|
if (isSyncRequired && peers.length === 0) {
|
|
994
1082
|
this.unsyncedTracker.add(coValueId);
|
|
1083
|
+
|
|
1084
|
+
// Mark CoValue as synced once a persistent server peer is added and
|
|
1085
|
+
// the CoValue is synced
|
|
1086
|
+
const unsubscribe = this.syncState.subscribeToCoValueUpdates(
|
|
1087
|
+
coValueId,
|
|
1088
|
+
(peer, _knownState, syncState) => {
|
|
1089
|
+
if (isPersistentServerPeer(peer) && syncState.uploaded) {
|
|
1090
|
+
this.unsyncedTracker.remove(coValueId);
|
|
1091
|
+
unsubscribe();
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
);
|
|
995
1095
|
return;
|
|
996
1096
|
}
|
|
997
1097
|
|
|
@@ -1044,6 +1144,19 @@ export class SyncManager {
|
|
|
1044
1144
|
});
|
|
1045
1145
|
}
|
|
1046
1146
|
|
|
1147
|
+
/**
|
|
1148
|
+
* Returns true if the local CoValue changes have been synced to all persistent server peers.
|
|
1149
|
+
*
|
|
1150
|
+
* Used during garbage collection to determine if the coValue is pending sync.
|
|
1151
|
+
*/
|
|
1152
|
+
isSyncedToServerPeers(id: RawCoID): boolean {
|
|
1153
|
+
// If there are currently no server peers, go ahead with GC.
|
|
1154
|
+
// The CoValue will be reloaded into memory and synced when a peer is added.
|
|
1155
|
+
return this.getPersistentServerPeers(id).every((peer) =>
|
|
1156
|
+
this.syncState.isSynced(peer, id),
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1047
1160
|
waitForSyncWithPeer(peerId: PeerID, id: RawCoID, timeout: number) {
|
|
1048
1161
|
const peerState = this.peers[peerId];
|
|
1049
1162
|
|
|
@@ -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
|
+
});
|