cojson 0.19.18 → 0.19.20

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 (124) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +9 -0
  3. package/dist/PeerState.d.ts.map +1 -1
  4. package/dist/SyncStateManager.d.ts +5 -2
  5. package/dist/SyncStateManager.d.ts.map +1 -1
  6. package/dist/SyncStateManager.js +49 -12
  7. package/dist/SyncStateManager.js.map +1 -1
  8. package/dist/UnsyncedCoValuesTracker.d.ts +81 -0
  9. package/dist/UnsyncedCoValuesTracker.d.ts.map +1 -0
  10. package/dist/UnsyncedCoValuesTracker.js +209 -0
  11. package/dist/UnsyncedCoValuesTracker.js.map +1 -0
  12. package/dist/config.d.ts +6 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +10 -0
  15. package/dist/config.js.map +1 -1
  16. package/dist/exports.d.ts +11 -3
  17. package/dist/exports.d.ts.map +1 -1
  18. package/dist/exports.js +6 -1
  19. package/dist/exports.js.map +1 -1
  20. package/dist/localNode.d.ts +9 -5
  21. package/dist/localNode.d.ts.map +1 -1
  22. package/dist/localNode.js +12 -8
  23. package/dist/localNode.js.map +1 -1
  24. package/dist/queue/IncomingMessagesQueue.d.ts +6 -7
  25. package/dist/queue/IncomingMessagesQueue.d.ts.map +1 -1
  26. package/dist/queue/IncomingMessagesQueue.js +7 -30
  27. package/dist/queue/IncomingMessagesQueue.js.map +1 -1
  28. package/dist/queue/LinkedList.d.ts +1 -1
  29. package/dist/queue/LinkedList.d.ts.map +1 -1
  30. package/dist/queue/LinkedList.js.map +1 -1
  31. package/dist/queue/StorageStreamingQueue.d.ts +43 -0
  32. package/dist/queue/StorageStreamingQueue.d.ts.map +1 -0
  33. package/dist/queue/StorageStreamingQueue.js +70 -0
  34. package/dist/queue/StorageStreamingQueue.js.map +1 -0
  35. package/dist/storage/knownState.d.ts +1 -1
  36. package/dist/storage/knownState.js +4 -4
  37. package/dist/storage/sqlite/client.d.ts +8 -0
  38. package/dist/storage/sqlite/client.d.ts.map +1 -1
  39. package/dist/storage/sqlite/client.js +17 -0
  40. package/dist/storage/sqlite/client.js.map +1 -1
  41. package/dist/storage/sqlite/sqliteMigrations.d.ts.map +1 -1
  42. package/dist/storage/sqlite/sqliteMigrations.js +9 -0
  43. package/dist/storage/sqlite/sqliteMigrations.js.map +1 -1
  44. package/dist/storage/sqliteAsync/client.d.ts +8 -0
  45. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  46. package/dist/storage/sqliteAsync/client.js +19 -0
  47. package/dist/storage/sqliteAsync/client.js.map +1 -1
  48. package/dist/storage/storageAsync.d.ts +9 -2
  49. package/dist/storage/storageAsync.d.ts.map +1 -1
  50. package/dist/storage/storageAsync.js +9 -0
  51. package/dist/storage/storageAsync.js.map +1 -1
  52. package/dist/storage/storageSync.d.ts +17 -4
  53. package/dist/storage/storageSync.d.ts.map +1 -1
  54. package/dist/storage/storageSync.js +67 -44
  55. package/dist/storage/storageSync.js.map +1 -1
  56. package/dist/storage/types.d.ts +35 -0
  57. package/dist/storage/types.d.ts.map +1 -1
  58. package/dist/sync.d.ts +38 -1
  59. package/dist/sync.d.ts.map +1 -1
  60. package/dist/sync.js +181 -7
  61. package/dist/sync.js.map +1 -1
  62. package/dist/tests/IncomingMessagesQueue.test.js +4 -150
  63. package/dist/tests/IncomingMessagesQueue.test.js.map +1 -1
  64. package/dist/tests/StorageStreamingQueue.test.d.ts +2 -0
  65. package/dist/tests/StorageStreamingQueue.test.d.ts.map +1 -0
  66. package/dist/tests/StorageStreamingQueue.test.js +213 -0
  67. package/dist/tests/StorageStreamingQueue.test.js.map +1 -0
  68. package/dist/tests/SyncManager.processQueues.test.d.ts +2 -0
  69. package/dist/tests/SyncManager.processQueues.test.d.ts.map +1 -0
  70. package/dist/tests/SyncManager.processQueues.test.js +208 -0
  71. package/dist/tests/SyncManager.processQueues.test.js.map +1 -0
  72. package/dist/tests/SyncStateManager.test.js +3 -3
  73. package/dist/tests/SyncStateManager.test.js.map +1 -1
  74. package/dist/tests/coValueCore.loadFromStorage.test.js +3 -0
  75. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  76. package/dist/tests/setup.d.ts +2 -0
  77. package/dist/tests/setup.d.ts.map +1 -0
  78. package/dist/tests/setup.js +4 -0
  79. package/dist/tests/setup.js.map +1 -0
  80. package/dist/tests/sync.garbageCollection.test.js.map +1 -1
  81. package/dist/tests/sync.mesh.test.js +19 -19
  82. package/dist/tests/sync.storage.test.js +176 -20
  83. package/dist/tests/sync.storage.test.js.map +1 -1
  84. package/dist/tests/sync.test.js +1 -1
  85. package/dist/tests/sync.test.js.map +1 -1
  86. package/dist/tests/sync.tracking.test.d.ts +2 -0
  87. package/dist/tests/sync.tracking.test.d.ts.map +1 -0
  88. package/dist/tests/sync.tracking.test.js +261 -0
  89. package/dist/tests/sync.tracking.test.js.map +1 -0
  90. package/dist/tests/testUtils.d.ts +4 -3
  91. package/dist/tests/testUtils.d.ts.map +1 -1
  92. package/dist/tests/testUtils.js +4 -4
  93. package/dist/tests/testUtils.js.map +1 -1
  94. package/package.json +4 -4
  95. package/src/PeerState.ts +2 -2
  96. package/src/SyncStateManager.ts +63 -12
  97. package/src/UnsyncedCoValuesTracker.ts +272 -0
  98. package/src/config.ts +13 -0
  99. package/src/exports.ts +10 -1
  100. package/src/localNode.ts +15 -3
  101. package/src/queue/IncomingMessagesQueue.ts +7 -39
  102. package/src/queue/LinkedList.ts +1 -1
  103. package/src/queue/StorageStreamingQueue.ts +96 -0
  104. package/src/storage/knownState.ts +4 -4
  105. package/src/storage/sqlite/client.ts +31 -0
  106. package/src/storage/sqlite/sqliteMigrations.ts +9 -0
  107. package/src/storage/sqliteAsync/client.ts +35 -0
  108. package/src/storage/storageAsync.ts +18 -1
  109. package/src/storage/storageSync.ts +119 -56
  110. package/src/storage/types.ts +42 -0
  111. package/src/sync.ts +235 -8
  112. package/src/tests/IncomingMessagesQueue.test.ts +4 -206
  113. package/src/tests/StorageStreamingQueue.test.ts +276 -0
  114. package/src/tests/SyncManager.processQueues.test.ts +287 -0
  115. package/src/tests/SyncStateManager.test.ts +3 -0
  116. package/src/tests/coValueCore.loadFromStorage.test.ts +11 -0
  117. package/src/tests/setup.ts +4 -0
  118. package/src/tests/sync.garbageCollection.test.ts +1 -3
  119. package/src/tests/sync.mesh.test.ts +19 -19
  120. package/src/tests/sync.storage.test.ts +224 -32
  121. package/src/tests/sync.test.ts +1 -9
  122. package/src/tests/sync.tracking.test.ts +396 -0
  123. package/src/tests/testUtils.ts +11 -5
  124. package/vitest.config.ts +1 -0
