cojson 0.20.8 → 0.20.10

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 (169) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/dist/PeerState.d.ts +2 -2
  4. package/dist/PeerState.d.ts.map +1 -1
  5. package/dist/PeerState.js +3 -3
  6. package/dist/PeerState.js.map +1 -1
  7. package/dist/StorageReconciliationAckTracker.d.ts +14 -0
  8. package/dist/StorageReconciliationAckTracker.d.ts.map +1 -0
  9. package/dist/StorageReconciliationAckTracker.js +72 -0
  10. package/dist/StorageReconciliationAckTracker.js.map +1 -0
  11. package/dist/SyncStateManager.js +2 -2
  12. package/dist/SyncStateManager.js.map +1 -1
  13. package/dist/coValueCore/coValueCore.d.ts +2 -1
  14. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  15. package/dist/coValueCore/coValueCore.js +43 -10
  16. package/dist/coValueCore/coValueCore.js.map +1 -1
  17. package/dist/coValues/coList.d.ts +2 -0
  18. package/dist/coValues/coList.d.ts.map +1 -1
  19. package/dist/coValues/coList.js +28 -0
  20. package/dist/coValues/coList.js.map +1 -1
  21. package/dist/coValues/group.d.ts +4 -1
  22. package/dist/coValues/group.d.ts.map +1 -1
  23. package/dist/coValues/group.js +15 -1
  24. package/dist/coValues/group.js.map +1 -1
  25. package/dist/config.d.ts +8 -0
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +14 -0
  28. package/dist/config.js.map +1 -1
  29. package/dist/exports.d.ts +9 -1
  30. package/dist/exports.d.ts.map +1 -1
  31. package/dist/exports.js +5 -1
  32. package/dist/exports.js.map +1 -1
  33. package/dist/localNode.d.ts +7 -3
  34. package/dist/localNode.d.ts.map +1 -1
  35. package/dist/localNode.js +13 -5
  36. package/dist/localNode.js.map +1 -1
  37. package/dist/permissions.d.ts +1 -0
  38. package/dist/permissions.d.ts.map +1 -1
  39. package/dist/queue/LinkedList.d.ts +2 -0
  40. package/dist/queue/LinkedList.d.ts.map +1 -1
  41. package/dist/queue/LinkedList.js +7 -0
  42. package/dist/queue/LinkedList.js.map +1 -1
  43. package/dist/queue/OutgoingLoadQueue.d.ts +4 -1
  44. package/dist/queue/OutgoingLoadQueue.d.ts.map +1 -1
  45. package/dist/queue/OutgoingLoadQueue.js +41 -13
  46. package/dist/queue/OutgoingLoadQueue.js.map +1 -1
  47. package/dist/queue/PriorityBasedMessageQueue.d.ts +1 -0
  48. package/dist/queue/PriorityBasedMessageQueue.d.ts.map +1 -1
  49. package/dist/queue/PriorityBasedMessageQueue.js +11 -1
  50. package/dist/queue/PriorityBasedMessageQueue.js.map +1 -1
  51. package/dist/storage/knownState.d.ts +2 -0
  52. package/dist/storage/knownState.d.ts.map +1 -1
  53. package/dist/storage/knownState.js +11 -0
  54. package/dist/storage/knownState.js.map +1 -1
  55. package/dist/storage/sqlite/client.d.ts +10 -1
  56. package/dist/storage/sqlite/client.d.ts.map +1 -1
  57. package/dist/storage/sqlite/client.js +84 -0
  58. package/dist/storage/sqlite/client.js.map +1 -1
  59. package/dist/storage/sqlite/sqliteMigrations.d.ts.map +1 -1
  60. package/dist/storage/sqlite/sqliteMigrations.js +11 -0
  61. package/dist/storage/sqlite/sqliteMigrations.js.map +1 -1
  62. package/dist/storage/sqliteAsync/client.d.ts +10 -1
  63. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  64. package/dist/storage/sqliteAsync/client.js +86 -0
  65. package/dist/storage/sqliteAsync/client.js.map +1 -1
  66. package/dist/storage/storageAsync.d.ts +9 -2
  67. package/dist/storage/storageAsync.d.ts.map +1 -1
  68. package/dist/storage/storageAsync.js +19 -0
  69. package/dist/storage/storageAsync.js.map +1 -1
  70. package/dist/storage/storageSync.d.ts +9 -2
  71. package/dist/storage/storageSync.d.ts.map +1 -1
  72. package/dist/storage/storageSync.js +20 -13
  73. package/dist/storage/storageSync.js.map +1 -1
  74. package/dist/storage/types.d.ts +64 -0
  75. package/dist/storage/types.d.ts.map +1 -1
  76. package/dist/storage/types.js.map +1 -1
  77. package/dist/sync.d.ts +44 -2
  78. package/dist/sync.d.ts.map +1 -1
  79. package/dist/sync.js +268 -44
  80. package/dist/sync.js.map +1 -1
  81. package/dist/tests/OutgoingLoadQueue.test.js +137 -39
  82. package/dist/tests/OutgoingLoadQueue.test.js.map +1 -1
  83. package/dist/tests/SQLiteClientAsync.test.js +1 -1
  84. package/dist/tests/SQLiteClientAsync.test.js.map +1 -1
  85. package/dist/tests/StorageApiAsync.test.js +138 -0
  86. package/dist/tests/StorageApiAsync.test.js.map +1 -1
  87. package/dist/tests/StorageApiSync.test.js +154 -0
  88. package/dist/tests/StorageApiSync.test.js.map +1 -1
  89. package/dist/tests/StorageReconciliationAckTracker.test.d.ts +2 -0
  90. package/dist/tests/StorageReconciliationAckTracker.test.d.ts.map +1 -0
  91. package/dist/tests/StorageReconciliationAckTracker.test.js +74 -0
  92. package/dist/tests/StorageReconciliationAckTracker.test.js.map +1 -0
  93. package/dist/tests/SyncStateManager.test.js +18 -0
  94. package/dist/tests/SyncStateManager.test.js.map +1 -1
  95. package/dist/tests/coList.test.js +112 -1
  96. package/dist/tests/coList.test.js.map +1 -1
  97. package/dist/tests/coValueCore.loadFromStorage.test.js +36 -0
  98. package/dist/tests/coValueCore.loadFromStorage.test.js.map +1 -1
  99. package/dist/tests/group.test.js +44 -0
  100. package/dist/tests/group.test.js.map +1 -1
  101. package/dist/tests/knownState.lazyLoading.test.js +6 -0
  102. package/dist/tests/knownState.lazyLoading.test.js.map +1 -1
  103. package/dist/tests/messagesTestUtils.d.ts.map +1 -1
  104. package/dist/tests/messagesTestUtils.js +4 -0
  105. package/dist/tests/messagesTestUtils.js.map +1 -1
  106. package/dist/tests/sync.concurrentLoad.test.js +333 -1
  107. package/dist/tests/sync.concurrentLoad.test.js.map +1 -1
  108. package/dist/tests/sync.garbageCollection.test.js +4 -0
  109. package/dist/tests/sync.garbageCollection.test.js.map +1 -1
  110. package/dist/tests/sync.load.test.js +19 -0
  111. package/dist/tests/sync.load.test.js.map +1 -1
  112. package/dist/tests/sync.mesh.test.js +1 -0
  113. package/dist/tests/sync.mesh.test.js.map +1 -1
  114. package/dist/tests/sync.multipleServers.test.js +41 -3
  115. package/dist/tests/sync.multipleServers.test.js.map +1 -1
  116. package/dist/tests/sync.storage.test.js +2 -0
  117. package/dist/tests/sync.storage.test.js.map +1 -1
  118. package/dist/tests/sync.storageAsync.test.js +1 -0
  119. package/dist/tests/sync.storageAsync.test.js.map +1 -1
  120. package/dist/tests/sync.storageReconciliation.test.d.ts +2 -0
  121. package/dist/tests/sync.storageReconciliation.test.d.ts.map +1 -0
  122. package/dist/tests/sync.storageReconciliation.test.js +501 -0
  123. package/dist/tests/sync.storageReconciliation.test.js.map +1 -0
  124. package/dist/tests/testUtils.d.ts +1 -0
  125. package/dist/tests/testUtils.d.ts.map +1 -1
  126. package/dist/tests/testUtils.js +3 -2
  127. package/dist/tests/testUtils.js.map +1 -1
  128. package/package.json +4 -4
  129. package/src/PeerState.ts +10 -3
  130. package/src/StorageReconciliationAckTracker.ts +83 -0
  131. package/src/SyncStateManager.ts +3 -3
  132. package/src/coValueCore/coValueCore.ts +47 -16
  133. package/src/coValues/coList.ts +23 -0
  134. package/src/coValues/group.ts +18 -0
  135. package/src/config.ts +18 -0
  136. package/src/exports.ts +8 -0
  137. package/src/localNode.ts +18 -0
  138. package/src/permissions.ts +1 -1
  139. package/src/queue/LinkedList.ts +10 -0
  140. package/src/queue/OutgoingLoadQueue.ts +57 -15
  141. package/src/queue/PriorityBasedMessageQueue.ts +15 -1
  142. package/src/storage/knownState.ts +14 -0
  143. package/src/storage/sqlite/client.ts +128 -0
  144. package/src/storage/sqlite/sqliteMigrations.ts +11 -0
  145. package/src/storage/sqliteAsync/client.ts +139 -0
  146. package/src/storage/storageAsync.ts +37 -0
  147. package/src/storage/storageSync.ts +41 -16
  148. package/src/storage/types.ts +110 -0
  149. package/src/sync.ts +311 -14
  150. package/src/tests/OutgoingLoadQueue.test.ts +226 -59
  151. package/src/tests/SQLiteClientAsync.test.ts +1 -1
  152. package/src/tests/StorageApiAsync.test.ts +161 -1
  153. package/src/tests/StorageApiSync.test.ts +176 -0
  154. package/src/tests/StorageReconciliationAckTracker.test.ts +99 -0
  155. package/src/tests/SyncStateManager.test.ts +25 -0
  156. package/src/tests/coList.test.ts +138 -0
  157. package/src/tests/coValueCore.loadFromStorage.test.ts +72 -1
  158. package/src/tests/group.test.ts +87 -0
  159. package/src/tests/knownState.lazyLoading.test.ts +36 -1
  160. package/src/tests/messagesTestUtils.ts +4 -0
  161. package/src/tests/sync.concurrentLoad.test.ts +491 -0
  162. package/src/tests/sync.garbageCollection.test.ts +4 -0
  163. package/src/tests/sync.load.test.ts +26 -0
  164. package/src/tests/sync.mesh.test.ts +1 -0
  165. package/src/tests/sync.multipleServers.test.ts +60 -2
  166. package/src/tests/sync.storage.test.ts +2 -0
  167. package/src/tests/sync.storageAsync.test.ts +1 -0
  168. package/src/tests/sync.storageReconciliation.test.ts +697 -0
  169. package/src/tests/testUtils.ts +10 -1
