cojson 0.20.9 → 0.20.11

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 (179) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/dist/OngoingStorageReconciliationTracker.d.ts +16 -0
  4. package/dist/OngoingStorageReconciliationTracker.d.ts.map +1 -0
  5. package/dist/OngoingStorageReconciliationTracker.js +75 -0
  6. package/dist/OngoingStorageReconciliationTracker.js.map +1 -0
  7. package/dist/PeerState.d.ts +2 -2
  8. package/dist/PeerState.d.ts.map +1 -1
  9. package/dist/PeerState.js +3 -3
  10. package/dist/PeerState.js.map +1 -1
  11. package/dist/StorageReconciliationAckTracker.d.ts +14 -0
  12. package/dist/StorageReconciliationAckTracker.d.ts.map +1 -0
  13. package/dist/StorageReconciliationAckTracker.js +72 -0
  14. package/dist/StorageReconciliationAckTracker.js.map +1 -0
  15. package/dist/SyncStateManager.js +2 -2
  16. package/dist/SyncStateManager.js.map +1 -1
  17. package/dist/coValueCore/coValueCore.d.ts +2 -1
  18. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  19. package/dist/coValueCore/coValueCore.js +43 -10
  20. package/dist/coValueCore/coValueCore.js.map +1 -1
  21. package/dist/coValues/coList.d.ts +2 -0
  22. package/dist/coValues/coList.d.ts.map +1 -1
  23. package/dist/coValues/coList.js +28 -0
  24. package/dist/coValues/coList.js.map +1 -1
  25. package/dist/coValues/group.d.ts +4 -1
  26. package/dist/coValues/group.d.ts.map +1 -1
  27. package/dist/coValues/group.js +15 -1
  28. package/dist/coValues/group.js.map +1 -1
  29. package/dist/config.d.ts +8 -0
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +14 -0
  32. package/dist/config.js.map +1 -1
  33. package/dist/exports.d.ts +9 -1
  34. package/dist/exports.d.ts.map +1 -1
  35. package/dist/exports.js +5 -1
  36. package/dist/exports.js.map +1 -1
  37. package/dist/localNode.d.ts +7 -3
  38. package/dist/localNode.d.ts.map +1 -1
  39. package/dist/localNode.js +13 -5
  40. package/dist/localNode.js.map +1 -1
  41. package/dist/permissions.d.ts +1 -0
  42. package/dist/permissions.d.ts.map +1 -1
  43. package/dist/queue/LinkedList.d.ts +2 -0
  44. package/dist/queue/LinkedList.d.ts.map +1 -1
  45. package/dist/queue/LinkedList.js +7 -0
  46. package/dist/queue/LinkedList.js.map +1 -1
  47. package/dist/queue/OutgoingLoadQueue.d.ts +4 -1
  48. package/dist/queue/OutgoingLoadQueue.d.ts.map +1 -1
  49. package/dist/queue/OutgoingLoadQueue.js +41 -13
  50. package/dist/queue/OutgoingLoadQueue.js.map +1 -1
  51. package/dist/queue/PriorityBasedMessageQueue.d.ts +1 -0
  52. package/dist/queue/PriorityBasedMessageQueue.d.ts.map +1 -1
  53. package/dist/queue/PriorityBasedMessageQueue.js +11 -1
  54. package/dist/queue/PriorityBasedMessageQueue.js.map +1 -1
  55. package/dist/storage/knownState.d.ts +2 -0
  56. package/dist/storage/knownState.d.ts.map +1 -1
  57. package/dist/storage/knownState.js +11 -0
  58. package/dist/storage/knownState.js.map +1 -1
  59. package/dist/storage/sqlite/client.d.ts +10 -1
  60. package/dist/storage/sqlite/client.d.ts.map +1 -1
  61. package/dist/storage/sqlite/client.js +84 -0
  62. package/dist/storage/sqlite/client.js.map +1 -1
  63. package/dist/storage/sqlite/sqliteMigrations.d.ts.map +1 -1
  64. package/dist/storage/sqlite/sqliteMigrations.js +11 -0
  65. package/dist/storage/sqlite/sqliteMigrations.js.map +1 -1
  66. package/dist/storage/sqliteAsync/client.d.ts +10 -1
  67. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  68. package/dist/storage/sqliteAsync/client.js +86 -0
  69. package/dist/storage/sqliteAsync/client.js.map +1 -1
  70. package/dist/storage/storageAsync.d.ts +9 -2
  71. package/dist/storage/storageAsync.d.ts.map +1 -1
  72. package/dist/storage/storageAsync.js +19 -0
  73. package/dist/storage/storageAsync.js.map +1 -1
  74. package/dist/storage/storageSync.d.ts +9 -2
  75. package/dist/storage/storageSync.d.ts.map +1 -1
  76. package/dist/storage/storageSync.js +20 -13
  77. package/dist/storage/storageSync.js.map +1 -1
  78. package/dist/storage/types.d.ts +64 -0
  79. package/dist/storage/types.d.ts.map +1 -1
  80. package/dist/storage/types.js.map +1 -1
  81. package/dist/sync.d.ts +53 -2
  82. package/dist/sync.d.ts.map +1 -1
  83. package/dist/sync.js +300 -44
  84. package/dist/sync.js.map +1 -1
  85. package/dist/tests/OngoingStorageReconciliationTracker.test.d.ts +2 -0
  86. package/dist/tests/OngoingStorageReconciliationTracker.test.d.ts.map +1 -0
  87. package/dist/tests/OngoingStorageReconciliationTracker.test.js +60 -0
  88. package/dist/tests/OngoingStorageReconciliationTracker.test.js.map +1 -0
  89. package/dist/tests/OutgoingLoadQueue.test.js +137 -39
  90. package/dist/tests/OutgoingLoadQueue.test.js.map +1 -1
  91. package/dist/tests/SQLiteClientAsync.test.js +1 -1
  92. package/dist/tests/SQLiteClientAsync.test.js.map +1 -1
  93. package/dist/tests/StorageApiAsync.test.js +138 -0
  94. package/dist/tests/StorageApiAsync.test.js.map +1 -1
  95. package/dist/tests/StorageApiSync.test.js +154 -0
  96. package/dist/tests/StorageApiSync.test.js.map +1 -1
  97. package/dist/tests/StorageReconciliationAckTracker.test.d.ts +2 -0
  98. package/dist/tests/StorageReconciliationAckTracker.test.d.ts.map +1 -0
  99. package/dist/tests/StorageReconciliationAckTracker.test.js +74 -0
  100. package/dist/tests/StorageReconciliationAckTracker.test.js.map +1 -0
  101. package/dist/tests/SyncStateManager.test.js +18 -0
  102. package/dist/tests/SyncStateManager.test.js.map +1 -1
  103. package/dist/tests/coList.test.js +112 -1
  104. package/dist/tests/coList.test.js.map +1 -1
  105. package/dist/tests/coValueCore.loadFromStorage.test.js +36 -0
  106. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  107. package/dist/tests/group.test.js +44 -0
  108. package/dist/tests/group.test.js.map +1 -1
  109. package/dist/tests/knownState.lazyLoading.test.js +6 -0
  110. package/dist/tests/knownState.lazyLoading.test.js.map +1 -1
  111. package/dist/tests/messagesTestUtils.d.ts.map +1 -1
  112. package/dist/tests/messagesTestUtils.js +4 -0
  113. package/dist/tests/messagesTestUtils.js.map +1 -1
  114. package/dist/tests/sync.concurrentLoad.test.js +333 -1
  115. package/dist/tests/sync.concurrentLoad.test.js.map +1 -1
  116. package/dist/tests/sync.garbageCollection.test.js +4 -0
  117. package/dist/tests/sync.garbageCollection.test.js.map +1 -1
  118. package/dist/tests/sync.load.test.js +19 -0
  119. package/dist/tests/sync.load.test.js.map +1 -1
  120. package/dist/tests/sync.mesh.test.js +1 -0
  121. package/dist/tests/sync.mesh.test.js.map +1 -1
  122. package/dist/tests/sync.multipleServers.test.js +41 -3
  123. package/dist/tests/sync.multipleServers.test.js.map +1 -1
  124. package/dist/tests/sync.storage.test.js +2 -0
  125. package/dist/tests/sync.storage.test.js.map +1 -1
  126. package/dist/tests/sync.storageAsync.test.js +1 -0
  127. package/dist/tests/sync.storageAsync.test.js.map +1 -1
  128. package/dist/tests/sync.storageReconciliation.test.d.ts +2 -0
  129. package/dist/tests/sync.storageReconciliation.test.d.ts.map +1 -0
  130. package/dist/tests/sync.storageReconciliation.test.js +502 -0
  131. package/dist/tests/sync.storageReconciliation.test.js.map +1 -0
  132. package/dist/tests/testUtils.d.ts +1 -0
  133. package/dist/tests/testUtils.d.ts.map +1 -1
  134. package/dist/tests/testUtils.js +3 -2
  135. package/dist/tests/testUtils.js.map +1 -1
  136. package/package.json +4 -4
  137. package/src/OngoingStorageReconciliationTracker.ts +97 -0
  138. package/src/PeerState.ts +10 -3
  139. package/src/StorageReconciliationAckTracker.ts +83 -0
  140. package/src/SyncStateManager.ts +3 -3
  141. package/src/coValueCore/coValueCore.ts +47 -16
  142. package/src/coValues/coList.ts +23 -0
  143. package/src/coValues/group.ts +18 -0
  144. package/src/config.ts +18 -0
  145. package/src/exports.ts +8 -0
  146. package/src/localNode.ts +18 -0
  147. package/src/permissions.ts +1 -1
  148. package/src/queue/LinkedList.ts +10 -0
  149. package/src/queue/OutgoingLoadQueue.ts +57 -15
  150. package/src/queue/PriorityBasedMessageQueue.ts +15 -1
  151. package/src/storage/knownState.ts +14 -0
  152. package/src/storage/sqlite/client.ts +128 -0
  153. package/src/storage/sqlite/sqliteMigrations.ts +11 -0
  154. package/src/storage/sqliteAsync/client.ts +139 -0
  155. package/src/storage/storageAsync.ts +37 -0
  156. package/src/storage/storageSync.ts +41 -16
  157. package/src/storage/types.ts +110 -0
  158. package/src/sync.ts +359 -14
  159. package/src/tests/OngoingStorageReconciliationTracker.test.ts +85 -0
  160. package/src/tests/OutgoingLoadQueue.test.ts +226 -59
  161. package/src/tests/SQLiteClientAsync.test.ts +1 -1
  162. package/src/tests/StorageApiAsync.test.ts +161 -1
  163. package/src/tests/StorageApiSync.test.ts +176 -0
  164. package/src/tests/StorageReconciliationAckTracker.test.ts +99 -0
  165. package/src/tests/SyncStateManager.test.ts +25 -0
  166. package/src/tests/coList.test.ts +138 -0
  167. package/src/tests/coValueCore.loadFromStorage.test.ts +72 -1
  168. package/src/tests/group.test.ts +87 -0
  169. package/src/tests/knownState.lazyLoading.test.ts +36 -1
  170. package/src/tests/messagesTestUtils.ts +4 -0
  171. package/src/tests/sync.concurrentLoad.test.ts +491 -0
  172. package/src/tests/sync.garbageCollection.test.ts +4 -0
  173. package/src/tests/sync.load.test.ts +26 -0
  174. package/src/tests/sync.mesh.test.ts +1 -0
  175. package/src/tests/sync.multipleServers.test.ts +60 -2
  176. package/src/tests/sync.storage.test.ts +2 -0
  177. package/src/tests/sync.storageAsync.test.ts +1 -0
  178. package/src/tests/sync.storageReconciliation.test.ts +696 -0
  179. package/src/tests/testUtils.ts +10 -1
