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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +22 -0
- package/dist/ClockOffset.d.ts +21 -0
- package/dist/ClockOffset.d.ts.map +1 -0
- package/dist/ClockOffset.js +55 -0
- package/dist/ClockOffset.js.map +1 -0
- package/dist/coValueCore/coValueCore.js +2 -2
- package/dist/coValueCore/coValueCore.js.map +1 -1
- package/dist/exports.d.ts +2 -2
- package/dist/exports.d.ts.map +1 -1
- package/dist/exports.js.map +1 -1
- package/dist/localNode.d.ts +21 -4
- package/dist/localNode.d.ts.map +1 -1
- package/dist/localNode.js +44 -6
- package/dist/localNode.js.map +1 -1
- package/dist/queue/StoreQueue.d.ts +3 -2
- package/dist/queue/StoreQueue.d.ts.map +1 -1
- package/dist/queue/StoreQueue.js +4 -4
- package/dist/queue/StoreQueue.js.map +1 -1
- package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
- package/dist/storage/sqliteAsync/client.js +17 -2
- package/dist/storage/sqliteAsync/client.js.map +1 -1
- package/dist/storage/storageAsync.d.ts +1 -1
- package/dist/storage/storageAsync.d.ts.map +1 -1
- package/dist/storage/storageAsync.js +8 -4
- package/dist/storage/storageAsync.js.map +1 -1
- package/dist/storage/storageSync.d.ts +1 -1
- package/dist/storage/storageSync.d.ts.map +1 -1
- package/dist/storage/storageSync.js +6 -2
- package/dist/storage/storageSync.js.map +1 -1
- package/dist/storage/types.d.ts +9 -1
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/sync.d.ts +21 -0
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +41 -3
- package/dist/sync.js.map +1 -1
- package/dist/tests/ClockOffset.test.d.ts +2 -0
- package/dist/tests/ClockOffset.test.d.ts.map +1 -0
- package/dist/tests/ClockOffset.test.js +146 -0
- package/dist/tests/ClockOffset.test.js.map +1 -0
- package/dist/tests/SQLiteClientAsync.test.js +43 -8
- package/dist/tests/SQLiteClientAsync.test.js.map +1 -1
- package/dist/tests/StoreQueue.test.js +16 -3
- package/dist/tests/StoreQueue.test.js.map +1 -1
- package/dist/tests/clockDrift.integration.test.d.ts +2 -0
- package/dist/tests/clockDrift.integration.test.d.ts.map +1 -0
- package/dist/tests/clockDrift.integration.test.js +177 -0
- package/dist/tests/clockDrift.integration.test.js.map +1 -0
- package/dist/tests/localNode.clockOffset.test.d.ts +2 -0
- package/dist/tests/localNode.clockOffset.test.d.ts.map +1 -0
- package/dist/tests/localNode.clockOffset.test.js +70 -0
- package/dist/tests/localNode.clockOffset.test.js.map +1 -0
- package/dist/tests/sync.localStoreDurability.test.d.ts +2 -0
- package/dist/tests/sync.localStoreDurability.test.d.ts.map +1 -0
- package/dist/tests/sync.localStoreDurability.test.js +257 -0
- package/dist/tests/sync.localStoreDurability.test.js.map +1 -0
- package/dist/tests/testStorage.js +2 -2
- package/dist/tests/testStorage.js.map +1 -1
- package/dist/tests/testUtils.d.ts +2 -0
- package/dist/tests/testUtils.d.ts.map +1 -1
- package/dist/tests/testUtils.js +9 -2
- package/dist/tests/testUtils.js.map +1 -1
- package/package.json +4 -4
- package/src/ClockOffset.ts +83 -0
- package/src/coValueCore/coValueCore.ts +2 -2
- package/src/exports.ts +7 -1
- package/src/localNode.ts +53 -1
- package/src/queue/StoreQueue.ts +12 -4
- package/src/storage/sqliteAsync/client.ts +17 -2
- package/src/storage/storageAsync.ts +14 -4
- package/src/storage/storageSync.ts +12 -2
- package/src/storage/types.ts +13 -1
- package/src/sync.ts +87 -17
- package/src/tests/ClockOffset.test.ts +177 -0
- package/src/tests/SQLiteClientAsync.test.ts +55 -10
- package/src/tests/StoreQueue.test.ts +20 -0
- package/src/tests/clockDrift.integration.test.ts +261 -0
- package/src/tests/localNode.clockOffset.test.ts +92 -0
- package/src/tests/sync.localStoreDurability.test.ts +357 -0
- package/src/tests/testStorage.ts +29 -24
- 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(
|
|
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
|
-
|
|
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(
|
|
304
|
-
|
|
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
|
/**
|
package/src/storage/types.ts
CHANGED
|
@@ -60,7 +60,19 @@ export interface StorageAPI {
|
|
|
60
60
|
callback: (data: NewContentMessage) => void,
|
|
61
61
|
done?: (found: boolean) => void,
|
|
62
62
|
): void;
|
|
63
|
-
|
|
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.
|
|
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(
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
-
|
|
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
|
|
62
|
+
// Second transaction fails
|
|
54
63
|
await expect(
|
|
55
|
-
dbClient.transaction(async (
|
|
56
|
-
|
|
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
|
});
|