@@ -1,3 +1,4 @@
1
+ import { UpDownCounter, ValueType, metrics } from "@opentelemetry/api";
1
2
  import { CO_VALUE_LOADING_CONFIG } from "../config.js";
2
3
  import { CoValueCore } from "../exports.js";
3
4
  import type { RawCoID } from "../ids.js";
@@ -10,6 +11,11 @@ interface PendingLoad {
10
11
  sendCallback: () => void;
11
12
  }
12
13
 
14
+ interface InFlightLoad {
15
+ value: CoValueCore;
16
+ sentAt: number;
17
+ }
18
+
13
19
  /**
14
20
  * Mode for enqueuing load requests:
15
21
  * - "high-priority" (default): high priority, processed in order
@@ -17,6 +23,7 @@ interface PendingLoad {
17
23
  * - "immediate": bypasses the queue entirely, executes immediately
18
24
  */
19
25
  export type LoadMode = "low-priority" | "immediate" | "high-priority";
26
+ export type LoadCompletionSource = "content" | "known";
20
27
 
21
28
  /**
22
29
  * A queue that manages outgoing load requests with throttling.
@@ -28,7 +35,8 @@ export type LoadMode = "low-priority" | "immediate" | "high-priority";
28
35
  * - Manages timeouts for in-flight loads with a single timer
29
36
  */