package/src/sync.ts CHANGED
@@ -1,9 +1,13 @@
1
+ import { base58 } from "@scure/base";
1
2
  import { md5 } from "@noble/hashes/legacy";
2
3
  import { Histogram, ValueType, metrics } from "@opentelemetry/api";
3
4
  import { PeerState } from "./PeerState.js";
4
5
  import { SyncStateManager } from "./SyncStateManager.js";
5
6
  import { UnsyncedCoValuesTracker } from "./UnsyncedCoValuesTracker.js";
6
- import { SYNC_SCHEDULER_CONFIG } from "./config.js";
7
+ import {
8
+ STORAGE_RECONCILIATION_CONFIG,
9
+ SYNC_SCHEDULER_CONFIG,
10
+ } from "./config.js";
7
11
  import {
8
12
  getContenDebugInfo,
9
13
  getNewTransactionsFromContentMessage,
@@ -21,6 +25,8 @@ import { CoValuePriority } from "./priority.js";
21
25
  import { IncomingMessagesQueue } from "./queue/IncomingMessagesQueue.js";
22
26
  import { LocalTransactionsSyncQueue } from "./queue/LocalTransactionsSyncQueue.js";
23
27
  import type { StorageStreamingQueue } from "./queue/StorageStreamingQueue.js";
28
+ import { OngoingStorageReconciliationTracker } from "./OngoingStorageReconciliationTracker.js";
29
+ import { StorageReconciliationServerAckTracker } from "./StorageReconciliationAckTracker.js";
24
30
  import {
25
31
  CoValueKnownState,
26
32
  knownStateFrom,
@@ -33,7 +39,9 @@ export type SyncMessage =
33
39
  | LoadMessage
34
40
  | KnownStateMessage
35
41
  | NewContentMessage
36
- | DoneMessage;
42
+ | DoneMessage
43
+ | ReconcileMessage
44
+ | ReconcileAckMessage;
37
45
 
38
46
  export type LoadMessage = {
39
47
  action: "load";
@@ -68,6 +76,19 @@ export type DoneMessage = {
68
76
  id: RawCoID;
69
77
  };
70
78
 
79
+ export type ReconcileBatchID = string;
80
+
81
+ export type ReconcileMessage = {
82
+ action: "reconcile";
83
+ id: ReconcileBatchID;
84
+ values: [coValue: RawCoID, sessionsHash: string][];
85
+ };
86
+
87
+ export type ReconcileAckMessage = {
88
+ action: "reconcile-ack";
89
+ id: ReconcileBatchID;
90
+ };
91
+
71
92
  /**
72
93
  * Determines when network sync is enabled.
73
94
  * - "always": sync is enabled for both Anonymous Authentication and Authenticated Account
@@ -111,9 +132,29 @@ export type ServerPeerSelector = (
111
132
  serverPeers: PeerState[],
112
133
  ) => PeerState[];
113
134
 
135
+ /**
136
+ * Manages the sync of coValues between peers.
137
+ * It is responsible for sending, receiving and processing sync messages.
138
+ * For more details on how the sync protocol works, see the sync protocol documentation:
139
+ * {@link docs/sync-protocol.md}
140
+ */
114
141
  export class SyncManager {
115
142
  peers: { [key: PeerID]: PeerState } = {};
116
143
  local: LocalNode;
144
+ /**
145
+ * Tracks pending reconcile acks from the server.
146
+ */
147
+ private reconciliationAckTracker =
148
+ new StorageReconciliationServerAckTracker();
149
+ /**
150
+ * Tracks ongoing storage reconciliation batches in a server.
151
+ */
152
+ private ongoingStorageReconciliationTracker =
153
+ new OngoingStorageReconciliationTracker();
154
+
155
+ get pendingReconciliationAck(): Map<string, number> {
156
+ return this.reconciliationAckTracker.pendingReconciliationAck;
157
+ }
117
158
 
118
159
  // When true, transactions will not be verified.
119
160
  // This is useful when syncing only for storage purposes, with the expectation that
@@ -127,6 +168,8 @@ export class SyncManager {
127
168
  this._ignoreUnknownCoValuesFromServers = true;
128
169
  }
129
170
 
171
+ fullStorageReconciliationEnabled = false;
172
+
130
173
  peersCounter = metrics.getMeter("cojson").createUpDownCounter("jazz.peers", {
131
174
  description: "Amount of connected peers",
132
175
  valueType: ValueType.INT,
@@ -179,6 +222,15 @@ export class SyncManager {
179
222
  }
180
223
 
181
224
  handleSyncMessage(msg: SyncMessage, peer: PeerState) {
225
+ if (msg.action === "reconcile") {
226
+ this.handleReconcile(msg, peer);
227
+ return;
228
+ }
229
+ if (msg.action === "reconcile-ack") {
230
+ this.handleReconcileAck(msg, peer);
231
+ return;
232
+ }
233
+
182
234
  if (!isRawCoID(msg.id)) {
183
235
  const errorType = msg.id ? "invalid" : "undefined";
184
236
  logger.warn(`Received sync message with ${errorType} id`, {
@@ -233,7 +285,20 @@ export class SyncManager {
233
285
  }
234
286
  }
235
287
 
236
- sendNewContent(id: RawCoID, peer: PeerState, seen: Set<RawCoID> = new Set()) {
288
+ sendNewContent(
289
+ id: RawCoID,
290
+ peer: PeerState,
291
+ forceKnownReplyOnNoDelta: boolean = false,
292
+ ) {
293
+ this.#sendNewContent(id, peer, new Set(), forceKnownReplyOnNoDelta);
294
+ }
295
+
296
+ #sendNewContent(
297
+ id: RawCoID,
298
+ peer: PeerState,
299
+ seen: Set<RawCoID>,
300
+ forceKnownReplyOnNoDelta: boolean,
301
+ ) {
237
302
  if (seen.has(id)) {
238
303
  return;
239
304
  }
@@ -249,7 +314,7 @@ export class SyncManager {
249
314
  const includeDependencies = peer.role !== "server";
250
315
  if (includeDependencies) {
251
316
  for (const dependency of coValue.getDependedOnCoValues()) {
252
- this.sendNewContent(dependency, peer, seen);
317
+ this.#sendNewContent(dependency, peer, seen, false);
253
318
  }
254
319
  }
255
320
 
@@ -263,7 +328,7 @@ export class SyncManager {
263
328
  }
264
329
 
265
330
  peer.combineOptimisticWith(id, coValue.knownState());
266
- } else if (!peer.toldKnownState.has(id)) {
331
+ } else if (forceKnownReplyOnNoDelta || !peer.toldKnownState.has(id)) {
267
332
  if (coValue.isDeleted) {
268
333
  // 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
334
  this.trySendToPeer(
@@ -281,15 +346,172 @@ export class SyncManager {
281
346
  peer.trackToldKnownState(id);
282
347
  }
283
348
 
349
+ /**
350
+ * Reconciles all in-memory CoValues with all persistent server peers
351
+ */
284
352
  reconcileServerPeers() {
285
353
  const serverPeers = Object.values(this.peers).filter(
286
- (peer) => peer.role === "server",
354
+ isPersistentServerPeer,
287
355
  );
288
356
  for (const peer of serverPeers) {
289
357
  this.startPeerReconciliation(peer);
290
358
  }
291
359
  }
292
360
 
361
+ /**
362
+ * Ensures all CoValues in storage are synced to the given server peer.
363
+ * Sends "reconcile" message(s) with [coValueId, sessionsHash] for each CoValue.
364
+ * Server responds with "known" only where it is missing the CoValue or has different sessions,
365
+ * so that client can send missing content.
366
+ * Processes CoValues in batches of RECONCILIATION_BATCH_SIZE.
367
+ * @param peer - The server peer to reconcile with.
368
+ * @param initialOffset - Offset to start from (for resuming after interrupt). Default 0.
369
+ * @param onComplete - Called when reconciliation is fully complete (all batches sent and acked).
370
+ */
371
+ startStorageReconciliation(
372
+ peer: PeerState,
373
+ initialOffset?: number,
374
+ onComplete?: () => void,
375
+ ): void {
376
+ if (!this.local.storage) return;
377
+ if (!isPersistentServerPeer(peer)) return;
378
+
379
+ const startOffset = initialOffset ?? 0;
380
+ const batchSize = STORAGE_RECONCILIATION_CONFIG.BATCH_SIZE;
381
+
382
+ const storage = this.local.storage;
383
+
384
+ storage.getCoValueCount((totalCoValueCount) => {
385
+ const sendReconcileMessage = (
386
+ batchId: string,
387
+ entries: [RawCoID, string][],
388
+ offset: number,
389
+ ) => {
390
+ if (entries.length === 0) return;
391
+
392
+ this.reconciliationAckTracker.trackBatch(
393
+ batchId,
394
+ peer.id,
395
+ offset + batchSize,
396
+ );
397
+
398
+ this.trySendToPeer(peer, {
399
+ action: "reconcile",
400
+ id: batchId,
401
+ values: entries,
402
+ });
403
+ };
404
+
405
+ const triggerNextBatch = (lastBatchLength: number, offset: number) => {
406
+ // This value becomes false when the last covalueid batch picked from the storage
407
+ // is smaller than the batch size.
408
+ if (lastBatchLength === batchSize) {
409
+ logger.info("Reconciled CoValues in storage", {
410
+ peerId: peer.id,
411
+ completed: offset + batchSize,
412
+ total: totalCoValueCount,
413
+ });
414
+ processStorageBatch(offset + batchSize);
415
+ } else {
416
+ // Note: `completed` can be higher than `total` if CoValues were added
417
+ // after the reconciliation started
418
+ logger.info("Storage reconciliation complete", {
419
+ peerId: peer.id,
420
+ startOffset,
421
+ completed: offset + lastBatchLength,
422
+ total: totalCoValueCount,
423
+ });
424
+ onComplete?.();
425
+ }
426
+ };
427
+
428
+ const processStorageBatch = (offset: number) => {
429
+ storage.getCoValueIDs(batchSize, offset, (batch) => {
430
+ this.buildStorageReconciliationEntries(batch, (entries) => {
431
+ if (entries.length === 0) {
432
+ triggerNextBatch(batch.length, offset);
433
+ return;
434
+ }
435
+
436
+ const batchId = base58.encode(this.local.crypto.randomBytes(12));
437
+ sendReconcileMessage(batchId, entries, offset);
438
+
439
+ this.reconciliationAckTracker.waitForAck(batchId, peer, () => {
440
+ triggerNextBatch(batch.length, offset);
441
+ });
442
+ });
443
+ });
444
+ };
445
+
446
+ logger.info("Starting storage reconciliation", {
447
+ peerId: peer.id,
448
+ startOffset,
449
+ total: totalCoValueCount,
450
+ });
451
+ processStorageBatch(startOffset);
452
+ });
453
+ }
454
+
455
+ private buildStorageReconciliationEntries(
456
+ batch: { id: RawCoID }[],
457
+ callback: (entries: [RawCoID, string][]) => void,
458
+ ): void {
459
+ const storage = this.local.storage;
460
+
461
+ if (!storage) {
462
+ callback([]);
463
+ return;
464
+ }
465
+
466
+ const pending = batch.filter(({ id }) => !this.local.isCoValueInMemory(id));
467
+
468
+ if (pending.length === 0) {
469
+ callback([]);
470
+ return;
471
+ }
472
+
473
+ let done = 0;
474
+ const entries: [RawCoID, string][] = [];
475
+
476
+ for (const coValue of pending) {
477
+ storage.loadKnownState(coValue.id, (storageKnownState) => {
478
+ if (storageKnownState) {
479
+ entries.push([
480
+ coValue.id,
481
+ this.hashKnownStateSessions(storageKnownState.sessions),
482
+ ]);
483
+ }
484
+
485
+ done += 1;
486
+ if (done === pending.length) {
487
+ callback(entries);
488
+ }
489
+ });
490
+ }
491
+ }
492
+
493
+ private maybeStartStorageReconciliationForPeer(peer: PeerState): void {
494
+ if (!this.fullStorageReconciliationEnabled) return;
495
+ if (!this.local.storage) return;
496
+
497
+ const sessionId = this.local.currentSessionID;
498
+ this.local.storage.tryAcquireStorageReconciliationLock(
499
+ sessionId,
500
+ peer.id,
501
+ (result) => {
502
+ if (!result.acquired) return;
503
+
504
+ const lastProcessedOffset = result.lastProcessedOffset;
505
+ this.startStorageReconciliation(peer, lastProcessedOffset, () => {
506
+ this.local.storage?.releaseStorageReconciliationLock(
507
+ sessionId,
508
+ peer.id,
509
+ );
510
+ });
511
+ },
512
+ );
513
+ }
514
+
293
515
  async resumeUnsyncedCoValues(): Promise<void> {
294
516
  if (!this.local.storage) {
295
517
  // No storage available, skip resumption
@@ -364,12 +586,19 @@ export class SyncManager {
364
586
  });
365
587
  }
366
588
 
589
+ /**
590
+ * Reconciles all in-memory CoValues with the given peer.
591
+ * Creates a subscription for each CoValue that is not already subscribed to.
592
+ */
367
593
  startPeerReconciliation(peer: PeerState) {
368
594
  if (isPersistentServerPeer(peer)) {
369
595
  // Resume syncing unsynced CoValues asynchronously
370
596
  this.resumeUnsyncedCoValues().catch((error) => {
371
597
  logger.warn("Failed to resume unsynced CoValues:", error);
372
598
  });
599
+
600
+ // Try to run full storage reconciliation for this peer (scheduled per peer, every 30 days)
601
+ this.maybeStartStorageReconciliationForPeer(peer);
373
602
  }
374
603
 
375
604
  const coValuesOrderedByDependency: CoValueCore[] = [];
@@ -549,6 +778,7 @@ export class SyncManager {
549
778
 
550
779
  peerState.addCloseListener(() => {
551
780
  unsubscribeFromKnownStatesUpdates();
781
+ this.ongoingStorageReconciliationTracker.clearPeer(peer.id);
552
782
  this.peersCounter.add(-1, { role: peer.role });
553
783
 
554
784
  if (!peer.persistent && this.peers[peer.id] === peerState) {
@@ -592,7 +822,7 @@ export class SyncManager {
592
822
 
593
823
  // Fast path: CoValue is already in memory
594
824
  if (coValue.isAvailable()) {
595
- this.sendNewContent(msg.id, peer);
825
+ this.sendNewContent(msg.id, peer, true);
596
826
  return;
597
827
  }
598
828
 
@@ -608,7 +838,7 @@ export class SyncManager {
608
838
  coValue.getKnownStateFromStorage((storageKnownState) => {
609
839
  // Race condition: CoValue might have been loaded while we were waiting for storage
610
840
  if (coValue.isAvailable()) {
611
- this.sendNewContent(msg.id, peer);
841
+ this.sendNewContent(msg.id, peer, true);
612
842
  return;
613
843
  }
614
844
 
@@ -631,7 +861,7 @@ export class SyncManager {
631
861
  // Even though we responded with KNOWN (client has everything), we need
632
862
  // to establish a subscription so that updates from core flow to us.
633
863
  const serverPeers = this.getServerPeers(msg.id, peer.id);
634
- coValue.loadFromPeers(serverPeers);
864
+ coValue.loadFromPeers(serverPeers, "low-priority");
635
865
 
636
866
  return;
637
867
  }
@@ -652,7 +882,7 @@ export class SyncManager {
652
882
  ) {
653
883
  coValue.loadFromStorage((found) => {
654
884
  if (found && coValue.isAvailable()) {
655
- this.sendNewContent(id, peer);
885
+ this.sendNewContent(id, peer, true);
656
886
  } else {
657
887
  this.loadFromPeersAndRespond(id, peer, coValue);
658
888
  }
@@ -668,7 +898,7 @@ export class SyncManager {
668
898
  coValue: CoValueCore,
669
899
  ) {
670
900
  const peers = this.getServerPeers(id, peer.id);
671
- coValue.loadFromPeers(peers);
901
+ coValue.loadFromPeers(peers, "immediate");
672
902
 
673
903
  const handleLoadResult = () => {
674
904
  if (coValue.isAvailable()) {
@@ -734,9 +964,107 @@ export class SyncManager {
734
964
 
735
965
  if (coValue.isAvailable()) {
736
966
  this.sendNewContent(msg.id, peer);
967
+ } else if (coValue.isKnownStateAvailable()) {
968
+ // Validate if content is missing before loading it from storage
969
+ if (!this.syncState.isSynced(peer, msg.id)) {
970
+ this.local.loadCoValueCore(msg.id).then(() => {
971
+ this.sendNewContent(msg.id, peer);
972
+ });
973
+ }
737
974
  }
738
975
 
739
- peer.trackLoadRequestComplete(coValue);
976
+ peer.trackLoadRequestComplete(coValue, "known");
977
+ this.maybeMarkCoValueAsReconciled(peer, msg.id);
978
+ }
979
+
980
+ handleReconcile(msg: ReconcileMessage, peer: PeerState): void {
981
+ let remaining = msg.values.length;
982
+ if (remaining === 0) {
983
+ this.trySendToPeer(peer, { action: "reconcile-ack", id: msg.id });
984
+ return;
985
+ }
986
+
987
+ const pending = new Set<RawCoID>();
988
+ const processEntryDone = () => {
989
+ remaining -= 1;
990
+
991
+ if (remaining !== 0) {
992
+ return;
993
+ }
994
+
995
+ if (pending.size === 0) {
996
+ this.trySendToPeer(peer, { action: "reconcile-ack", id: msg.id });
997
+ return;
998
+ }
999
+
1000
+ this.ongoingStorageReconciliationTracker.trackBatch(
1001
+ peer.id,
1002
+ msg.id,
1003
+ pending,
1004
+ );
1005
+ };
1006
+
1007
+ for (const [coValueId, clientSessionsHash] of msg.values) {
1008
+ // Avoid creating a new coValue object if it's not already in memory
1009
+ const inMemoryCoValue = this.local.isCoValueInMemory(coValueId)
1010
+ ? this.local.getCoValue(coValueId)
1011
+ : undefined;
1012
+ if (inMemoryCoValue?.isErroredInPeer(peer.id)) {
1013
+ processEntryDone();
1014
+ continue;
1015
+ }
1016
+
1017
+ const maybeSendLoadRequest = (
1018
+ knownState: CoValueKnownState | undefined,
1019
+ ) => {
1020
+ if (!knownState) {
1021
+ pending.add(coValueId);
1022
+ peer.trackToldKnownState(coValueId);
1023
+ this.trySendToPeer(peer, {
1024
+ action: "load",
1025
+ id: coValueId,
1026
+ header: false,
1027
+ sessions: {},
1028
+ });
1029
+ } else {
1030
+ const serverSessionsHash = this.hashKnownStateSessions(
1031
+ knownState.sessions,
1032
+ );
1033
+ if (serverSessionsHash !== clientSessionsHash) {
1034
+ pending.add(coValueId);
1035
+ peer.trackToldKnownState(coValueId);
1036
+ this.trySendToPeer(peer, { action: "load", ...knownState });
1037
+ }
1038
+ }
1039
+ processEntryDone();
1040
+ };
1041
+
1042
+ if (
1043
+ inMemoryCoValue?.isAvailable() ||
1044
+ inMemoryCoValue?.loadingState === "onlyKnownState"
1045
+ ) {
1046
+ maybeSendLoadRequest(inMemoryCoValue.knownState());
1047
+ } else {
1048
+ this.local.storage
1049
+ ? this.local.storage.loadKnownState(coValueId, maybeSendLoadRequest)
1050
+ : maybeSendLoadRequest(undefined);
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ handleReconcileAck(msg: ReconcileAckMessage, peer: PeerState): void {
1056
+ const nextOffset = this.reconciliationAckTracker.handleAck(msg.id, peer.id);
1057
+ if (nextOffset !== undefined) {
1058
+ this.local.storage?.renewStorageReconciliationLock(
1059
+ this.local.currentSessionID,
1060
+ peer.id,
1061
+ nextOffset,
1062
+ );
1063
+ }
1064
+ }
1065
+
1066
+ private hashKnownStateSessions(sessions: KnownStateSessions): string {
1067
+ return this.local.crypto.shortHash(sessions);
740
1068
  }
741
1069
 
742
1070
  recordTransactionsSize(newTransactions: Transaction[], source: string) {
@@ -1040,7 +1368,10 @@ export class SyncManager {
1040
1368
  }
1041
1369
  }
1042
1370
 
1043
- peer?.trackLoadRequestComplete(coValue);
1371
+ peer?.trackLoadRequestComplete(coValue, "content");
1372
+ if (peer && !coValue.isStreaming()) {
1373
+ this.maybeMarkCoValueAsReconciled(peer, msg.id);
1374
+ }
1044
1375
 
1045
1376
  for (const peer of this.getPeers(coValue.id)) {
1046
1377
  /**
@@ -1056,7 +1387,7 @@ export class SyncManager {
1056
1387
  if (peer.isCoValueSubscribedToPeer(coValue.id)) {
1057
1388
  this.sendNewContent(coValue.id, peer);
1058
1389
  } else if (peer.role === "server") {
1059
- peer.sendLoadRequest(coValue);
1390
+ peer.sendLoadRequest(coValue, "low-priority");
1060
1391
  }
1061
1392
  }
1062
1393
  }
@@ -1067,6 +1398,20 @@ export class SyncManager {
1067
1398
  return this.sendNewContent(msg.id, peer);
1068
1399
  }
1069
1400
 
1401
+ private maybeMarkCoValueAsReconciled(peer: PeerState, coValueId: RawCoID) {
1402
+ const completedBatchIds =
1403
+ this.ongoingStorageReconciliationTracker.markItemComplete(
1404
+ peer.id,
1405
+ coValueId,
1406
+ );
1407
+ for (const batchId of completedBatchIds) {
1408
+ this.trySendToPeer(peer, {
1409
+ action: "reconcile-ack",
1410
+ id: batchId,
1411
+ });
1412
+ }
1413
+ }
1414
+
1070
1415
  private syncQueue = new LocalTransactionsSyncQueue((content) =>
1071
1416
  this.syncContent(content),
1072
1417
  );
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { OngoingStorageReconciliationTracker } from "../OngoingStorageReconciliationTracker.js";
3
+ import { RawCoID } from "../ids.js";
4
+
5
+ function coID(value: string): RawCoID {
6
+ return value as RawCoID;
7
+ }
8
+
9
+ describe("OngoingStorageReconciliationTracker", () => {
10
+ test("does not track empty batches", () => {
11
+ const tracker = new OngoingStorageReconciliationTracker();
12
+
13
+ tracker.trackBatch("peer-1", "batch-1", new Set());
14
+
15
+ // @ts-expect-error - reconcileBatches is private
16
+ expect(tracker.reconcileBatches.size).toEqual(0);
17
+ });
18
+
19
+ test("completes a batch only when all its covalues are completed", () => {
20
+ const tracker = new OngoingStorageReconciliationTracker();
21
+
22
+ tracker.trackBatch(
23
+ "peer-1",
24
+ "batch-1",
25
+ new Set([coID("co_A"), coID("co_B")]),
26
+ );
27
+
28
+ expect(tracker.markItemComplete("peer-1", coID("co_A"))).toEqual([]);
29
+ expect(tracker.markItemComplete("peer-1", coID("co_B"))).toEqual([
30
+ "batch-1",
31
+ ]);
32
+ // @ts-expect-error - reconcileBatches is private
33
+ expect(tracker.reconcileBatches.size).toEqual(0);
34
+ });
35
+
36
+ test("returns all completed batch ids for a covalue that is in multiple batches", () => {
37
+ const tracker = new OngoingStorageReconciliationTracker();
38
+
39
+ tracker.trackBatch("peer-1", "batch-1", new Set([coID("co_A")]));
40
+ tracker.trackBatch(
41
+ "peer-1",
42
+ "batch-2",
43
+ new Set([coID("co_A"), coID("co_B")]),
44
+ );
45
+ tracker.trackBatch("peer-1", "batch-3", new Set([coID("co_B")]));
46
+
47
+ expect(tracker.markItemComplete("peer-1", coID("co_A"))).toEqual([
48
+ "batch-1",
49
+ ]);
50
+ expect(tracker.markItemComplete("peer-1", coID("co_B"))).toEqual([
51
+ "batch-2",
52
+ "batch-3",
53
+ ]);
54
+ // @ts-expect-error - reconcileBatches is private
55
+ expect(tracker.reconcileBatches.size).toEqual(0);
56
+ });
57
+
58
+ test("isolates state by peer", () => {
59
+ const tracker = new OngoingStorageReconciliationTracker();
60
+
61
+ tracker.trackBatch("peer-1", "batch-A", new Set([coID("co_A")]));
62
+ tracker.trackBatch("peer-2", "batch-B", new Set([coID("co_A")]));
63
+
64
+ expect(tracker.markItemComplete("peer-1", coID("co_A"))).toEqual([
65
+ "batch-A",
66
+ ]);
67
+ expect(tracker.markItemComplete("peer-2", coID("co_A"))).toEqual([
68
+ "batch-B",
69
+ ]);
70
+ });
71
+
72
+ test("clearPeer removes only that peer state", () => {
73
+ const tracker = new OngoingStorageReconciliationTracker();
74
+
75
+ tracker.trackBatch("peer-1", "batch-A", new Set([coID("co_A")]));
76
+ tracker.trackBatch("peer-2", "batch-B", new Set([coID("co_B")]));
77
+
78
+ tracker.clearPeer("peer-1");
79
+
80
+ expect(tracker.markItemComplete("peer-1", coID("co_A"))).toEqual([]);
81
+ expect(tracker.markItemComplete("peer-2", coID("co_B"))).toEqual([
82
+ "batch-B",
83
+ ]);
84
+ });
85
+ });