cojson 0.20.18 → 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 +13 -0
- 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 +7 -3
- package/dist/localNode.d.ts.map +1 -1
- package/dist/localNode.js +11 -4
- 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/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/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/package.json +4 -4
- package/src/exports.ts +7 -1
- package/src/localNode.ts +24 -3
- 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/SQLiteClientAsync.test.ts +55 -10
- package/src/tests/StoreQueue.test.ts +20 -0
- package/src/tests/sync.localStoreDurability.test.ts +357 -0
- package/src/tests/testStorage.ts +29 -24
|
@@ -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
|
/**
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
|
3
|
+
import { LocalNode } from "../localNode";
|
|
4
|
+
import {
|
|
5
|
+
SyncMessagesLog,
|
|
6
|
+
getSyncServerConnectedPeer,
|
|
7
|
+
loadCoValueOrFail,
|
|
8
|
+
setupTestNode,
|
|
9
|
+
waitFor,
|
|
10
|
+
} from "./testUtils";
|
|
11
|
+
import { getDbPath, registerStorageCleanupRunner } from "./testStorage";
|
|
12
|
+
|
|
13
|
+
const Crypto = await WasmCrypto.create();
|
|
14
|
+
|
|
15
|
+
let jazzCloud: ReturnType<typeof setupTestNode>;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
registerStorageCleanupRunner();
|
|
19
|
+
SyncMessagesLog.clear();
|
|
20
|
+
jazzCloud = setupTestNode({ isSyncServer: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("StorageAPI.store done callback", () => {
|
|
24
|
+
test("async storage invokes done after the write completes", async () => {
|
|
25
|
+
const client = setupTestNode();
|
|
26
|
+
const group = client.node.createGroup();
|
|
27
|
+
// Attach storage after creating the group so our manual store is the only write
|
|
28
|
+
const { storage } = await client.addAsyncStorage();
|
|
29
|
+
|
|
30
|
+
const content = group.core.newContentSince(undefined)!;
|
|
31
|
+
const done = vi.fn();
|
|
32
|
+
|
|
33
|
+
storage.store(content[0]!, () => undefined, done);
|
|
34
|
+
|
|
35
|
+
expect(done).not.toHaveBeenCalled();
|
|
36
|
+
await waitFor(() => expect(done).toHaveBeenCalledTimes(1));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("sync storage invokes done synchronously", async () => {
|
|
40
|
+
const client = setupTestNode();
|
|
41
|
+
const group = client.node.createGroup();
|
|
42
|
+
const { storage } = client.addStorage();
|
|
43
|
+
|
|
44
|
+
const content = group.core.newContentSince(undefined)!;
|
|
45
|
+
const done = vi.fn();
|
|
46
|
+
|
|
47
|
+
storage.store(content[0]!, () => undefined, done);
|
|
48
|
+
|
|
49
|
+
expect(done).toHaveBeenCalledTimes(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("async storage invokes done after a successful correction round-trip", async () => {
|
|
53
|
+
const client = setupTestNode();
|
|
54
|
+
const group = client.node.createGroup();
|
|
55
|
+
const map = group.createMap();
|
|
56
|
+
map.set("a", 1, "trusting");
|
|
57
|
+
const { storage } = await client.addAsyncStorage();
|
|
58
|
+
|
|
59
|
+
const fullContent = map.core.newContentSince(undefined)!;
|
|
60
|
+
// Content without the header, stored into empty storage → triggers a correction
|
|
61
|
+
const withoutHeader = map.core.newContentSince({
|
|
62
|
+
id: map.id,
|
|
63
|
+
header: true,
|
|
64
|
+
sessions: {},
|
|
65
|
+
})!;
|
|
66
|
+
const done = vi.fn();
|
|
67
|
+
|
|
68
|
+
storage.store(withoutHeader[0]!, () => fullContent, done);
|
|
69
|
+
|
|
70
|
+
await waitFor(() => expect(done).toHaveBeenCalledTimes(1));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("local store durability window", () => {
|
|
75
|
+
test("opens before the first peer send and closes when async storage drains", async () => {
|
|
76
|
+
const client = setupTestNode();
|
|
77
|
+
await client.addAsyncStorage();
|
|
78
|
+
const { peerState } = client.connectToSyncServer();
|
|
79
|
+
|
|
80
|
+
// Instrument the actual outgoing push: the window must open before any
|
|
81
|
+
// content message physically leaves the node
|
|
82
|
+
let contentSent = false;
|
|
83
|
+
const originalPush = peerState.pushOutgoingMessage.bind(peerState);
|
|
84
|
+
vi.spyOn(peerState, "pushOutgoingMessage").mockImplementation((msg) => {
|
|
85
|
+
if (msg.action === "content") {
|
|
86
|
+
contentSent = true;
|
|
87
|
+
}
|
|
88
|
+
return originalPush(msg);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const events: Array<{ hasPending: boolean; contentSentAlready: boolean }> =
|
|
92
|
+
[];
|
|
93
|
+
client.node.syncManager.onLocalStoreDurabilityChange = (hasPending) => {
|
|
94
|
+
events.push({ hasPending, contentSentAlready: contentSent });
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const group = client.node.createGroup();
|
|
98
|
+
const map = group.createMap();
|
|
99
|
+
map.set("hello", "world", "trusting");
|
|
100
|
+
|
|
101
|
+
await waitFor(() => expect(events.some((e) => !e.hasPending)).toBe(true));
|
|
102
|
+
|
|
103
|
+
// The window opened BEFORE any content was sent to a peer
|
|
104
|
+
expect(events[0]).toEqual({ hasPending: true, contentSentAlready: false });
|
|
105
|
+
// ...and eventually closed once storage drained
|
|
106
|
+
expect(events.at(-1)!.hasPending).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("reopens for a new local edit after the previous window closed", async () => {
|
|
110
|
+
const client = setupTestNode();
|
|
111
|
+
await client.addAsyncStorage();
|
|
112
|
+
client.connectToSyncServer();
|
|
113
|
+
|
|
114
|
+
const events: boolean[] = [];
|
|
115
|
+
client.node.syncManager.onLocalStoreDurabilityChange = (hasPending) => {
|
|
116
|
+
events.push(hasPending);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const group = client.node.createGroup();
|
|
120
|
+
const map = group.createMap();
|
|
121
|
+
map.set("hello", "world", "trusting");
|
|
122
|
+
|
|
123
|
+
// First window: opened and closed once storage drained
|
|
124
|
+
await waitFor(() => expect(events).toEqual([true, false]));
|
|
125
|
+
|
|
126
|
+
// A second local edit reopens the window and it closes again on drain
|
|
127
|
+
map.set("second", "batch", "trusting");
|
|
128
|
+
|
|
129
|
+
await waitFor(() => expect(events).toEqual([true, false, true, false]));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("reports the node's current session ID", async () => {
|
|
133
|
+
const client = setupTestNode();
|
|
134
|
+
await client.addAsyncStorage();
|
|
135
|
+
client.connectToSyncServer();
|
|
136
|
+
|
|
137
|
+
const sessions: string[] = [];
|
|
138
|
+
client.node.syncManager.onLocalStoreDurabilityChange = (
|
|
139
|
+
_hasPending,
|
|
140
|
+
sessionID,
|
|
141
|
+
) => {
|
|
142
|
+
sessions.push(sessionID);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const group = client.node.createGroup();
|
|
146
|
+
group.createMap().set("hello", "world", "trusting");
|
|
147
|
+
|
|
148
|
+
await waitFor(() => expect(sessions.length).toBeGreaterThan(0));
|
|
149
|
+
expect(sessions.every((s) => s === client.node.currentSessionID)).toBe(
|
|
150
|
+
true,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("does not fire with synchronous storage", async () => {
|
|
155
|
+
const client = setupTestNode();
|
|
156
|
+
client.addStorage();
|
|
157
|
+
client.connectToSyncServer();
|
|
158
|
+
|
|
159
|
+
const listener = vi.fn();
|
|
160
|
+
client.node.syncManager.onLocalStoreDurabilityChange = listener;
|
|
161
|
+
|
|
162
|
+
const group = client.node.createGroup();
|
|
163
|
+
const map = group.createMap();
|
|
164
|
+
map.set("hello", "world", "trusting");
|
|
165
|
+
|
|
166
|
+
await map.core.waitForSync();
|
|
167
|
+
expect(listener).not.toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("does not fire when there are no peers to send to", async () => {
|
|
171
|
+
const client = setupTestNode(); // not connected
|
|
172
|
+
await client.addAsyncStorage();
|
|
173
|
+
|
|
174
|
+
const listener = vi.fn();
|
|
175
|
+
client.node.syncManager.onLocalStoreDurabilityChange = listener;
|
|
176
|
+
|
|
177
|
+
const group = client.node.createGroup();
|
|
178
|
+
const map = group.createMap();
|
|
179
|
+
map.set("hello", "world", "trusting");
|
|
180
|
+
|
|
181
|
+
await client.node.syncManager.waitForStorageSync(map.id);
|
|
182
|
+
expect(listener).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("remote content does not open the window on the receiving node", async () => {
|
|
186
|
+
const server = setupTestNode({ isSyncServer: true });
|
|
187
|
+
await server.addAsyncStorage({ ourName: "server" });
|
|
188
|
+
|
|
189
|
+
const listener = vi.fn();
|
|
190
|
+
server.node.syncManager.onLocalStoreDurabilityChange = listener;
|
|
191
|
+
|
|
192
|
+
const client = setupTestNode({ connected: true });
|
|
193
|
+
const group = client.node.createGroup();
|
|
194
|
+
const map = group.createMap();
|
|
195
|
+
map.set("hello", "world", "trusting");
|
|
196
|
+
|
|
197
|
+
await map.core.waitForSync();
|
|
198
|
+
expect(listener).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("window stays open when storage writes never complete (crash window)", async () => {
|
|
202
|
+
const client = setupTestNode();
|
|
203
|
+
const { storage } = await client.addAsyncStorage();
|
|
204
|
+
const { peerState } = client.connectToSyncServer();
|
|
205
|
+
|
|
206
|
+
// Simulate the crash window: storage accepts writes but never completes them
|
|
207
|
+
storage.store = async () => {};
|
|
208
|
+
|
|
209
|
+
const events: boolean[] = [];
|
|
210
|
+
client.node.syncManager.onLocalStoreDurabilityChange = (hasPending) =>
|
|
211
|
+
events.push(hasPending);
|
|
212
|
+
|
|
213
|
+
const group = client.node.createGroup();
|
|
214
|
+
const map = group.createMap();
|
|
215
|
+
map.set("hello", "world", "trusting");
|
|
216
|
+
|
|
217
|
+
// Wait for the content to reach the server (peer sync only) — storage sync
|
|
218
|
+
// never resolves in this scenario since store() is stubbed to a no-op, so
|
|
219
|
+
// we can't use map.core.waitForSync() here, which also waits on storage.
|
|
220
|
+
await client.node.syncManager.waitForSyncWithPeer(
|
|
221
|
+
peerState.id,
|
|
222
|
+
map.id,
|
|
223
|
+
5000,
|
|
224
|
+
);
|
|
225
|
+
expect(events).toEqual([true]); // opened, never closed
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("opens when pending content is sent through reconnection reconciliation", async () => {
|
|
229
|
+
const client = setupTestNode();
|
|
230
|
+
const { storage } = await client.addAsyncStorage();
|
|
231
|
+
|
|
232
|
+
// Writes never complete: the local edit below stays pending forever
|
|
233
|
+
storage.store = async () => {};
|
|
234
|
+
|
|
235
|
+
const events: boolean[] = [];
|
|
236
|
+
client.node.syncManager.onLocalStoreDurabilityChange = (hasPending) =>
|
|
237
|
+
events.push(hasPending);
|
|
238
|
+
|
|
239
|
+
const group = client.node.createGroup();
|
|
240
|
+
const map = group.createMap();
|
|
241
|
+
map.set("hello", "world", "trusting");
|
|
242
|
+
|
|
243
|
+
// Not connected yet: nothing was sent, so the window must not be open
|
|
244
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
245
|
+
expect(events).toEqual([]);
|
|
246
|
+
|
|
247
|
+
// Connecting sends the pending content through reconciliation
|
|
248
|
+
// (sendNewContent), not through syncContent — the window must still open
|
|
249
|
+
const { peerState } = client.connectToSyncServer();
|
|
250
|
+
await client.node.syncManager.waitForSyncWithPeer(
|
|
251
|
+
peerState.id,
|
|
252
|
+
map.id,
|
|
253
|
+
5000,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(events).toEqual([true]);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("LocalNode creation options", () => {
|
|
261
|
+
test("withNewlyCreatedAccount wires onLocalStoreDurabilityChange into the node's syncManager", async () => {
|
|
262
|
+
const listener = vi.fn();
|
|
263
|
+
|
|
264
|
+
const { node } = await LocalNode.withNewlyCreatedAccount({
|
|
265
|
+
creationProps: { name: "test" },
|
|
266
|
+
crypto: Crypto,
|
|
267
|
+
peers: [],
|
|
268
|
+
onLocalStoreDurabilityChange: listener,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(node.syncManager.onLocalStoreDurabilityChange).toBe(listener);
|
|
272
|
+
await node.gracefulShutdown();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("withLoadedAccount wires onLocalStoreDurabilityChange", async () => {
|
|
276
|
+
const listener = vi.fn();
|
|
277
|
+
|
|
278
|
+
const created = await LocalNode.withNewlyCreatedAccount({
|
|
279
|
+
creationProps: { name: "test" },
|
|
280
|
+
crypto: Crypto,
|
|
281
|
+
peers: [],
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const { peer } = getSyncServerConnectedPeer({
|
|
285
|
+
peerId: created.accountID,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Sync the account to the server so it can be loaded
|
|
289
|
+
created.node.syncManager.addPeer(peer);
|
|
290
|
+
await created.node.syncManager.waitForAllCoValuesSync();
|
|
291
|
+
|
|
292
|
+
const { peer: peer2 } = getSyncServerConnectedPeer({
|
|
293
|
+
peerId: "loadingNode",
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const node = await LocalNode.withLoadedAccount({
|
|
297
|
+
accountID: created.accountID,
|
|
298
|
+
accountSecret: created.accountSecret,
|
|
299
|
+
sessionID: undefined,
|
|
300
|
+
peers: [peer2],
|
|
301
|
+
crypto: Crypto,
|
|
302
|
+
onLocalStoreDurabilityChange: listener,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(node.syncManager.onLocalStoreDurabilityChange).toBe(listener);
|
|
306
|
+
await node.gracefulShutdown();
|
|
307
|
+
await created.node.gracefulShutdown();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("crash recovery with a fresh session", () => {
|
|
312
|
+
test("restarting with a new session over the same storage recovers and syncs cleanly", async () => {
|
|
313
|
+
const client = setupTestNode();
|
|
314
|
+
const dbPath = getDbPath();
|
|
315
|
+
const { storage } = await client.addAsyncStorage({ filename: dbPath });
|
|
316
|
+
const { peerState } = client.connectToSyncServer();
|
|
317
|
+
|
|
318
|
+
const group = client.node.createGroup();
|
|
319
|
+
const map = group.createMap();
|
|
320
|
+
map.set("hello", "world", "trusting");
|
|
321
|
+
await map.core.waitForSync();
|
|
322
|
+
|
|
323
|
+
// Enter the crash window: further writes reach the server but not storage
|
|
324
|
+
storage.store = async () => {};
|
|
325
|
+
map.set("crashed", "yes", "trusting");
|
|
326
|
+
await client.node.syncManager.waitForSyncWithPeer(
|
|
327
|
+
peerState.id,
|
|
328
|
+
map.id,
|
|
329
|
+
5000,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// "Crash": abandon the node without flushing (no graceful shutdown), then
|
|
333
|
+
// restart with the SAME agent, SAME storage file, but a NEW session — which
|
|
334
|
+
// is exactly what the session providers do when the durability marker is set.
|
|
335
|
+
client.disconnect();
|
|
336
|
+
const restarted = setupTestNode({ secret: client.node.agentSecret });
|
|
337
|
+
await restarted.addAsyncStorage({ filename: dbPath });
|
|
338
|
+
restarted.connectToSyncServer();
|
|
339
|
+
|
|
340
|
+
// The fresh session is load-bearing: reusing the crashed session could fork its hash chain
|
|
341
|
+
expect(restarted.node.currentSessionID).not.toBe(
|
|
342
|
+
client.node.currentSessionID,
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
// The crashed transaction comes back down from the server
|
|
346
|
+
const mapOnRestart = await loadCoValueOrFail(restarted.node, map.id);
|
|
347
|
+
await waitFor(() => expect(mapOnRestart.get("crashed")).toEqual("yes"));
|
|
348
|
+
|
|
349
|
+
// New writes from the fresh session sync cleanly — no signature rejection
|
|
350
|
+
mapOnRestart.set("recovered", "yes", "trusting");
|
|
351
|
+
await mapOnRestart.core.waitForSync();
|
|
352
|
+
|
|
353
|
+
const mapOnServer = await loadCoValueOrFail(jazzCloud.node, map.id);
|
|
354
|
+
expect(mapOnServer.get("recovered")).toEqual("yes");
|
|
355
|
+
expect(mapOnServer.get("crashed")).toEqual("yes");
|
|
356
|
+
});
|
|
357
|
+
});
|