cojson 0.20.17 → 0.20.19

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 (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +22 -0
  3. package/dist/ClockOffset.d.ts +21 -0
  4. package/dist/ClockOffset.d.ts.map +1 -0
  5. package/dist/ClockOffset.js +55 -0
  6. package/dist/ClockOffset.js.map +1 -0
  7. package/dist/coValueCore/coValueCore.js +2 -2
  8. package/dist/coValueCore/coValueCore.js.map +1 -1
  9. package/dist/exports.d.ts +2 -2
  10. package/dist/exports.d.ts.map +1 -1
  11. package/dist/exports.js.map +1 -1
  12. package/dist/localNode.d.ts +21 -4
  13. package/dist/localNode.d.ts.map +1 -1
  14. package/dist/localNode.js +44 -6
  15. package/dist/localNode.js.map +1 -1
  16. package/dist/queue/StoreQueue.d.ts +3 -2
  17. package/dist/queue/StoreQueue.d.ts.map +1 -1
  18. package/dist/queue/StoreQueue.js +4 -4
  19. package/dist/queue/StoreQueue.js.map +1 -1
  20. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  21. package/dist/storage/sqliteAsync/client.js +17 -2
  22. package/dist/storage/sqliteAsync/client.js.map +1 -1
  23. package/dist/storage/storageAsync.d.ts +1 -1
  24. package/dist/storage/storageAsync.d.ts.map +1 -1
  25. package/dist/storage/storageAsync.js +8 -4
  26. package/dist/storage/storageAsync.js.map +1 -1
  27. package/dist/storage/storageSync.d.ts +1 -1
  28. package/dist/storage/storageSync.d.ts.map +1 -1
  29. package/dist/storage/storageSync.js +6 -2
  30. package/dist/storage/storageSync.js.map +1 -1
  31. package/dist/storage/types.d.ts +9 -1
  32. package/dist/storage/types.d.ts.map +1 -1
  33. package/dist/sync.d.ts +21 -0
  34. package/dist/sync.d.ts.map +1 -1
  35. package/dist/sync.js +41 -3
  36. package/dist/sync.js.map +1 -1
  37. package/dist/tests/ClockOffset.test.d.ts +2 -0
  38. package/dist/tests/ClockOffset.test.d.ts.map +1 -0
  39. package/dist/tests/ClockOffset.test.js +146 -0
  40. package/dist/tests/ClockOffset.test.js.map +1 -0
  41. package/dist/tests/SQLiteClientAsync.test.js +43 -8
  42. package/dist/tests/SQLiteClientAsync.test.js.map +1 -1
  43. package/dist/tests/StoreQueue.test.js +16 -3
  44. package/dist/tests/StoreQueue.test.js.map +1 -1
  45. package/dist/tests/clockDrift.integration.test.d.ts +2 -0
  46. package/dist/tests/clockDrift.integration.test.d.ts.map +1 -0
  47. package/dist/tests/clockDrift.integration.test.js +177 -0
  48. package/dist/tests/clockDrift.integration.test.js.map +1 -0
  49. package/dist/tests/localNode.clockOffset.test.d.ts +2 -0
  50. package/dist/tests/localNode.clockOffset.test.d.ts.map +1 -0
  51. package/dist/tests/localNode.clockOffset.test.js +70 -0
  52. package/dist/tests/localNode.clockOffset.test.js.map +1 -0
  53. package/dist/tests/sync.localStoreDurability.test.d.ts +2 -0
  54. package/dist/tests/sync.localStoreDurability.test.d.ts.map +1 -0
  55. package/dist/tests/sync.localStoreDurability.test.js +257 -0
  56. package/dist/tests/sync.localStoreDurability.test.js.map +1 -0
  57. package/dist/tests/testStorage.js +2 -2
  58. package/dist/tests/testStorage.js.map +1 -1
  59. package/dist/tests/testUtils.d.ts +2 -0
  60. package/dist/tests/testUtils.d.ts.map +1 -1
  61. package/dist/tests/testUtils.js +9 -2
  62. package/dist/tests/testUtils.js.map +1 -1
  63. package/package.json +4 -4
  64. package/src/ClockOffset.ts +83 -0
  65. package/src/coValueCore/coValueCore.ts +2 -2
  66. package/src/exports.ts +7 -1
  67. package/src/localNode.ts +53 -1
  68. package/src/queue/StoreQueue.ts +12 -4
  69. package/src/storage/sqliteAsync/client.ts +17 -2
  70. package/src/storage/storageAsync.ts +14 -4
  71. package/src/storage/storageSync.ts +12 -2
  72. package/src/storage/types.ts +13 -1
  73. package/src/sync.ts +87 -17
  74. package/src/tests/ClockOffset.test.ts +177 -0
  75. package/src/tests/SQLiteClientAsync.test.ts +55 -10
  76. package/src/tests/StoreQueue.test.ts +20 -0
  77. package/src/tests/clockDrift.integration.test.ts +261 -0
  78. package/src/tests/localNode.clockOffset.test.ts +92 -0
  79. package/src/tests/sync.localStoreDurability.test.ts +357 -0
  80. package/src/tests/testStorage.ts +29 -24
  81. package/src/tests/testUtils.ts +16 -0
@@ -267,16 +267,26 @@ export class StorageApiAsync implements StorageAPI {
267
267
 
268
268
  storeQueue = new StoreQueue();
269
269
 
270
- async store(msg: NewContentMessage, correctionCallback: CorrectionCallback) {
270
+ async store(
271
+ msg: NewContentMessage,
272
+ correctionCallback: CorrectionCallback,
273
+ done?: () => void,
274
+ ) {
271
275
  /**
272
276
  * The store operations must be done one by one, because we can't start a new transaction when there
273
277
  * is already a transaction open.
274
278
  */
275
- this.storeQueue.push(msg, correctionCallback);
279
+ this.storeQueue.push(msg, correctionCallback, done);
276
280
 
277
- this.storeQueue.processQueue(async (data, correctionCallback) => {
281
+ this.storeQueue.processQueue(async (data, correctionCallback, done) => {
278
282
  this.interruptEraser("store");
279
- return this.storeSingle(data, correctionCallback);
283
+ const success = await this.storeSingle(data, correctionCallback);
284
+
285
+ if (success) {
286
+ done?.();
287
+ }
288
+
289
+ return success;
280
290
  });
281
291
  }
282
292
 
@@ -300,8 +300,18 @@ export class StorageApiSync implements StorageAPI {
300
300
  pushCallback(contentMessage);
301
301
  }
302
302
 
303
- store(msg: NewContentMessage, correctionCallback: CorrectionCallback) {
304
- return this.storeSingle(msg, correctionCallback);
303
+ store(
304
+ msg: NewContentMessage,
305
+ correctionCallback: CorrectionCallback,
306
+ done?: () => void,
307
+ ) {
308
+ const success = this.storeSingle(msg, correctionCallback);
309
+
310
+ if (success) {
311
+ done?.();
312
+ }
313
+
314
+ return success;
305
315
  }
306
316
 
307
317
  /**
@@ -60,7 +60,19 @@ export interface StorageAPI {
60
60
  callback: (data: NewContentMessage) => void,
61
61
  done?: (found: boolean) => void,
62
62
  ): void;
63
- store(data: NewContentMessage, handleCorrection: CorrectionCallback): void;
63
+ /**
64
+ * Stores the content. `done` is invoked only after the content is durably
65
+ * stored (including any correction round-trip). It is NOT invoked when the
66
+ * write fails or is dropped — callers rely on that to detect content that
67
+ * was sent to peers but never persisted. Depending on the implementation,
68
+ * done may fire synchronously before store() returns (sync storage) or
69
+ * later (async queue).
70
+ */
71
+ store(
72
+ data: NewContentMessage,
73
+ handleCorrection: CorrectionCallback,
74
+ done?: () => void,
75
+ ): void;
64
76
 
65
77
  streamingQueue?: StorageStreamingQueue;
66
78
 
package/src/sync.ts CHANGED
@@ -53,6 +53,11 @@ export type KnownStateMessage = {
53
53
  asDependencyOf?: RawCoID;
54
54
  } & CoValueKnownState;
55
55
 
56
+ export type LocalStoreDurabilityListener = (
57
+ hasPending: boolean,
58
+ sessionID: SessionID,
59
+ ) => void;
60
+
56
61
  export type NewContentMessage = {
57
62
  action: "content";
58
63
  id: RawCoID;
@@ -805,6 +810,15 @@ export class SyncManager {
805
810
  }
806
811
 
807
812
  trySendToPeer(peer: PeerState, msg: SyncMessage) {
813
+ if (msg.action === "content") {
814
+ // Content leaves the node from several paths (local-transaction sync,
815
+ // reconnection reconciliation, corrections). This is the single choke
816
+ // point for all of them, so the durability window is opened here: if any
817
+ // local store is still pending when content goes out, the session must
818
+ // be marked unsafe to reuse until storage drains.
819
+ this.openLocalStoreDurabilityWindow();
820
+ }
821
+
808
822
  return peer.pushOutgoingMessage(msg);
809
823
  }
810
824
 
@@ -1424,10 +1438,62 @@ export class SyncManager {
1424
1438
  syncLocalTransaction = this.syncQueue.syncTransaction;
1425
1439
  trackDirtyCoValues = this.syncQueue.trackDirtyCoValues;
1426
1440
 
1441
+ /**
1442
+ * Tracks locally-created content that has been handed to async storage but
1443
+ * is not yet durably stored. If such content is also sent to a peer and the
1444
+ * process crashes before the write completes, local storage ends up behind
1445
+ * what the server received for this session — reusing the session after
1446
+ * restart would then fork its hash chain. The listener lets the platform
1447
+ * layer mark the session as unsafe to reuse while that window is open.
1448
+ *
1449
+ * With synchronous storage the store completes inline, the counter is back
1450
+ * to 0 before any send, and the listener never fires.
1451
+ *
1452
+ * Exceptions thrown by the listener are caught and logged so they can't
1453
+ * disrupt the sync/store flow.
1454
+ */
1455
+ onLocalStoreDurabilityChange?: LocalStoreDurabilityListener;
1456
+ private pendingLocalStores = 0;
1457
+ private localStoreDurabilityWindowOpen = false;
1458
+
1459
+ private emitLocalStoreDurabilityChange(hasPending: boolean) {
1460
+ try {
1461
+ this.onLocalStoreDurabilityChange?.(
1462
+ hasPending,
1463
+ this.local.currentSessionID,
1464
+ );
1465
+ } catch (err) {
1466
+ logger.error("Error in onLocalStoreDurabilityChange listener", { err });
1467
+ }
1468
+ }
1469
+
1470
+ private handleLocalStoreDone = () => {
1471
+ this.pendingLocalStores--;
1472
+
1473
+ if (this.pendingLocalStores === 0 && this.localStoreDurabilityWindowOpen) {
1474
+ this.localStoreDurabilityWindowOpen = false;
1475
+ this.emitLocalStoreDurabilityChange(false);
1476
+ }
1477
+ };
1478
+
1479
+ private openLocalStoreDurabilityWindow() {
1480
+ if (this.pendingLocalStores > 0 && !this.localStoreDurabilityWindowOpen) {
1481
+ this.localStoreDurabilityWindowOpen = true;
1482
+ this.emitLocalStoreDurabilityChange(true);
1483
+ }
1484
+ }
1485
+
1427
1486
  syncContent(content: NewContentMessage) {
1428
1487
  const coValue = this.local.getCoValue(content.id);
1429
1488
 
1430
- this.storeContent(content);
1489
+ if (this.local.storage) {
1490
+ // A per-message done callback is used instead of storage.waitForSync
1491
+ // because waitForSync resolves on known-state coverage (which can also
1492
+ // happen on deletion/erasure) and allocates a promise + subscription per
1493
+ // wait — done fires only on a durable write of exactly this content.
1494
+ this.pendingLocalStores++;
1495
+ this.storeContent(content, this.handleLocalStoreDone);
1496
+ }
1431
1497
 
1432
1498
  this.trackSyncState(coValue.id);
1433
1499
 
@@ -1498,7 +1564,7 @@ export class SyncManager {
1498
1564
  }
1499
1565
  }
1500
1566
 
1501
- private storeContent(content: NewContentMessage) {
1567
+ private storeContent(content: NewContentMessage, onStored?: () => void) {
1502
1568
  const storage = this.local.storage;
1503
1569
 
1504
1570
  if (!storage) return;
@@ -1513,22 +1579,26 @@ export class SyncManager {
1513
1579
 
1514
1580
  // Try to store the content as-is for performance
1515
1581
  // In case that some transactions are missing, a correction will be requested, but it's an edge case
1516
- storage.store(content, (correction) => {
1517
- if (!value.verified) {
1518
- logger.error(
1519
- "Correction requested for a CoValue with no verified content",
1520
- {
1521
- id: content.id,
1522
- content: getContenDebugInfo(content),
1523
- correction,
1524
- state: value.loadingState,
1525
- },
1526
- );
1527
- return undefined;
1528
- }
1582
+ storage.store(
1583
+ content,
1584
+ (correction) => {
1585
+ if (!value.verified) {
1586
+ logger.error(
1587
+ "Correction requested for a CoValue with no verified content",
1588
+ {
1589
+ id: content.id,
1590
+ content: getContenDebugInfo(content),
1591
+ correction,
1592
+ state: value.loadingState,
1593
+ },
1594
+ );
1595
+ return undefined;
1596
+ }
1529
1597
 
1530
- return value.newContentSince(correction);
1531
- });
1598
+ return value.newContentSince(correction);
1599
+ },
1600
+ onStored,
1601
+ );
1532
1602
  }
1533
1603
 
1534
1604
  /**
@@ -0,0 +1,177 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { ClockOffset } from "../ClockOffset.js";
3
+
4
+ function makeRng(seed: number) {
5
+ let state = seed >>> 0;
6
+ return () => {
7
+ state ^= state << 13;
8
+ state ^= state >>> 17;
9
+ state ^= state << 5;
10
+ return ((state >>> 0) / 0xffffffff) * 2 - 1;
11
+ };
12
+ }
13
+
14
+ describe("ClockOffset", () => {
15
+ test("currentOffset() returns 0 before any samples are ingested", () => {
16
+ const clockOffset = new ClockOffset();
17
+
18
+ expect(clockOffset.currentOffset()).toBe(0);
19
+ });
20
+
21
+ test("sampleCount() reports sliding window size capped at windowSize", () => {
22
+ const windowSize = 4;
23
+ const clockOffset = new ClockOffset({
24
+ windowSize,
25
+ // Keep the outlier gate wide enough to accept the spread of samples
26
+ // we feed below; the point of this test is the window count, not
27
+ // outlier rejection.
28
+ outlierThresholdMs: 10_000,
29
+ });
30
+
31
+ expect(clockOffset.sampleCount()).toBe(0);
32
+
33
+ for (let i = 0; i < 3; i++) {
34
+ clockOffset.addSample({
35
+ serverTime: 1_000 + i,
36
+ localReceiveTime: 1_000,
37
+ });
38
+ }
39
+ expect(clockOffset.sampleCount()).toBe(3);
40
+
41
+ for (let i = 0; i < windowSize + 2; i++) {
42
+ clockOffset.addSample({
43
+ serverTime: 2_000 + i,
44
+ localReceiveTime: 2_000,
45
+ });
46
+ }
47
+ expect(clockOffset.sampleCount()).toBe(windowSize);
48
+ });
49
+
50
+ test("after a single sample, currentOffset() reflects serverTime - localReceiveTime", () => {
51
+ const clockOffset = new ClockOffset();
52
+
53
+ clockOffset.addSample({ serverTime: 10_500, localReceiveTime: 10_000 });
54
+
55
+ expect(clockOffset.currentOffset()).toBe(500);
56
+ });
57
+
58
+ test("converges to within a small tolerance of the true offset under bounded jitter", () => {
59
+ // True offset is 1000 ms; each sample has jitter of +/- 50 ms. The tolerance
60
+ // of 60 ms is chosen to be slightly larger than the jitter amplitude so the
61
+ // test does not assume a specific estimator (mean / median / trimmed mean
62
+ // would all satisfy it), but is tight enough to prove convergence is real.
63
+ const clockOffset = new ClockOffset();
64
+ const rng = makeRng(42);
65
+ const trueOffset = 1000;
66
+
67
+ for (let i = 0; i < 64; i++) {
68
+ const jitter = rng() * 50;
69
+ const localReceiveTime = 1_000_000 + i * 100;
70
+ const serverTime = localReceiveTime + trueOffset + jitter;
71
+ clockOffset.addSample({ serverTime, localReceiveTime });
72
+ }
73
+
74
+ expect(clockOffset.currentOffset()).toBeGreaterThan(trueOffset - 60);
75
+ expect(clockOffset.currentOffset()).toBeLessThan(trueOffset + 60);
76
+ });
77
+
78
+ test("a single huge outlier does not meaningfully move the estimate", () => {
79
+ const clockOffset = new ClockOffset();
80
+ const trueOffset = 1000;
81
+
82
+ // Fill the window with clean samples near offset=1000.
83
+ for (let i = 0; i < 32; i++) {
84
+ const localReceiveTime = 2_000_000 + i * 100;
85
+ clockOffset.addSample({
86
+ serverTime: localReceiveTime + trueOffset,
87
+ localReceiveTime,
88
+ });
89
+ }
90
+
91
+ const before = clockOffset.currentOffset();
92
+
93
+ // One extreme sample — offset would be ~100_000 if taken at face value.
94
+ clockOffset.addSample({
95
+ serverTime: 2_000_000 + 32 * 100 + 100_000,
96
+ localReceiveTime: 2_000_000 + 32 * 100,
97
+ });
98
+
99
+ const after = clockOffset.currentOffset();
100
+
101
+ expect(Math.abs(after - trueOffset)).toBeLessThan(50);
102
+ expect(Math.abs(after - before)).toBeLessThan(50);
103
+ });
104
+
105
+ test("samples beyond maxAbsOffsetMs are ignored entirely", () => {
106
+ const clockOffset = new ClockOffset({ maxAbsOffsetMs: 5000 });
107
+
108
+ clockOffset.addSample({ serverTime: 10_200, localReceiveTime: 10_000 });
109
+ const before = clockOffset.currentOffset();
110
+
111
+ // Implied offset is 10_000_000 ms — well beyond the 5000 ms cap.
112
+ clockOffset.addSample({
113
+ serverTime: 20_000_000,
114
+ localReceiveTime: 10_000_000,
115
+ });
116
+
117
+ expect(clockOffset.currentOffset()).toBe(before);
118
+ });
119
+
120
+ test("sliding window drops old samples once newer ones fill it", () => {
121
+ const clockOffset = new ClockOffset({ windowSize: 10 });
122
+
123
+ // Ten samples establishing offset ~500.
124
+ for (let i = 0; i < 10; i++) {
125
+ const localReceiveTime = 3_000_000 + i * 100;
126
+ clockOffset.addSample({
127
+ serverTime: localReceiveTime + 500,
128
+ localReceiveTime,
129
+ });
130
+ }
131
+
132
+ // Twenty samples at offset ~2000 — more than the window, so the old
133
+ // offset=500 samples should be fully evicted.
134
+ for (let i = 0; i < 20; i++) {
135
+ const localReceiveTime = 3_100_000 + i * 100;
136
+ clockOffset.addSample({
137
+ serverTime: localReceiveTime + 2000,
138
+ localReceiveTime,
139
+ });
140
+ }
141
+
142
+ expect(clockOffset.currentOffset()).toBeGreaterThan(1950);
143
+ expect(clockOffset.currentOffset()).toBeLessThan(2050);
144
+ });
145
+
146
+ test("a hostile peer's single bad sample does not poison later estimates", () => {
147
+ const clockOffset = new ClockOffset();
148
+ const trueOffset = 1000;
149
+
150
+ // Warm-up with good samples.
151
+ for (let i = 0; i < 16; i++) {
152
+ const localReceiveTime = 4_000_000 + i * 100;
153
+ clockOffset.addSample({
154
+ serverTime: localReceiveTime + trueOffset,
155
+ localReceiveTime,
156
+ });
157
+ }
158
+
159
+ // Hostile sample — way off.
160
+ clockOffset.addSample({
161
+ serverTime: 4_000_000 + 16 * 100 + 500_000,
162
+ localReceiveTime: 4_000_000 + 16 * 100,
163
+ });
164
+
165
+ // More good samples after the attack.
166
+ for (let i = 17; i < 48; i++) {
167
+ const localReceiveTime = 4_000_000 + i * 100;
168
+ clockOffset.addSample({
169
+ serverTime: localReceiveTime + trueOffset,
170
+ localReceiveTime,
171
+ });
172
+ }
173
+
174
+ expect(clockOffset.currentOffset()).toBeGreaterThan(trueOffset - 25);
175
+ expect(clockOffset.currentOffset()).toBeLessThan(trueOffset + 25);
176
+ });
177
+ });
@@ -2,6 +2,15 @@ import { beforeEach, describe, expect, test } from "vitest";
2
2
  import { getDbPath } from "./testStorage.js";
3
3
  import { setupTestNode } from "./testUtils.js";
4
4
  import { DBClientInterfaceAsync } from "../exports.js";
5
+ import type { Transaction } from "../coValueCore/verifiedState.js";
6
+
7
+ function makeTrustingTransaction(changes: string) {
8
+ return {
9
+ privacy: "trusting",
10
+ madeAt: 0,
11
+ changes,
12
+ } as unknown as Transaction;
13
+ }
5
14
 
6
15
  describe("SQLiteClientAsync", () => {
7
16
  describe("transaction", () => {
@@ -50,18 +59,12 @@ describe("SQLiteClientAsync", () => {
50
59
  signature: `signature_z0`,
51
60
  });
52
61
  });
53
- // Second transaction fails (duplicate primary key)
62
+ // Second transaction fails
54
63
  await expect(
55
- dbClient.transaction(async (tx) => {
56
- return tx.addSignatureAfter({
57
- sessionRowID: 0,
58
- idx: 0,
59
- signature: `signature_z0`,
60
- });
64
+ dbClient.transaction(async () => {
65
+ throw new Error("transaction failed");
61
66
  }),
62
- ).rejects.toThrow(
63
- /UNIQUE constraint failed: signatureAfter\.ses, signatureAfter\.idx/,
64
- );
67
+ ).rejects.toThrow("transaction failed");
65
68
  // Third transaction succeeds
66
69
  await dbClient.transaction(async (tx) => {
67
70
  return tx.addSignatureAfter({
@@ -71,5 +74,47 @@ describe("SQLiteClientAsync", () => {
71
74
  });
72
75
  });
73
76
  });
77
+
78
+ test("addTransaction overwrites an orphan row at the same (ses, idx)", async () => {
79
+ // Simulates a row left behind by an interrupted write transaction:
80
+ // the session row was rolled back but the transaction row persisted.
81
+ await dbClient.transaction(async (tx) => {
82
+ return tx.addTransaction(1, 0, makeTrustingTransaction("orphan"));
83
+ });
84
+
85
+ await expect(
86
+ dbClient.transaction(async (tx) => {
87
+ return tx.addTransaction(1, 0, makeTrustingTransaction("recovered"));
88
+ }),
89
+ ).resolves.not.toThrow();
90
+
91
+ const txs = await dbClient.getNewTransactionInSession(1, 0, 0);
92
+ expect(txs).toHaveLength(1);
93
+ expect(txs[0]?.tx).toEqual(makeTrustingTransaction("recovered"));
94
+ });
95
+
96
+ test("addSignatureAfter overwrites an orphan row at the same (ses, idx)", async () => {
97
+ await dbClient.transaction(async (tx) => {
98
+ return tx.addSignatureAfter({
99
+ sessionRowID: 1,
100
+ idx: 0,
101
+ signature: "signature_zorphan",
102
+ });
103
+ });
104
+
105
+ await expect(
106
+ dbClient.transaction(async (tx) => {
107
+ return tx.addSignatureAfter({
108
+ sessionRowID: 1,
109
+ idx: 0,
110
+ signature: "signature_zrecovered",
111
+ });
112
+ }),
113
+ ).resolves.not.toThrow();
114
+
115
+ const signatures = await dbClient.getSignatures(1, 0);
116
+ expect(signatures).toHaveLength(1);
117
+ expect(signatures[0]?.signature).toBe("signature_zrecovered");
118
+ });
74
119
  });
75
120
  });
@@ -82,11 +82,13 @@ describe("StoreQueue", () => {
82
82
  1,
83
83
  data1,
84
84
  mockCorrectionCallback,
85
+ undefined,
85
86
  );
86
87
  expect(mockCallback).toHaveBeenNthCalledWith(
87
88
  2,
88
89
  data2,
89
90
  mockCorrectionCallback,
91
+ undefined,
90
92
  );
91
93
  });
92
94
 
@@ -192,6 +194,7 @@ describe("StoreQueue", () => {
192
194
  expect(mockCallback).toHaveBeenLastCalledWith(
193
195
  data3,
194
196
  mockCorrectionCallback,
197
+ undefined,
195
198
  );
196
199
  });
197
200
 
@@ -267,4 +270,21 @@ describe("StoreQueue", () => {
267
270
  expect(count).toBe(entries);
268
271
  });
269
272
  });
273
+
274
+ describe("done callbacks", () => {
275
+ test("passes each entry's done callback to the processing callback", async () => {
276
+ const queue = new StoreQueue();
277
+ const done1 = vi.fn();
278
+
279
+ queue.push(createMockNewContentMessage("co_z1"), () => undefined, done1);
280
+ queue.push(createMockNewContentMessage("co_z2"), () => undefined);
281
+
282
+ const seen: Array<(() => void) | undefined> = [];
283
+ await queue.processQueue(async (_data, _correction, done) => {
284
+ seen.push(done);
285
+ });
286
+
287
+ expect(seen).toEqual([done1, undefined]);
288
+ });
289
+ });
270
290
  });