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.
Files changed (159) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/dist/CojsonMessageChannel/CojsonMessageChannel.d.ts +42 -0
  4. package/dist/CojsonMessageChannel/CojsonMessageChannel.d.ts.map +1 -0
  5. package/dist/CojsonMessageChannel/CojsonMessageChannel.js +261 -0
  6. package/dist/CojsonMessageChannel/CojsonMessageChannel.js.map +1 -0
  7. package/dist/CojsonMessageChannel/MessagePortOutgoingChannel.d.ts +18 -0
  8. package/dist/CojsonMessageChannel/MessagePortOutgoingChannel.d.ts.map +1 -0
  9. package/dist/CojsonMessageChannel/MessagePortOutgoingChannel.js +37 -0
  10. package/dist/CojsonMessageChannel/MessagePortOutgoingChannel.js.map +1 -0
  11. package/dist/CojsonMessageChannel/index.d.ts +3 -0
  12. package/dist/CojsonMessageChannel/index.d.ts.map +1 -0
  13. package/dist/CojsonMessageChannel/index.js +2 -0
  14. package/dist/CojsonMessageChannel/index.js.map +1 -0
  15. package/dist/CojsonMessageChannel/types.d.ts +149 -0
  16. package/dist/CojsonMessageChannel/types.d.ts.map +1 -0
  17. package/dist/CojsonMessageChannel/types.js +36 -0
  18. package/dist/CojsonMessageChannel/types.js.map +1 -0
  19. package/dist/GarbageCollector.d.ts +4 -2
  20. package/dist/GarbageCollector.d.ts.map +1 -1
  21. package/dist/GarbageCollector.js +5 -3
  22. package/dist/GarbageCollector.js.map +1 -1
  23. package/dist/SyncStateManager.d.ts +3 -3
  24. package/dist/SyncStateManager.d.ts.map +1 -1
  25. package/dist/SyncStateManager.js +4 -4
  26. package/dist/SyncStateManager.js.map +1 -1
  27. package/dist/coValueCore/coValueCore.d.ts +28 -1
  28. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  29. package/dist/coValueCore/coValueCore.js +50 -5
  30. package/dist/coValueCore/coValueCore.js.map +1 -1
  31. package/dist/coValues/account.d.ts.map +1 -1
  32. package/dist/coValues/account.js +10 -10
  33. package/dist/coValues/account.js.map +1 -1
  34. package/dist/exports.d.ts +1 -0
  35. package/dist/exports.d.ts.map +1 -1
  36. package/dist/exports.js +1 -0
  37. package/dist/exports.js.map +1 -1
  38. package/dist/ids.d.ts +1 -1
  39. package/dist/ids.d.ts.map +1 -1
  40. package/dist/ids.js.map +1 -1
  41. package/dist/knownState.d.ts +5 -0
  42. package/dist/knownState.d.ts.map +1 -1
  43. package/dist/knownState.js +15 -0
  44. package/dist/knownState.js.map +1 -1
  45. package/dist/localNode.d.ts +1 -3
  46. package/dist/localNode.d.ts.map +1 -1
  47. package/dist/localNode.js +11 -4
  48. package/dist/localNode.js.map +1 -1
  49. package/dist/storage/knownState.d.ts +5 -0
  50. package/dist/storage/knownState.d.ts.map +1 -1
  51. package/dist/storage/knownState.js +11 -0
  52. package/dist/storage/knownState.js.map +1 -1
  53. package/dist/storage/sqlite/client.d.ts +2 -0
  54. package/dist/storage/sqlite/client.d.ts.map +1 -1
  55. package/dist/storage/sqlite/client.js +18 -0
  56. package/dist/storage/sqlite/client.js.map +1 -1
  57. package/dist/storage/sqliteAsync/client.d.ts +2 -0
  58. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  59. package/dist/storage/sqliteAsync/client.js +20 -0
  60. package/dist/storage/sqliteAsync/client.js.map +1 -1
  61. package/dist/storage/storageAsync.d.ts +10 -3
  62. package/dist/storage/storageAsync.d.ts.map +1 -1
  63. package/dist/storage/storageAsync.js +52 -3
  64. package/dist/storage/storageAsync.js.map +1 -1
  65. package/dist/storage/storageSync.d.ts +9 -3
  66. package/dist/storage/storageSync.d.ts.map +1 -1
  67. package/dist/storage/storageSync.js +27 -3
  68. package/dist/storage/storageSync.js.map +1 -1
  69. package/dist/storage/types.d.ts +23 -0
  70. package/dist/storage/types.d.ts.map +1 -1
  71. package/dist/sync.d.ts +23 -0
  72. package/dist/sync.d.ts.map +1 -1
  73. package/dist/sync.js +136 -45
  74. package/dist/sync.js.map +1 -1
  75. package/dist/tests/CojsonMessageChannel.test.d.ts +2 -0
  76. package/dist/tests/CojsonMessageChannel.test.d.ts.map +1 -0
  77. package/dist/tests/CojsonMessageChannel.test.js +236 -0
  78. package/dist/tests/CojsonMessageChannel.test.js.map +1 -0
  79. package/dist/tests/GarbageCollector.test.js +87 -13
  80. package/dist/tests/GarbageCollector.test.js.map +1 -1
  81. package/dist/tests/StorageApiAsync.test.js +124 -1
  82. package/dist/tests/StorageApiAsync.test.js.map +1 -1
  83. package/dist/tests/StorageApiSync.test.js +123 -0
  84. package/dist/tests/StorageApiSync.test.js.map +1 -1
  85. package/dist/tests/SyncManager.processQueues.test.js +1 -1
  86. package/dist/tests/SyncManager.processQueues.test.js.map +1 -1
  87. package/dist/tests/SyncStateManager.test.js +1 -1
  88. package/dist/tests/SyncStateManager.test.js.map +1 -1
  89. package/dist/tests/coPlainText.test.js +1 -1
  90. package/dist/tests/coPlainText.test.js.map +1 -1
  91. package/dist/tests/coValueCore.loadFromStorage.test.js +2 -0
  92. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  93. package/dist/tests/knownState.lazyLoading.test.d.ts +2 -0
  94. package/dist/tests/knownState.lazyLoading.test.d.ts.map +1 -0
  95. package/dist/tests/knownState.lazyLoading.test.js +167 -0
  96. package/dist/tests/knownState.lazyLoading.test.js.map +1 -0
  97. package/dist/tests/messagesTestUtils.d.ts +5 -2
  98. package/dist/tests/messagesTestUtils.d.ts.map +1 -1
  99. package/dist/tests/messagesTestUtils.js +4 -0
  100. package/dist/tests/messagesTestUtils.js.map +1 -1
  101. package/dist/tests/sync.garbageCollection.test.js +56 -32
  102. package/dist/tests/sync.garbageCollection.test.js.map +1 -1
  103. package/dist/tests/sync.load.test.js +387 -1
  104. package/dist/tests/sync.load.test.js.map +1 -1
  105. package/dist/tests/sync.mesh.test.js +5 -5
  106. package/dist/tests/sync.mesh.test.js.map +1 -1
  107. package/dist/tests/sync.peerReconciliation.test.js +3 -3
  108. package/dist/tests/sync.peerReconciliation.test.js.map +1 -1
  109. package/dist/tests/sync.storage.test.js +9 -9
  110. package/dist/tests/sync.storage.test.js.map +1 -1
  111. package/dist/tests/sync.storageAsync.test.js +7 -7
  112. package/dist/tests/sync.storageAsync.test.js.map +1 -1
  113. package/dist/tests/sync.tracking.test.js +35 -4
  114. package/dist/tests/sync.tracking.test.js.map +1 -1
  115. package/dist/tests/testStorage.js +38 -2
  116. package/dist/tests/testStorage.js.map +1 -1
  117. package/dist/tests/testUtils.d.ts +38 -4
  118. package/dist/tests/testUtils.d.ts.map +1 -1
  119. package/dist/tests/testUtils.js +68 -7
  120. package/dist/tests/testUtils.js.map +1 -1
  121. package/package.json +4 -4
  122. package/src/CojsonMessageChannel/CojsonMessageChannel.ts +332 -0
  123. package/src/CojsonMessageChannel/MessagePortOutgoingChannel.ts +52 -0
  124. package/src/CojsonMessageChannel/index.ts +9 -0
  125. package/src/CojsonMessageChannel/types.ts +200 -0
  126. package/src/GarbageCollector.ts +5 -5
  127. package/src/SyncStateManager.ts +6 -6
  128. package/src/coValueCore/coValueCore.ts +56 -7
  129. package/src/coValues/account.ts +12 -14
  130. package/src/exports.ts +1 -0
  131. package/src/ids.ts +1 -1
  132. package/src/knownState.ts +24 -0
  133. package/src/localNode.ts +12 -7
  134. package/src/storage/knownState.ts +12 -0
  135. package/src/storage/sqlite/client.ts +31 -0
  136. package/src/storage/sqliteAsync/client.ts +35 -0
  137. package/src/storage/storageAsync.ts +66 -4
  138. package/src/storage/storageSync.ts +37 -4
  139. package/src/storage/types.ts +32 -0
  140. package/src/sync.ts +159 -46
  141. package/src/tests/CojsonMessageChannel.test.ts +306 -0
  142. package/src/tests/GarbageCollector.test.ts +114 -13
  143. package/src/tests/StorageApiAsync.test.ts +186 -1
  144. package/src/tests/StorageApiSync.test.ts +181 -0
  145. package/src/tests/SyncManager.processQueues.test.ts +1 -1
  146. package/src/tests/SyncStateManager.test.ts +1 -1
  147. package/src/tests/coPlainText.test.ts +1 -1
  148. package/src/tests/coValueCore.loadFromStorage.test.ts +5 -0
  149. package/src/tests/knownState.lazyLoading.test.ts +219 -0
  150. package/src/tests/messagesTestUtils.ts +10 -3
  151. package/src/tests/sync.garbageCollection.test.ts +69 -36
  152. package/src/tests/sync.load.test.ts +482 -2
  153. package/src/tests/sync.mesh.test.ts +5 -5
  154. package/src/tests/sync.peerReconciliation.test.ts +3 -3
  155. package/src/tests/sync.storage.test.ts +9 -9
  156. package/src/tests/sync.storageAsync.test.ts +7 -7
  157. package/src/tests/sync.tracking.test.ts +54 -4
  158. package/src/tests/testStorage.ts +40 -2
  159. 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.role === "server" && peer.persistent) {
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.id, id, knownState.value());
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 peers = this.getServerPeers(msg.id, peer.id);
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
- coValue.load(peers);
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
- peer.trackToldKnownState(msg.id);
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
- id: msg.id,
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
- handleLoadResult();
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 do have information about the header.
622
- // If it's false in this point it means that the coValue is unavailable on the other peer.
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
- // We check if the covalue was in memory and has been garbage collected
701
- // In that case we should have it tracked in the storage
702
- const storageKnownState = this.local.storage?.getKnownState(msg.id);
703
-
704
- if (storageKnownState?.header) {
705
- // If the CoValue has been garbage collected, we load it from the storage before handling the new content
706
- coValue.loadFromStorage((found) => {
707
- if (found) {
708
- this.handleNewContent(msg, from);
709
- } else {
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
+ });