30
37
  export class OutgoingLoadQueue {
31
- private inFlightLoads: Map<CoValueCore, number> = new Map();
38
+ private inFlightLoads: Map<RawCoID, InFlightLoad> = new Map();
39
+ private inFlightCounter: UpDownCounter;
32
40
  private highPriorityPending: LinkedList<PendingLoad> = meteredList(
33
41
  "load-requests-queue",
34
42
  { priority: "high" },
@@ -49,7 +57,18 @@ export class OutgoingLoadQueue {
49
57
  new Map();
50
58
  private timeoutHandle: ReturnType<typeof setTimeout> | null = null;
51
59
 
52
- constructor(private peerId: PeerID) {}
60
+ constructor(private peerId: PeerID) {
61
+ this.inFlightCounter = metrics
62
+ .getMeter("cojson")
63
+ .createUpDownCounter("jazz.loadqueue.outgoing.inflight", {
64
+ description: "Number of in-flight outgoing load requests",
65
+ unit: "1",
66
+ valueType: ValueType.INT,
67
+ });
68
+
69
+ // Emit an initial 0 value so the series appears immediately.
70
+ this.inFlightCounter.add(0);
71
+ }
53
72
 
54
73
  /**
55
74
  * Check if we can send another load request.
@@ -66,10 +85,20 @@ export class OutgoingLoadQueue {
66
85
  */
67
86
  private trackSent(coValue: CoValueCore): void {
68
87
  const now = performance.now();
69
- this.inFlightLoads.set(coValue, now);
88
+ this.inFlightLoads.set(coValue.id, { value: coValue, sentAt: now });
89
+ this.inFlightCounter.add(1);
70
90
  this.scheduleTimeoutCheck(CO_VALUE_LOADING_CONFIG.TIMEOUT);
71
91
  }
72
92
 
93
+ private untrackInFlight(id: RawCoID): boolean {
94
+ if (!this.inFlightLoads.delete(id)) {
95
+ return false;
96
+ }
97
+
98
+ this.inFlightCounter.add(-1);
99
+ return true;
100
+ }
101
+
73
102
  /**
74
103
  * Schedule a timeout check if not already scheduled.
75
104
  * Uses a single timer to check all in-flight loads.
@@ -92,7 +121,7 @@ export class OutgoingLoadQueue {
92
121
  const now = performance.now();
93
122
 
94
123
  let nextTimeout: number | undefined;
95
- for (const [coValue, sentAt] of this.inFlightLoads.entries()) {
124
+ for (const { value: coValue, sentAt } of this.inFlightLoads.values()) {
96
125
  const timeout = sentAt + CO_VALUE_LOADING_CONFIG.TIMEOUT;
97
126
 
98
127
  if (now >= timeout) {
@@ -101,7 +130,8 @@ export class OutgoingLoadQueue {
101
130
  id: coValue.id,
102
131
  peerId: this.peerId,
103
132
  });
104
- coValue.markNotFoundInPeer(this.peerId);
133
+ // Re-resolve by ID to avoid mutating a stale CoValue instance.
134
+ coValue.node.getCoValue(coValue.id).markNotFoundInPeer(this.peerId);
105
135
  } else if (coValue.isStreaming()) {
106
136
  logger.warn(
107
137
  "Content streaming is taking more than " +
@@ -116,8 +146,9 @@ export class OutgoingLoadQueue {
116
146
  );
117
147
  }
118
148
 
119
- this.inFlightLoads.delete(coValue);
120
- this.processQueue();
149
+ if (this.untrackInFlight(coValue.id)) {
150
+ this.processQueue();
151
+ }
121
152
  } else {
122
153
  nextTimeout = Math.min(nextTimeout ?? Infinity, timeout - now);
123
154
  }
@@ -130,30 +161,37 @@ export class OutgoingLoadQueue {
130
161
  }
131
162
 
132
163
  trackUpdate(coValue: CoValueCore): void {
133
- if (!this.inFlightLoads.has(coValue)) {
164
+ if (!this.inFlightLoads.has(coValue.id)) {
134
165
  return;
135
166
  }
136
167
 
137
168
  // Refresh the timeout for the in-flight load
138
- this.inFlightLoads.set(coValue, performance.now());
169
+ this.inFlightLoads.set(coValue.id, {
170
+ value: coValue,
171
+ sentAt: performance.now(),
172
+ });
139
173
  }
140
174
 
141
175
  /**
142
176
  * Track that a load request has completed.
143
177
  * Triggers processing of pending requests.
144
178
  */
145
- trackComplete(coValue: CoValueCore): void {
146
- if (!this.inFlightLoads.has(coValue)) {
179
+ trackComplete(
180
+ coValue: CoValueCore,
181
+ source: LoadCompletionSource = "content",
182
+ ): void {
183
+ if (!this.inFlightLoads.has(coValue.id)) {
147
184
  return;
148
185
  }
149
186
 
150
- if (coValue.isStreaming()) {
187
+ if (source === "content" && coValue.isStreaming()) {
151
188
  // wait for the next chunk
152
189
  return;
153
190
  }
154
191
 
155
- this.inFlightLoads.delete(coValue);
156
- this.processQueue();
192
+ if (this.untrackInFlight(coValue.id)) {
193
+ this.processQueue();
194
+ }
157
195
  }
158
196
 
159
197
  /**
@@ -170,7 +208,7 @@ export class OutgoingLoadQueue {
170
208
  sendCallback: () => void,
171
209
  mode: LoadMode = "high-priority",
172
210
  ): void {
173
- if (this.inFlightLoads.has(value)) {
211
+ if (this.inFlightLoads.has(value.id)) {
174
212
  return;
175
213
  }
176
214
 
@@ -270,7 +308,11 @@ export class OutgoingLoadQueue {
270
308
  clearTimeout(this.timeoutHandle);
271
309
  this.timeoutHandle = null;
272
310
  }
311
+ const inFlightCount = this.inFlightLoads.size;
273
312
  this.inFlightLoads.clear();
313
+ if (inFlightCount > 0) {
314
+ this.inFlightCounter.add(-inFlightCount);
315
+ }
274
316
 
275
317
  // Drain existing queues to balance push/pull metrics
276
318
  while (this.highPriorityPending.shift()) {}
@@ -32,7 +32,11 @@ export class PriorityBasedMessageQueue {
32
32
  }
33
33
 
34
34
  public push(msg: SyncMessage) {
35
- const priority = "priority" in msg ? msg.priority : this.defaultPriority;
35
+ let priority = "priority" in msg ? msg.priority : this.defaultPriority;
36
+
37
+ if (msg.action === "reconcile") {
38
+ priority = CO_VALUE_PRIORITY.LOW;
39
+ }
36
40
 
37
41
  this.getQueue(priority).push(msg);
38
42
  }
@@ -42,4 +46,14 @@ export class PriorityBasedMessageQueue {
42
46
 
43
47
  return this.queues[priority]?.shift();
44
48
  }
49
+
50
+ public trackPushPull(msg: SyncMessage) {
51
+ let priority = "priority" in msg ? msg.priority : this.defaultPriority;
52
+
53
+ if (msg.action === "reconcile") {
54
+ priority = CO_VALUE_PRIORITY.LOW;
55
+ }
56
+
57
+ this.getQueue(priority).trackPushPull();
58
+ }
45
59
  }
@@ -89,6 +89,20 @@ export class StorageKnownState {
89
89
  requests.add(req);
90
90
  });
91
91
  }
92
+
93
+ deleteKnownState(id: string) {
94
+ this.knownStates.delete(id);
95
+
96
+ for (const request of this.waitForSyncRequests.get(id) || []) {
97
+ request.resolve();
98
+ }
99
+ this.waitForSyncRequests.delete(id);
100
+ }
101
+
102
+ clear() {
103
+ this.knownStates.clear();
104
+ this.waitForSyncRequests.clear();
105
+ }
92
106
  }
93
107
 
94
108
  function isInSync(
@@ -12,12 +12,15 @@ import type {
12
12
  DBTransactionInterfaceSync,
13
13
  SessionRow,
14
14
  SignatureAfterRow,
15
+ StorageReconciliationLockRow,
15
16
  StoredCoValueRow,
16
17
  StoredSessionRow,
17
18
  TransactionRow,
19
+ StorageReconciliationAcquireResult,
18
20
  } from "../types.js";
19
21
  import { DeletedCoValueDeletionStatus } from "../types.js";
20
22
  import type { SQLiteDatabaseDriver } from "./types.js";
23
+ import { STORAGE_RECONCILIATION_CONFIG } from "../../config.js";
21
24
 
22
25
  export type RawCoValueRow = {
23
26
  id: RawCoID;
@@ -268,11 +271,55 @@ export class SQLiteClient
268
271
  );
269
272
  }
270
273
 
274
+ getStorageReconciliationLock(
275
+ key: string,
276
+ ): StorageReconciliationLockRow | undefined {
277
+ return this.db.get<StorageReconciliationLockRow>(
278
+ "SELECT * FROM storageReconciliationLocks WHERE key = ?",
279
+ [key],
280
+ );
281
+ }
282
+
283
+ putStorageReconciliationLock(entry: StorageReconciliationLockRow): void {
284
+ const {
285
+ key,
286
+ holderSessionId,
287
+ acquiredAt,
288
+ releasedAt,
289
+ lastProcessedOffset,
290
+ } = entry;
291
+ this.db.run(
292
+ `INSERT OR REPLACE INTO storageReconciliationLocks (key, holderSessionId, acquiredAt, releasedAt, lastProcessedOffset) VALUES (?, ?, ?, ?, ?)`,
293
+ [
294
+ key,
295
+ holderSessionId,
296
+ acquiredAt,
297
+ releasedAt ?? null,
298
+ lastProcessedOffset,
299
+ ],
300
+ );
301
+ }
302
+
271
303
  transaction(operationsCallback: (tx: DBTransactionInterfaceSync) => unknown) {
272
304
  this.db.transaction(() => operationsCallback(this));
273
305
  return undefined;
274
306
  }
275
307
 
308
+ getCoValueIDs(limit: number, offset: number): { id: RawCoID }[] {
309
+ return this.db.query<{ id: RawCoID }>(
310
+ "SELECT id FROM coValues WHERE rowID > ? ORDER BY rowID LIMIT ?",
311
+ [offset, limit],
312
+ );
313
+ }
314
+
315
+ getCoValueCount(): number {
316
+ const row = this.db.get<{ count: number }>(
317
+ "SELECT COUNT(*) as count FROM coValues",
318
+ [],
319
+ );
320
+ return row?.count ?? 0;
321
+ }
322
+
276
323
  getUnsyncedCoValueIDs(): RawCoID[] {
277
324
  const rows = this.db.query<{ co_value_id: RawCoID }>(
278
325
  "SELECT DISTINCT co_value_id FROM unsynced_covalues",
@@ -303,6 +350,87 @@ export class SQLiteClient
303
350
  this.db.run("DELETE FROM unsynced_covalues WHERE co_value_id = ?", [id]);
304
351
  }
305
352
 
353
+ tryAcquireStorageReconciliationLock(
354
+ sessionId: SessionID,
355
+ peerId: PeerID,
356
+ ): StorageReconciliationAcquireResult {
357
+ let result: StorageReconciliationAcquireResult = {
358
+ acquired: false,
359
+ reason: "not_due",
360
+ };
361
+ this.transaction(() => {
362
+ const now = Date.now();
363
+ const lockKey = `lock#${peerId}`;
364
+ const lockRow = this.getStorageReconciliationLock(lockKey);
365
+ if (
366
+ lockRow?.releasedAt &&
367
+ now - lockRow.releasedAt <
368
+ STORAGE_RECONCILIATION_CONFIG.RECONCILIATION_INTERVAL_MS
369
+ ) {
370
+ result = { acquired: false, reason: "not_due" };
371
+ return;
372
+ }
373
+ const expiresAt = lockRow
374
+ ? lockRow.acquiredAt + STORAGE_RECONCILIATION_CONFIG.LOCK_TTL_MS
375
+ : 0;
376
+ const isLockHeldByOtherSession = lockRow?.holderSessionId !== sessionId;
377
+ if (
378
+ lockRow &&
379
+ !lockRow.releasedAt &&
380
+ expiresAt >= now &&
381
+ isLockHeldByOtherSession
382
+ ) {
383
+ result = { acquired: false, reason: "lock_held" };
384
+ return;
385
+ }
386
+
387
+ const lastProcessedOffset =
388
+ lockRow && !lockRow.releasedAt ? (lockRow.lastProcessedOffset ?? 0) : 0;
389
+ this.putStorageReconciliationLock({
390
+ key: lockKey,
391
+ holderSessionId: sessionId,
392
+ acquiredAt: now,
393
+ lastProcessedOffset,
394
+ });
395
+ result = { acquired: true, lastProcessedOffset };
396
+ });
397
+ return result;
398
+ }
399
+
400
+ renewStorageReconciliationLock(
401
+ sessionId: SessionID,
402
+ peerId: PeerID,
403
+ offset: number,
404
+ ): void {
405
+ const lockKey = `lock#${peerId}`;
406
+ const lockRow = this.getStorageReconciliationLock(lockKey);
407
+ if (
408
+ lockRow &&
409
+ lockRow.holderSessionId === sessionId &&
410
+ !lockRow.releasedAt
411
+ ) {
412
+ this.putStorageReconciliationLock({
413
+ ...lockRow,
414
+ lastProcessedOffset: offset,
415
+ });
416
+ }
417
+ }
418
+
419
+ releaseStorageReconciliationLock(sessionId: SessionID, peerId: PeerID): void {
420
+ this.transaction(() => {
421
+ const lockKey = `lock#${peerId}`;
422
+ const releasedAt = Date.now();
423
+ const lockRow = this.getStorageReconciliationLock(lockKey);
424
+ if (lockRow?.holderSessionId === sessionId) {
425
+ this.putStorageReconciliationLock({
426
+ ...lockRow,
427
+ releasedAt,
428
+ lastProcessedOffset: 0,
429
+ });
430
+ }
431
+ });
432
+ }
433
+
306
434
  getCoValueKnownState(coValueId: string): CoValueKnownState | undefined {
307
435
  // First check if the CoValue exists
308
436
  const coValueRow = this.db.get<{ rowID: number }>(
@@ -47,6 +47,17 @@ export const migrations: Record<number, string[]> = {
47
47
  ) WITHOUT ROWID;`,
48
48
  "CREATE INDEX IF NOT EXISTS deletedCoValuesByStatus ON deletedCoValues (status);",
49
49
  ],
50
+ 6: [
51
+ `CREATE TABLE IF NOT EXISTS storageReconciliationLocks (
52
+ key TEXT PRIMARY KEY,
53
+ holderSessionId TEXT NOT NULL,
54
+ acquiredAt INTEGER NOT NULL,
55
+ expiresAt INTEGER NOT NULL,
56
+ lastProcessedOffset INTEGER NOT NULL DEFAULT 0,
57
+ releasedAt INTEGER
58
+ ) WITHOUT ROWID;`,
59
+ ],
60
+ 7: ["ALTER TABLE storageReconciliationLocks DROP COLUMN expiresAt;"],
50
61
  };
51
62
 
52
63
  type Migration = {
@@ -11,13 +11,16 @@ import type {
11
11
  DBTransactionInterfaceAsync,
12
12
  SessionRow,
13
13
  SignatureAfterRow,
14
+ StorageReconciliationLockRow,
14
15
  StoredCoValueRow,
15
16
  StoredSessionRow,
16
17
  TransactionRow,
18
+ StorageReconciliationAcquireResult,
17
19
  } from "../types.js";
18
20
  import { DeletedCoValueDeletionStatus } from "../types.js";
19
21
  import type { SQLiteDatabaseDriverAsync } from "./types.js";
20
22
  import type { PeerID } from "../../sync.js";
23
+ import { STORAGE_RECONCILIATION_CONFIG } from "../../config.js";
21
24
 
22
25
  export type RawCoValueRow = {
23
26
  id: RawCoID;
@@ -153,6 +156,37 @@ export class SQLiteTransactionAsync implements DBTransactionInterfaceAsync {
153
156
  ],
154
157
  );
155
158
  }
159
+
160
+ async getStorageReconciliationLock(
161
+ key: string,
162
+ ): Promise<StorageReconciliationLockRow | undefined> {
163
+ return this.tx.get<StorageReconciliationLockRow>(
164
+ "SELECT * FROM storageReconciliationLocks WHERE key = ?",
165
+ [key],
166
+ );
167
+ }
168
+
169
+ async putStorageReconciliationLock(
170
+ entry: StorageReconciliationLockRow,
171
+ ): Promise<void> {
172
+ const {
173
+ key,
174
+ holderSessionId,
175
+ acquiredAt,
176
+ releasedAt,
177
+ lastProcessedOffset,
178
+ } = entry;
179
+ await this.tx.run(
180
+ `INSERT OR REPLACE INTO storageReconciliationLocks (key, holderSessionId, acquiredAt, releasedAt, lastProcessedOffset) VALUES (?, ?, ?, ?, ?)`,
181
+ [
182
+ key,
183
+ holderSessionId,
184
+ acquiredAt,
185
+ releasedAt ?? null,
186
+ lastProcessedOffset,
187
+ ],
188
+ );
189
+ }
156
190
  }
157
191
 
158
192
  export class SQLiteClientAsync implements DBClientInterfaceAsync {
@@ -334,6 +368,111 @@ export class SQLiteClientAsync implements DBClientInterfaceAsync {
334
368
  ]);
335
369
  }
336
370
 
371
+ async getCoValueIDs(
372
+ limit: number,
373
+ offset: number,
374
+ ): Promise<{ id: RawCoID }[]> {
375
+ return this.db.query<{ id: RawCoID }>(
376
+ "SELECT id FROM coValues WHERE rowID > ? ORDER BY rowID LIMIT ?",
377
+ [offset, limit],
378
+ );
379
+ }
380
+
381
+ async getCoValueCount(): Promise<number> {
382
+ const row = await this.db.get<{ count: number }>(
383
+ "SELECT COUNT(*) as count FROM coValues",
384
+ [],
385
+ );
386
+ return row?.count ?? 0;
387
+ }
388
+
389
+ async tryAcquireStorageReconciliationLock(
390
+ sessionId: SessionID,
391
+ peerId: PeerID,
392
+ ): Promise<StorageReconciliationAcquireResult> {
393
+ let result: StorageReconciliationAcquireResult = {
394
+ acquired: false,
395
+ reason: "not_due",
396
+ };
397
+ await this.transaction(async (tx) => {
398
+ const now = Date.now();
399
+ const lockKey = `lock#${peerId}`;
400
+
401
+ const lockRow = await tx.getStorageReconciliationLock(lockKey);
402
+ if (
403
+ lockRow?.releasedAt &&
404
+ now - lockRow.releasedAt <
405
+ STORAGE_RECONCILIATION_CONFIG.RECONCILIATION_INTERVAL_MS
406
+ ) {
407
+ result = { acquired: false, reason: "not_due" };
408
+ return;
409
+ }
410
+ const expiresAt = lockRow
411
+ ? lockRow.acquiredAt + STORAGE_RECONCILIATION_CONFIG.LOCK_TTL_MS
412
+ : 0;
413
+ const isLockHeldByOtherSession = lockRow?.holderSessionId !== sessionId;
414
+ if (
415
+ lockRow &&
416
+ !lockRow.releasedAt &&
417
+ expiresAt >= now &&
418
+ isLockHeldByOtherSession
419
+ ) {
420
+ result = { acquired: false, reason: "lock_held" };
421
+ return;
422
+ }
423
+
424
+ const lastProcessedOffset =
425
+ lockRow && !lockRow.releasedAt ? (lockRow.lastProcessedOffset ?? 0) : 0;
426
+ await tx.putStorageReconciliationLock({
427
+ key: lockKey,
428
+ holderSessionId: sessionId,
429
+ acquiredAt: now,
430
+ lastProcessedOffset,
431
+ });
432
+ result = { acquired: true, lastProcessedOffset };
433
+ });
434
+ return result;
435
+ }
436
+
437
+ async renewStorageReconciliationLock(
438
+ sessionId: SessionID,
439
+ peerId: PeerID,
440
+ offset: number,
441
+ ): Promise<void> {
442
+ await this.transaction(async (tx) => {
443
+ const lockKey = `lock#${peerId}`;
444
+ const lockRow = await tx.getStorageReconciliationLock(lockKey);
445
+ if (
446
+ lockRow &&
447
+ lockRow.holderSessionId === sessionId &&
448
+ !lockRow.releasedAt
449
+ ) {
450
+ await tx.putStorageReconciliationLock({
451
+ ...lockRow,
452
+ lastProcessedOffset: offset,
453
+ });
454
+ }
455
+ });
456
+ }
457
+
458
+ async releaseStorageReconciliationLock(
459
+ sessionId: SessionID,
460
+ peerId: PeerID,
461
+ ): Promise<void> {
462
+ await this.transaction(async (tx) => {
463
+ const lockKey = `lock#${peerId}`;
464
+ const releasedAt = Date.now();
465
+ const lockRow = await tx.getStorageReconciliationLock(lockKey);
466
+ if (lockRow && lockRow.holderSessionId === sessionId) {
467
+ await tx.putStorageReconciliationLock({
468
+ ...lockRow,
469
+ releasedAt,
470
+ lastProcessedOffset: 0,
471
+ });
472
+ }
473
+ });
474
+ }
475
+
337
476
  async getCoValueKnownState(
338
477
  coValueId: string,
339
478
  ): Promise<CoValueKnownState | undefined> {
@@ -30,6 +30,7 @@ import type {
30
30
  SignatureAfterRow,
31
31
  StoredCoValueRow,
32
32
  StoredSessionRow,
33
+ StorageReconciliationAcquireResult,
33
34
  } from "./types.js";
34
35
  import { isDeleteSessionID } from "../ids.js";
35
36
 
@@ -517,6 +518,40 @@ export class StorageApiAsync implements StorageAPI {
517
518
  this.dbClient.trackCoValuesSyncState(updates).then(() => done?.());
518
519
  }
519
520
 
521
+ getCoValueIDs(
522
+ limit: number,
523
+ offset: number,
524
+ callback: (batch: { id: RawCoID }[]) => void,
525
+ ): void {
526
+ this.dbClient.getCoValueIDs(limit, offset).then(callback);
527
+ }
528
+
529
+ getCoValueCount(callback: (count: number) => void): void {
530
+ this.dbClient.getCoValueCount().then(callback);
531
+ }
532
+
533
+ tryAcquireStorageReconciliationLock(
534
+ sessionId: SessionID,
535
+ peerId: PeerID,
536
+ callback: (result: StorageReconciliationAcquireResult) => void,
537
+ ): void {
538
+ this.dbClient
539
+ .tryAcquireStorageReconciliationLock(sessionId, peerId)
540
+ .then(callback);
541
+ }
542
+
543
+ renewStorageReconciliationLock(
544
+ sessionId: SessionID,
545
+ peerId: PeerID,
546
+ offset: number,
547
+ ): void {
548
+ this.dbClient.renewStorageReconciliationLock(sessionId, peerId, offset);
549
+ }
550
+
551
+ releaseStorageReconciliationLock(sessionId: SessionID, peerId: PeerID): void {
552
+ this.dbClient.releaseStorageReconciliationLock(sessionId, peerId);
553
+ }
554
+
520
555
  getUnsyncedCoValueIDs(
521
556
  callback: (unsyncedCoValueIDs: RawCoID[]) => void,
522
557
  ): void {
@@ -529,11 +564,13 @@ export class StorageApiAsync implements StorageAPI {
529
564
 
530
565
  onCoValueUnmounted(id: RawCoID): void {
531
566
  this.inMemoryCoValues.delete(id);
567
+ this.knownStates.deleteKnownState(id);
532
568
  }
533
569
 
534
570
  close() {
535
571
  this.deletedCoValuesEraserScheduler?.dispose();
536
572
  this.inMemoryCoValues.clear();
573
+ this.knownStates.clear();
537
574
  return this.storeQueue.close();
538
575
  }
539
576
  }