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.
Files changed (254) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +67 -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/coValueContentMessage.d.ts +0 -2
  28. package/dist/coValueContentMessage.d.ts.map +1 -1
  29. package/dist/coValueContentMessage.js +0 -8
  30. package/dist/coValueContentMessage.js.map +1 -1
  31. package/dist/coValueCore/SessionMap.d.ts +4 -2
  32. package/dist/coValueCore/SessionMap.d.ts.map +1 -1
  33. package/dist/coValueCore/SessionMap.js +30 -0
  34. package/dist/coValueCore/SessionMap.js.map +1 -1
  35. package/dist/coValueCore/coValueCore.d.ts +86 -4
  36. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  37. package/dist/coValueCore/coValueCore.js +318 -17
  38. package/dist/coValueCore/coValueCore.js.map +1 -1
  39. package/dist/coValueCore/verifiedState.d.ts +6 -1
  40. package/dist/coValueCore/verifiedState.d.ts.map +1 -1
  41. package/dist/coValueCore/verifiedState.js +9 -0
  42. package/dist/coValueCore/verifiedState.js.map +1 -1
  43. package/dist/coValues/coList.d.ts +3 -2
  44. package/dist/coValues/coList.d.ts.map +1 -1
  45. package/dist/coValues/coList.js.map +1 -1
  46. package/dist/coValues/group.d.ts.map +1 -1
  47. package/dist/coValues/group.js +3 -6
  48. package/dist/coValues/group.js.map +1 -1
  49. package/dist/config.d.ts +0 -6
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/config.js +0 -8
  52. package/dist/config.js.map +1 -1
  53. package/dist/crypto/NapiCrypto.d.ts +1 -2
  54. package/dist/crypto/NapiCrypto.d.ts.map +1 -1
  55. package/dist/crypto/NapiCrypto.js +19 -4
  56. package/dist/crypto/NapiCrypto.js.map +1 -1
  57. package/dist/crypto/RNCrypto.d.ts.map +1 -1
  58. package/dist/crypto/RNCrypto.js +19 -4
  59. package/dist/crypto/RNCrypto.js.map +1 -1
  60. package/dist/crypto/WasmCrypto.d.ts +11 -4
  61. package/dist/crypto/WasmCrypto.d.ts.map +1 -1
  62. package/dist/crypto/WasmCrypto.js +52 -10
  63. package/dist/crypto/WasmCrypto.js.map +1 -1
  64. package/dist/crypto/WasmCryptoEdge.d.ts +1 -0
  65. package/dist/crypto/WasmCryptoEdge.d.ts.map +1 -1
  66. package/dist/crypto/WasmCryptoEdge.js +4 -1
  67. package/dist/crypto/WasmCryptoEdge.js.map +1 -1
  68. package/dist/crypto/crypto.d.ts +3 -3
  69. package/dist/crypto/crypto.d.ts.map +1 -1
  70. package/dist/crypto/crypto.js +6 -1
  71. package/dist/crypto/crypto.js.map +1 -1
  72. package/dist/exports.d.ts +3 -2
  73. package/dist/exports.d.ts.map +1 -1
  74. package/dist/exports.js +3 -1
  75. package/dist/exports.js.map +1 -1
  76. package/dist/ids.d.ts +4 -1
  77. package/dist/ids.d.ts.map +1 -1
  78. package/dist/ids.js +4 -0
  79. package/dist/ids.js.map +1 -1
  80. package/dist/knownState.d.ts +2 -0
  81. package/dist/knownState.d.ts.map +1 -1
  82. package/dist/localNode.d.ts +13 -3
  83. package/dist/localNode.d.ts.map +1 -1
  84. package/dist/localNode.js +17 -2
  85. package/dist/localNode.js.map +1 -1
  86. package/dist/platformUtils.d.ts +3 -0
  87. package/dist/platformUtils.d.ts.map +1 -0
  88. package/dist/platformUtils.js +24 -0
  89. package/dist/platformUtils.js.map +1 -0
  90. package/dist/storage/DeletedCoValuesEraserScheduler.d.ts +30 -0
  91. package/dist/storage/DeletedCoValuesEraserScheduler.d.ts.map +1 -0
  92. package/dist/storage/DeletedCoValuesEraserScheduler.js +84 -0
  93. package/dist/storage/DeletedCoValuesEraserScheduler.js.map +1 -0
  94. package/dist/storage/sqlite/client.d.ts +3 -0
  95. package/dist/storage/sqlite/client.d.ts.map +1 -1
  96. package/dist/storage/sqlite/client.js +44 -0
  97. package/dist/storage/sqlite/client.js.map +1 -1
  98. package/dist/storage/sqlite/sqliteMigrations.d.ts.map +1 -1
  99. package/dist/storage/sqlite/sqliteMigrations.js +7 -0
  100. package/dist/storage/sqlite/sqliteMigrations.js.map +1 -1
  101. package/dist/storage/sqliteAsync/client.d.ts +3 -0
  102. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  103. package/dist/storage/sqliteAsync/client.js +42 -0
  104. package/dist/storage/sqliteAsync/client.js.map +1 -1
  105. package/dist/storage/storageAsync.d.ts +15 -3
  106. package/dist/storage/storageAsync.d.ts.map +1 -1
  107. package/dist/storage/storageAsync.js +60 -3
  108. package/dist/storage/storageAsync.js.map +1 -1
  109. package/dist/storage/storageSync.d.ts +14 -3
  110. package/dist/storage/storageSync.d.ts.map +1 -1
  111. package/dist/storage/storageSync.js +54 -3
  112. package/dist/storage/storageSync.js.map +1 -1
  113. package/dist/storage/types.d.ts +64 -0
  114. package/dist/storage/types.d.ts.map +1 -1
  115. package/dist/storage/types.js +12 -1
  116. package/dist/storage/types.js.map +1 -1
  117. package/dist/sync.d.ts +6 -0
  118. package/dist/sync.d.ts.map +1 -1
  119. package/dist/sync.js +69 -15
  120. package/dist/sync.js.map +1 -1
  121. package/dist/tests/CojsonMessageChannel.test.d.ts +2 -0
  122. package/dist/tests/CojsonMessageChannel.test.d.ts.map +1 -0
  123. package/dist/tests/CojsonMessageChannel.test.js +236 -0
  124. package/dist/tests/CojsonMessageChannel.test.js.map +1 -0
  125. package/dist/tests/DeletedCoValuesEraserScheduler.test.d.ts +2 -0
  126. package/dist/tests/DeletedCoValuesEraserScheduler.test.d.ts.map +1 -0
  127. package/dist/tests/DeletedCoValuesEraserScheduler.test.js +149 -0
  128. package/dist/tests/DeletedCoValuesEraserScheduler.test.js.map +1 -0
  129. package/dist/tests/GarbageCollector.test.js +91 -18
  130. package/dist/tests/GarbageCollector.test.js.map +1 -1
  131. package/dist/tests/StorageApiAsync.test.js +510 -146
  132. package/dist/tests/StorageApiAsync.test.js.map +1 -1
  133. package/dist/tests/StorageApiSync.test.js +531 -130
  134. package/dist/tests/StorageApiSync.test.js.map +1 -1
  135. package/dist/tests/SyncManager.processQueues.test.js +1 -1
  136. package/dist/tests/SyncManager.processQueues.test.js.map +1 -1
  137. package/dist/tests/SyncStateManager.test.js +1 -1
  138. package/dist/tests/SyncStateManager.test.js.map +1 -1
  139. package/dist/tests/WasmCrypto.test.js +6 -3
  140. package/dist/tests/WasmCrypto.test.js.map +1 -1
  141. package/dist/tests/coPlainText.test.js +1 -1
  142. package/dist/tests/coPlainText.test.js.map +1 -1
  143. package/dist/tests/coValueCore.loadFromStorage.test.js +4 -0
  144. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  145. package/dist/tests/coValueCore.test.js +34 -13
  146. package/dist/tests/coValueCore.test.js.map +1 -1
  147. package/dist/tests/coreWasm.test.js +127 -4
  148. package/dist/tests/coreWasm.test.js.map +1 -1
  149. package/dist/tests/crypto.test.js +89 -93
  150. package/dist/tests/crypto.test.js.map +1 -1
  151. package/dist/tests/deleteCoValue.test.d.ts +2 -0
  152. package/dist/tests/deleteCoValue.test.d.ts.map +1 -0
  153. package/dist/tests/deleteCoValue.test.js +313 -0
  154. package/dist/tests/deleteCoValue.test.js.map +1 -0
  155. package/dist/tests/group.removeMember.test.js +18 -30
  156. package/dist/tests/group.removeMember.test.js.map +1 -1
  157. package/dist/tests/knownState.lazyLoading.test.js +4 -0
  158. package/dist/tests/knownState.lazyLoading.test.js.map +1 -1
  159. package/dist/tests/sync.deleted.test.d.ts +2 -0
  160. package/dist/tests/sync.deleted.test.d.ts.map +1 -0
  161. package/dist/tests/sync.deleted.test.js +214 -0
  162. package/dist/tests/sync.deleted.test.js.map +1 -0
  163. package/dist/tests/sync.garbageCollection.test.js +56 -32
  164. package/dist/tests/sync.garbageCollection.test.js.map +1 -1
  165. package/dist/tests/sync.load.test.js +3 -5
  166. package/dist/tests/sync.load.test.js.map +1 -1
  167. package/dist/tests/sync.mesh.test.js +4 -3
  168. package/dist/tests/sync.mesh.test.js.map +1 -1
  169. package/dist/tests/sync.peerReconciliation.test.js +3 -3
  170. package/dist/tests/sync.peerReconciliation.test.js.map +1 -1
  171. package/dist/tests/sync.storage.test.js +12 -11
  172. package/dist/tests/sync.storage.test.js.map +1 -1
  173. package/dist/tests/sync.storageAsync.test.js +7 -7
  174. package/dist/tests/sync.storageAsync.test.js.map +1 -1
  175. package/dist/tests/sync.test.js +3 -2
  176. package/dist/tests/sync.test.js.map +1 -1
  177. package/dist/tests/sync.tracking.test.js +35 -4
  178. package/dist/tests/sync.tracking.test.js.map +1 -1
  179. package/dist/tests/testStorage.d.ts +3 -0
  180. package/dist/tests/testStorage.d.ts.map +1 -1
  181. package/dist/tests/testStorage.js +16 -2
  182. package/dist/tests/testStorage.js.map +1 -1
  183. package/dist/tests/testUtils.d.ts +29 -4
  184. package/dist/tests/testUtils.d.ts.map +1 -1
  185. package/dist/tests/testUtils.js +84 -9
  186. package/dist/tests/testUtils.js.map +1 -1
  187. package/package.json +6 -16
  188. package/src/CojsonMessageChannel/CojsonMessageChannel.ts +332 -0
  189. package/src/CojsonMessageChannel/MessagePortOutgoingChannel.ts +52 -0
  190. package/src/CojsonMessageChannel/index.ts +9 -0
  191. package/src/CojsonMessageChannel/types.ts +200 -0
  192. package/src/GarbageCollector.ts +5 -5
  193. package/src/SyncStateManager.ts +6 -6
  194. package/src/coValueContentMessage.ts +0 -14
  195. package/src/coValueCore/SessionMap.ts +43 -1
  196. package/src/coValueCore/coValueCore.ts +430 -15
  197. package/src/coValueCore/verifiedState.ts +26 -3
  198. package/src/coValues/coList.ts +5 -3
  199. package/src/coValues/group.ts +5 -6
  200. package/src/config.ts +0 -9
  201. package/src/crypto/NapiCrypto.ts +29 -13
  202. package/src/crypto/RNCrypto.ts +29 -11
  203. package/src/crypto/WasmCrypto.ts +67 -20
  204. package/src/crypto/WasmCryptoEdge.ts +5 -1
  205. package/src/crypto/crypto.ts +16 -4
  206. package/src/exports.ts +3 -0
  207. package/src/ids.ts +11 -1
  208. package/src/localNode.ts +18 -5
  209. package/src/platformUtils.ts +26 -0
  210. package/src/storage/DeletedCoValuesEraserScheduler.ts +124 -0
  211. package/src/storage/sqlite/client.ts +77 -0
  212. package/src/storage/sqlite/sqliteMigrations.ts +7 -0
  213. package/src/storage/sqliteAsync/client.ts +75 -0
  214. package/src/storage/storageAsync.ts +77 -4
  215. package/src/storage/storageSync.ts +73 -4
  216. package/src/storage/types.ts +75 -0
  217. package/src/sync.ts +84 -15
  218. package/src/tests/CojsonMessageChannel.test.ts +306 -0
  219. package/src/tests/DeletedCoValuesEraserScheduler.test.ts +185 -0
  220. package/src/tests/GarbageCollector.test.ts +119 -22
  221. package/src/tests/StorageApiAsync.test.ts +615 -156
  222. package/src/tests/StorageApiSync.test.ts +623 -137
  223. package/src/tests/SyncManager.processQueues.test.ts +1 -1
  224. package/src/tests/SyncStateManager.test.ts +1 -1
  225. package/src/tests/WasmCrypto.test.ts +8 -3
  226. package/src/tests/coPlainText.test.ts +1 -1
  227. package/src/tests/coValueCore.loadFromStorage.test.ts +8 -0
  228. package/src/tests/coValueCore.test.ts +49 -14
  229. package/src/tests/coreWasm.test.ts +319 -10
  230. package/src/tests/crypto.test.ts +141 -150
  231. package/src/tests/deleteCoValue.test.ts +528 -0
  232. package/src/tests/group.removeMember.test.ts +35 -35
  233. package/src/tests/knownState.lazyLoading.test.ts +8 -0
  234. package/src/tests/sync.deleted.test.ts +294 -0
  235. package/src/tests/sync.garbageCollection.test.ts +69 -36
  236. package/src/tests/sync.load.test.ts +3 -5
  237. package/src/tests/sync.mesh.test.ts +6 -3
  238. package/src/tests/sync.peerReconciliation.test.ts +3 -3
  239. package/src/tests/sync.storage.test.ts +14 -11
  240. package/src/tests/sync.storageAsync.test.ts +7 -7
  241. package/src/tests/sync.test.ts +5 -2
  242. package/src/tests/sync.tracking.test.ts +54 -4
  243. package/src/tests/testStorage.ts +30 -3
  244. package/src/tests/testUtils.ts +113 -15
  245. package/dist/crypto/PureJSCrypto.d.ts +0 -77
  246. package/dist/crypto/PureJSCrypto.d.ts.map +0 -1
  247. package/dist/crypto/PureJSCrypto.js +0 -236
  248. package/dist/crypto/PureJSCrypto.js.map +0 -1
  249. package/dist/tests/PureJSCrypto.test.d.ts +0 -2
  250. package/dist/tests/PureJSCrypto.test.d.ts.map +0 -1
  251. package/dist/tests/PureJSCrypto.test.js +0 -145
  252. package/dist/tests/PureJSCrypto.test.js.map +0 -1
  253. package/src/crypto/PureJSCrypto.ts +0 -429
  254. 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
- this.trySendToPeer(peer, {
265
- action: "known",
266
- ...coValue.knownStateWithStreaming(),
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.role === "server" && peer.persistent) {
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.id, id, knownState.value());
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 do have information about the header.
715
- // If it's false in this point it means that the coValue is unavailable on the other peer.
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
- peer.combineWith(msg.id, knownStateFromContent(validNewContent));
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
- this.trySendToPeer(peer, {
973
- action: "known",
974
- ...coValue.knownState(),
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
+ });