@@ -0,0 +1,272 @@
1
+ import type { RawCoID } from "./ids.js";
2
+ import { logger } from "./logger.js";
3
+ import type { PeerID } from "./sync.js";
4
+ import type { StorageAPI } from "./storage/types.js";
5
+
6
+ /**
7
+ * Used to track a CoValue that hasn't been synced to any peer,
8
+ * because none is currently connected.
9
+ */
10
+ const ANY_PEER_ID: PeerID = "any";
11
+
12
+ // Flush pending updates to storage after 200ms
13
+ let BATCH_DELAY_MS = 200;
14
+
15
+ /**
16
+ * Set the delay for flushing pending sync state updates to storage.
17
+ * @internal
18
+ */
19
+ export function setSyncStateTrackingBatchDelay(delay: number): void {
20
+ BATCH_DELAY_MS = delay;
21
+ }
22
+
23
+ type PendingUpdate = {
24
+ id: RawCoID;
25
+ peerId: PeerID;
26
+ synced: boolean;
27
+ };
28
+
29
+ /**
30
+ * Tracks CoValues that have unsynced changes to specific peers.
31
+ * Maintains an in-memory map and periodically persists to storage.
32
+ */
33
+ export class UnsyncedCoValuesTracker {
34
+ private unsynced: Map<RawCoID, Set<PeerID>> = new Map();
35
+ private coValueListeners: Map<RawCoID, Set<(synced: boolean) => void>> =
36
+ new Map();
37
+ // Listeners for global "all synced" status changes
38
+ private globalListeners: Set<(synced: boolean) => void> = new Set();
39
+
40
+ // Pending updates to be persisted
41
+ private pendingUpdates: PendingUpdate[] = [];
42
+ private flushTimer: ReturnType<typeof setTimeout> | undefined;
43
+
44
+ private storage?: StorageAPI;
45
+
46
+ /**
47
+ * Add a CoValue as unsynced to a specific peer.
48
+ * Triggers persistence if storage is available.
49
+ * @returns true if the CoValue was already tracked, false otherwise.
50
+ */
51
+ add(id: RawCoID, peerId: PeerID = ANY_PEER_ID): boolean {
52
+ if (!this.unsynced.has(id)) {
53
+ this.unsynced.set(id, new Set());
54
+ }
55
+ const peerSet = this.unsynced.get(id)!;
56
+
57
+ const alreadyTracked = peerSet.has(peerId);
58
+ if (!alreadyTracked) {
59
+ // Only update if this is a new peer
60
+ peerSet.add(peerId);
61
+
62
+ this.schedulePersist(id, peerId, false);
63
+
64
+ this.notifyCoValueListeners(id, false);
65
+ this.notifyGlobalListeners(false);
66
+ }
67
+
68
+ return alreadyTracked;
69
+ }
70
+
71
+ /**
72
+ * Remove a CoValue from being unsynced to a specific peer.
73
+ * Triggers persistence if storage is available.
74
+ */
75
+ remove(id: RawCoID, peerId: PeerID = ANY_PEER_ID): void {
76
+ const peerSet = this.unsynced.get(id);
77
+ if (!peerSet || !peerSet.has(peerId)) {
78
+ return;
79
+ }
80
+
81
+ peerSet.delete(peerId);
82
+
83
+ // If no more unsynced peers for this CoValue, remove the entry
84
+ if (peerSet.size === 0) {
85
+ this.unsynced.delete(id);
86
+ }
87
+
88
+ this.schedulePersist(id, peerId, true);
89
+
90
+ const isSynced = !this.unsynced.has(id);
91
+ this.notifyCoValueListeners(id, isSynced);
92
+ this.notifyGlobalListeners(this.isAllSynced());
93
+ }
94
+
95
+ /**
96
+ * Remove all tracking for a CoValue (all peers).
97
+ * Triggers persistence if storage is available.
98
+ */
99
+ removeAll(id: RawCoID): void {
100
+ const peerSet = this.unsynced.get(id);
101
+ if (!peerSet) {
102
+ return;
103
+ }
104
+
105
+ // Remove all peers for this CoValue
106
+ const peersToRemove = Array.from(peerSet);
107
+ for (const peerId of peersToRemove) {
108
+ this.remove(id, peerId);
109
+ }
110
+ }
111
+
112
+ forcePersist(): Promise<void> | undefined {
113
+ return this.flush();
114
+ }
115
+
116
+ private schedulePersist(id: RawCoID, peerId: PeerID, synced: boolean): void {
117
+ const storage = this.storage;
118
+ if (!storage) {
119
+ return;
120
+ }
121
+
122
+ this.pendingUpdates.push({ id, peerId, synced });
123
+ if (!this.flushTimer) {
124
+ this.flushTimer = setTimeout(() => {
125
+ this.flush();
126
+ }, BATCH_DELAY_MS);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Flush all pending persistence updates in a batch
132
+ */
133
+ private flush(): Promise<void> | undefined {
134
+ if (this.flushTimer) {
135
+ clearTimeout(this.flushTimer);
136
+ this.flushTimer = undefined;
137
+ }
138
+
139
+ if (this.pendingUpdates.length === 0) {
140
+ return;
141
+ }
142
+
143
+ const storage = this.storage;
144
+ if (!storage) {
145
+ return;
146
+ }
147
+
148
+ const filteredUpdates = this.simplifyPendingUpdates(this.pendingUpdates);
149
+ this.pendingUpdates = [];
150
+
151
+ return new Promise((resolve) => {
152
+ try {
153
+ storage.trackCoValuesSyncState(filteredUpdates, () => resolve());
154
+ } catch (error) {
155
+ logger.warn("Failed to persist batched unsynced CoValue tracking", {
156
+ err: error,
157
+ });
158
+ resolve();
159
+ }
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Get all CoValue IDs that have at least one unsynced peer.
165
+ */
166
+ getAll(): RawCoID[] {
167
+ return Array.from(this.unsynced.keys());
168
+ }
169
+
170
+ /**
171
+ * Check if all CoValues are synced
172
+ */
173
+ isAllSynced(): boolean {
174
+ return this.unsynced.size === 0;
175
+ }
176
+
177
+ /**
178
+ * Check if a specific CoValue is tracked as unsynced.
179
+ */
180
+ has(id: RawCoID): boolean {
181
+ return this.unsynced.has(id);
182
+ }
183
+
184
+ /**
185
+ * Subscribe to changes in whether a specific CoValue is synced.
186
+ * The listener is called immediately with the current state.
187
+ * @returns Unsubscribe function
188
+ */
189
+ subscribe(id: RawCoID, listener: (synced: boolean) => void): () => void;
190
+ /**
191
+ * Subscribe to changes in whether all CoValues are synced.
192
+ * The listener is called immediately with the current state.
193
+ * @returns Unsubscribe function
194
+ */
195
+ subscribe(listener: (synced: boolean) => void): () => void;
196
+ subscribe(
197
+ idOrListener: RawCoID | ((synced: boolean) => void),
198
+ listener?: (synced: boolean) => void,
199
+ ): () => void {
200
+ if (typeof idOrListener === "string" && listener) {
201
+ const id = idOrListener;
202
+ if (!this.coValueListeners.has(id)) {
203
+ this.coValueListeners.set(id, new Set());
204
+ }
205
+ this.coValueListeners.get(id)!.add(listener);
206
+
207
+ // Call immediately with current state
208
+ const isSynced = !this.unsynced.has(id);
209
+ listener(isSynced);
210
+
211
+ return () => {
212
+ const listeners = this.coValueListeners.get(id);
213
+ if (listeners) {
214
+ listeners.delete(listener);
215
+ if (listeners.size === 0) {
216
+ this.coValueListeners.delete(id);
217
+ }
218
+ }
219
+ };
220
+ }
221
+
222
+ const globalListener = idOrListener as (synced: boolean) => void;
223
+ this.globalListeners.add(globalListener);
224
+
225
+ // Call immediately with current state
226
+ globalListener(this.isAllSynced());
227
+
228
+ return () => {
229
+ this.globalListeners.delete(globalListener);
230
+ };
231
+ }
232
+
233
+ setStorage(storage: StorageAPI) {
234
+ this.storage = storage;
235
+ }
236
+
237
+ removeStorage() {
238
+ this.storage = undefined;
239
+ }
240
+
241
+ /**
242
+ * Notify all listeners for a specific CoValue about sync status change.
243
+ */
244
+ private notifyCoValueListeners(id: RawCoID, synced: boolean): void {
245
+ const listeners = this.coValueListeners.get(id);
246
+ if (listeners) {
247
+ for (const listener of listeners) {
248
+ listener(synced);
249
+ }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Notify all global listeners about "all synced" status change.
255
+ */
256
+ private notifyGlobalListeners(allSynced: boolean): void {
257
+ for (const listener of this.globalListeners) {
258
+ listener(allSynced);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Keep only the last update for each (id, peerId) combination
264
+ */
265
+ private simplifyPendingUpdates(updates: PendingUpdate[]): PendingUpdate[] {
266
+ const latestUpdates = new Map<string, PendingUpdate>();
267
+ for (const update of updates) {
268
+ latestUpdates.set(`${update.id}|${update.peerId}`, update);
269
+ }
270
+ return Array.from(latestUpdates.values());
271
+ }
272
+ }
package/src/config.ts CHANGED
@@ -60,3 +60,16 @@ export function setGarbageCollectorMaxAge(maxAge: number) {
60
60
  export function setGarbageCollectorInterval(interval: number) {
61
61
  GARBAGE_COLLECTOR_CONFIG.INTERVAL = interval;
62
62
  }
63
+
64
+ export const WEBSOCKET_CONFIG = {
65
+ MAX_OUTGOING_MESSAGES_CHUNK_BYTES: 25_000,
66
+ OUTGOING_MESSAGES_CHUNK_DELAY: 5,
67
+ };
68
+
69
+ export function setMaxOutgoingMessagesChunkBytes(bytes: number) {
70
+ WEBSOCKET_CONFIG.MAX_OUTGOING_MESSAGES_CHUNK_BYTES = bytes;
71
+ }
72
+
73
+ export function setOutgoingMessagesChunkDelay(delay: number) {
74
+ WEBSOCKET_CONFIG.OUTGOING_MESSAGES_CHUNK_DELAY = delay;
75
+ }
package/src/exports.ts CHANGED
@@ -65,12 +65,13 @@ import type { AgentID, RawCoID, SessionID } from "./ids.js";
65
65
  import type { JsonObject, JsonValue } from "./jsonValue.js";
66
66
  import type * as Media from "./media.js";
67
67
  import { isAccountRole } from "./permissions.js";
68
- import type { Peer, SyncMessage } from "./sync.js";
68
+ import type { Peer, SyncMessage, SyncWhen } from "./sync.js";
69
69
  import {
70
70
  DisconnectedError,
71
71
  SyncManager,
72
72
  hwrServerPeerSelector,
73
73
  } from "./sync.js";
74
+ import { setSyncStateTrackingBatchDelay } from "./UnsyncedCoValuesTracker.js";
74
75
  import { emptyKnownState } from "./knownState.js";
75
76
 
76
77
  import {
@@ -81,10 +82,13 @@ import { getDependedOnCoValuesFromRawData } from "./coValueCore/utils.js";
81
82
  import {
82
83
  CO_VALUE_LOADING_CONFIG,
83
84
  TRANSACTION_CONFIG,
85
+ WEBSOCKET_CONFIG,
84
86
  setCoValueLoadingMaxRetries,
85
87
  setCoValueLoadingRetryDelay,
86
88
  setCoValueLoadingTimeout,
87
89
  setIncomingMessagesTimeBudget,
90
+ setMaxOutgoingMessagesChunkBytes,
91
+ setOutgoingMessagesChunkDelay,
88
92
  setMaxRecommendedTxSize,
89
93
  } from "./config.js";
90
94
  import { LogLevel, logger } from "./logger.js";
@@ -125,6 +129,7 @@ export const cojsonInternals = {
125
129
  setCoValueLoadingRetryDelay,
126
130
  setCoValueLoadingMaxRetries,
127
131
  setCoValueLoadingTimeout,
132
+ setSyncStateTrackingBatchDelay,
128
133
  ConnectedPeerChannel,
129
134
  textEncoder,
130
135
  textDecoder,
@@ -133,6 +138,9 @@ export const cojsonInternals = {
133
138
  TRANSACTION_CONFIG,
134
139
  setMaxRecommendedTxSize,
135
140
  canBeBranched,
141
+ WEBSOCKET_CONFIG,
142
+ setMaxOutgoingMessagesChunkBytes,
143
+ setOutgoingMessagesChunkDelay,
136
144
  };
137
145
 
138
146
  export {
@@ -193,6 +201,7 @@ export type {
193
201
  AccountRole,
194
202
  AvailableCoValueCore,
195
203
  PeerState,
204
+ SyncWhen,
196
205
  CoValueHeader,
197
206
  };
198
207
 
package/src/localNode.ts CHANGED
@@ -34,7 +34,7 @@ import { AgentSecret, CryptoProvider } from "./crypto/crypto.js";
34
34
  import { AgentID, RawCoID, SessionID, isAgentID, isRawCoID } from "./ids.js";
35
35
  import { logger } from "./logger.js";
36
36
  import { StorageAPI } from "./storage/index.js";
37
- import { Peer, PeerID, SyncManager } from "./sync.js";
37
+ import { Peer, PeerID, SyncManager, type SyncWhen } from "./sync.js";
38
38
  import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromSessionID.js";
39
39
  import { expectGroup } from "./typeUtils/expectGroup.js";
40
40
  import { canBeBranched } from "./coValueCore/branching.js";
@@ -75,6 +75,7 @@ export class LocalNode {
75
75
  agentSecret: AgentSecret,
76
76
  currentSessionID: SessionID,
77
77
  crypto: CryptoProvider,
78
+ public readonly syncWhen?: SyncWhen,
78
79
  ) {
79
80
  this.agentSecret = agentSecret;
80
81
  this.currentSessionID = currentSessionID;
@@ -94,11 +95,13 @@ export class LocalNode {
94
95
 
95
96
  setStorage(storage: StorageAPI) {
96
97
  this.storage = storage;
98
+ this.syncManager.setStorage(storage);
97
99
  }
98
100
 
99
101
  removeStorage() {
100
102
  this.storage?.close();
101
103
  this.storage = undefined;
104
+ this.syncManager.removeStorage();
102
105
  }
103
106
 
104
107
  hasCoValue(id: RawCoID) {
@@ -178,12 +181,14 @@ export class LocalNode {
178
181
  crypto: CryptoProvider;
179
182
  initialAgentSecret?: AgentSecret;
180
183
  peers?: Peer[];
184
+ syncWhen?: SyncWhen;
181
185
  storage?: StorageAPI;
182
186
  }): RawAccount {
183
187
  const {
184
188
  crypto,
185
189
  initialAgentSecret = crypto.newRandomAgentSecret(),
186
190
  peers = [],
191
+ syncWhen,
187
192
  } = opts;
188
193
  const accountHeader = accountHeaderForInitialAgentSecret(
189
194
  initialAgentSecret,
@@ -195,6 +200,7 @@ export class LocalNode {
195
200
  initialAgentSecret,
196
201
  crypto.newRandomSessionID(accountID as RawAccountID),
197
202
  crypto,
203
+ syncWhen,
198
204
  );
199
205
 
200
206
  if (opts.storage) {
@@ -236,6 +242,7 @@ export class LocalNode {
236
242
  static async withNewlyCreatedAccount({
237
243
  creationProps,
238
244
  peers,
245
+ syncWhen,
239
246
  migration,
240
247
  crypto,
241
248
  initialAgentSecret = crypto.newRandomAgentSecret(),
@@ -243,6 +250,7 @@ export class LocalNode {
243
250
  }: {
244
251
  creationProps: { name: string };
245
252
  peers?: Peer[];
253
+ syncWhen?: SyncWhen;
246
254
  migration?: RawAccountMigration<AccountMeta>;
247
255
  crypto: CryptoProvider;
248
256
  initialAgentSecret?: AgentSecret;
@@ -257,6 +265,7 @@ export class LocalNode {
257
265
  crypto,
258
266
  initialAgentSecret,
259
267
  peers,
268
+ syncWhen,
260
269
  storage,
261
270
  });
262
271
  const node = account.core.node;
@@ -299,6 +308,7 @@ export class LocalNode {
299
308
  accountSecret,
300
309
  sessionID,
301
310
  peers,
311
+ syncWhen,
302
312
  crypto,
303
313
  migration,
304
314
  storage,
@@ -307,6 +317,7 @@ export class LocalNode {
307
317
  accountSecret: AgentSecret;
308
318
  sessionID: SessionID | undefined;
309
319
  peers: Peer[];
320
+ syncWhen?: SyncWhen;
310
321
  crypto: CryptoProvider;
311
322
  migration?: RawAccountMigration<AccountMeta>;
312
323
  storage?: StorageAPI;
@@ -318,6 +329,7 @@ export class LocalNode {
318
329
  accountSecret,
319
330
  sessionID || crypto.newRandomSessionID(accountID),
320
331
  crypto,
332
+ syncWhen,
321
333
  );
322
334
 
323
335
  if (storage) {
@@ -817,9 +829,9 @@ export class LocalNode {
817
829
  *
818
830
  * @returns Promise of the current pending store operation, if any.
819
831
  */
820
- gracefulShutdown(): Promise<unknown> | undefined {
821
- this.syncManager.gracefulShutdown();
832
+ async gracefulShutdown(): Promise<unknown> {
822
833
  this.garbageCollector?.stop();
834
+ await this.syncManager.gracefulShutdown();
823
835
  return this.storage?.close();
824
836
  }
825
837
  }
@@ -1,17 +1,15 @@
1
1
  import { Counter, ValueType, metrics } from "@opentelemetry/api";
2
2
  import type { PeerState } from "../PeerState.js";
3
- import { SYNC_SCHEDULER_CONFIG } from "../config.js";
4
- import { logger } from "../logger.js";
5
3
  import type { SyncMessage } from "../sync.js";
6
4
  import { LinkedList } from "./LinkedList.js";
7
5
 
8
6
  /**
9
- * A queue that schedules messages across different peers using a round-robin approach.
7
+ * A queue that manages incoming sync messages across different peers using a round-robin approach.
10
8
  *
11
9
  * This class manages incoming sync messages from multiple peers, ensuring fair processing
12
- * by cycling through each peer's message queue in a round-robin fashion. It also implements
13
- * collaborative scheduling on message processing, pausing when the main thread is blocked
14
- * for more than 50ms.
10
+ * by cycling through each peer's message queue in a round-robin fashion.
11
+ *
12
+ * Queue processing and scheduling is handled by SyncManager.processQueues().
15
13
  */
16
14
  export class IncomingMessagesQueue {
17
15
  private pullCounter: Counter;
@@ -21,7 +19,7 @@ export class IncomingMessagesQueue {
21
19
  peerToQueue: WeakMap<PeerState, LinkedList<SyncMessage>>;
22
20
  currentQueue = 0;
23
21
 
24
- constructor() {
22
+ constructor(private processQueues: () => void) {
25
23
  this.pullCounter = metrics
26
24
  .getMeter("cojson")
27
25
  .createCounter(`jazz.messagequeue.incoming.pulled`, {
@@ -74,6 +72,8 @@ export class IncomingMessagesQueue {
74
72
  this.pushCounter.add(1, {
75
73
  peerRole: peer.role,
76
74
  });
75
+
76
+ this.processQueues();
77
77
  }
78
78
 
79
79
  public pull() {
@@ -107,36 +107,4 @@ export class IncomingMessagesQueue {
107
107
 
108
108
  return undefined;
109
109
  }
110
-
111
- processing = false;
112
-
113
- async processQueue(callback: (msg: SyncMessage, peer: PeerState) => void) {
114
- this.processing = true;
115
-
116
- let entry: { msg: SyncMessage; peer: PeerState } | undefined;
117
- let lastTimer = performance.now();
118
-
119
- while ((entry = this.pull())) {
120
- const { msg, peer } = entry;
121
-
122
- try {
123
- callback(msg, peer);
124
- } catch (err) {
125
- logger.error("Error processing message", { err });
126
- }
127
-
128
- const currentTimer = performance.now();
129
-
130
- // We check if we have blocked the main thread for too long
131
- // and if so, we schedule a timer task to yield to the event loop
132
- if (
133
- currentTimer - lastTimer >
134
- SYNC_SCHEDULER_CONFIG.INCOMING_MESSAGES_TIME_BUDGET
135
- ) {
136
- await new Promise((resolve) => setTimeout(resolve, 0));
137
- }
138
- }
139
-
140
- this.processing = false;
141
- }
142
110
  }
@@ -108,7 +108,7 @@ class QueueMeter {
108
108
  }
109
109
  }
110
110
  export function meteredList<T>(
111
- type: "incoming" | "outgoing",
111
+ type: "incoming" | "outgoing" | "storage-streaming",
112
112
  attrs?: Record<string, string | number>,
113
113
  ) {
114
114
  return new LinkedList<T>(new QueueMeter("jazz.messagequeue." + type, attrs));
@@ -0,0 +1,96 @@
1
+ import { CO_VALUE_PRIORITY, type CoValuePriority } from "../priority.js";
2
+ import { LinkedList, meteredList } from "./LinkedList.js";
3
+
4
+ /**
5
+ * A callback that pushes content when invoked.
6
+ * Content is only fetched from the database when this callback is called.
7
+ */
8
+ export type ContentCallback = () => void;
9
+
10
+ // All priorities use the queue, processed in order: HIGH > MEDIUM > LOW
11
+ const PRIORITY_TO_QUEUE_INDEX = {
12
+ [CO_VALUE_PRIORITY.HIGH]: 0,
13
+ [CO_VALUE_PRIORITY.MEDIUM]: 1,
14
+ [CO_VALUE_PRIORITY.LOW]: 2,
15
+ } as const;
16
+
17
+ type StreamingQueueTuple = [
18
+ high: LinkedList<ContentCallback>,
19
+ medium: LinkedList<ContentCallback>,
20
+ low: LinkedList<ContentCallback>,
21
+ ];
22
+
23
+ /**
24
+ * A priority-based queue for storage content streaming.
25
+ *
26
+ * This queue manages content streaming for all priority levels (HIGH, MEDIUM, LOW).
27
+ * Content is processed in priority order: HIGH first, then MEDIUM, then LOW.
28
+ *
29
+ * Key features:
30
+ * - Stores callbacks to get content (lazy evaluation) rather than content itself
31
+ * - Priority-based ordering: HIGH > MEDIUM > LOW
32
+ */
33
+ export class StorageStreamingQueue {
34
+ private queues: StreamingQueueTuple;
35
+
36
+ constructor() {
37
+ this.queues = [
38
+ meteredList("storage-streaming", { priority: CO_VALUE_PRIORITY.HIGH }),
39
+ meteredList("storage-streaming", { priority: CO_VALUE_PRIORITY.MEDIUM }),
40
+ meteredList("storage-streaming", { priority: CO_VALUE_PRIORITY.LOW }),
41
+ ];
42
+ }
43
+
44
+ private getQueue(priority: CoValuePriority) {
45
+ return this.queues[PRIORITY_TO_QUEUE_INDEX[priority]];
46
+ }
47
+
48
+ /**
49
+ * Push a content callback to the queue with explicit priority.
50
+ * The callback will be invoked when the entry is pulled and processed.
51
+ *
52
+ * @param entry - Callback that pushes content when invoked
53
+ * @param priority - Priority for this entry (HIGH, MEDIUM, or LOW)
54
+ */
55
+ public push(entry: ContentCallback, priority: CoValuePriority): void {
56
+ this.getQueue(priority).push(entry);
57
+ }
58
+
59
+ /**
60
+ * Pull the next entry from the queue.
61
+ * Returns undefined if no entries are available.
62
+ * Priority order: HIGH > MEDIUM > LOW
63
+ */
64
+ public pull(): ContentCallback | undefined {
65
+ // Find the first non-empty queue (HIGH > MEDIUM > LOW)
66
+ const queueIndex = this.queues.findIndex((queue) => queue.length > 0);
67
+
68
+ if (queueIndex === -1) {
69
+ return undefined;
70
+ }
71
+
72
+ const entry = this.queues[queueIndex]?.shift();
73
+
74
+ if (!entry) {
75
+ return undefined;
76
+ }
77
+
78
+ return entry;
79
+ }
80
+
81
+ /**
82
+ * Check if the queue is empty (no pending entries).
83
+ */
84
+ public isEmpty(): boolean {
85
+ return this.queues.every((queue) => queue.length === 0);
86
+ }
87
+
88
+ private listener: (() => void) | undefined;
89
+ setListener(listener: () => void): void {
90
+ this.listener = listener;
91
+ }
92
+
93
+ emit(): void {
94
+ this.listener?.();
95
+ }
96
+ }
@@ -11,14 +11,14 @@ import {
11
11
  * and provides the API to wait for the data to be fully stored.
12
12
  */
13
13
  export class StorageKnownState {
14
- knwonStates = new Map<string, CoValueKnownState>();
14
+ knownStates = new Map<string, CoValueKnownState>();
15
15
 
16
16
  getKnownState(id: string): CoValueKnownState {
17
- const knownState = this.knwonStates.get(id);
17
+ const knownState = this.knownStates.get(id);
18
18
 
19
19
  if (!knownState) {
20
20
  const empty = emptyKnownState(id as RawCoID);
21
- this.knwonStates.set(id, empty);
21
+ this.knownStates.set(id, empty);
22
22
  return empty;
23
23
  }
24
24
 
@@ -26,7 +26,7 @@ export class StorageKnownState {
26
26
  }
27
27
 
28
28
  setKnownState(id: string, knownState: CoValueKnownState) {
29
- this.knwonStates.set(id, knownState);
29
+ this.knownStates.set(id, knownState);
30
30
  }
31
31
 
32
32
  handleUpdate(id: string, knownState: CoValueKnownState) {
@@ -4,6 +4,7 @@ import type {
4
4
  } from "../../coValueCore/verifiedState.js";
5
5
  import type { Signature } from "../../crypto/crypto.js";
6
6
  import type { RawCoID, SessionID } from "../../exports.js";
7
+ import type { PeerID } from "../../sync.js";
7
8
  import { logger } from "../../logger.js";
8
9
  import type {
9
10
  DBClientInterfaceSync,
@@ -193,4 +194,34 @@ export class SQLiteClient
193
194
  this.db.transaction(() => operationsCallback(this));
194
195
  return undefined;
195
196
  }
197
+
198
+ getUnsyncedCoValueIDs(): RawCoID[] {
199
+ const rows = this.db.query<{ co_value_id: RawCoID }>(
200
+ "SELECT DISTINCT co_value_id FROM unsynced_covalues",
201
+ [],
202
+ ) as { co_value_id: RawCoID }[];
203
+ return rows.map((row) => row.co_value_id);
204
+ }
205
+
206
+ trackCoValuesSyncState(
207
+ updates: { id: RawCoID; peerId: PeerID; synced: boolean }[],
208
+ ): void {
209
+ for (const update of updates) {
210
+ if (update.synced) {
211
+ this.db.run(
212
+ "DELETE FROM unsynced_covalues WHERE co_value_id = ? AND peer_id = ?",
213
+ [update.id, update.peerId],
214
+ );
215
+ } else {
216
+ this.db.run(
217
+ "INSERT OR REPLACE INTO unsynced_covalues (co_value_id, peer_id) VALUES (?, ?)",
218
+ [update.id, update.peerId],
219
+ );
220
+ }
221
+ }
222
+ }
223
+
224
+ stopTrackingSyncState(id: RawCoID): void {
225
+ this.db.run("DELETE FROM unsynced_covalues WHERE co_value_id = ?", [id]);
226
+ }
196
227
  }