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.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/dist/exports.d.ts +2 -2
  4. package/dist/exports.d.ts.map +1 -1
  5. package/dist/exports.js.map +1 -1
  6. package/dist/localNode.d.ts +7 -3
  7. package/dist/localNode.d.ts.map +1 -1
  8. package/dist/localNode.js +11 -4
  9. package/dist/localNode.js.map +1 -1
  10. package/dist/queue/StoreQueue.d.ts +3 -2
  11. package/dist/queue/StoreQueue.d.ts.map +1 -1
  12. package/dist/queue/StoreQueue.js +4 -4
  13. package/dist/queue/StoreQueue.js.map +1 -1
  14. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  15. package/dist/storage/sqliteAsync/client.js +17 -2
  16. package/dist/storage/sqliteAsync/client.js.map +1 -1
  17. package/dist/storage/storageAsync.d.ts +1 -1
  18. package/dist/storage/storageAsync.d.ts.map +1 -1
  19. package/dist/storage/storageAsync.js +8 -4
  20. package/dist/storage/storageAsync.js.map +1 -1
  21. package/dist/storage/storageSync.d.ts +1 -1
  22. package/dist/storage/storageSync.d.ts.map +1 -1
  23. package/dist/storage/storageSync.js +6 -2
  24. package/dist/storage/storageSync.js.map +1 -1
  25. package/dist/storage/types.d.ts +9 -1
  26. package/dist/storage/types.d.ts.map +1 -1
  27. package/dist/sync.d.ts +21 -0
  28. package/dist/sync.d.ts.map +1 -1
  29. package/dist/sync.js +41 -3
  30. package/dist/sync.js.map +1 -1
  31. package/dist/tests/SQLiteClientAsync.test.js +43 -8
  32. package/dist/tests/SQLiteClientAsync.test.js.map +1 -1
  33. package/dist/tests/StoreQueue.test.js +16 -3
  34. package/dist/tests/StoreQueue.test.js.map +1 -1
  35. package/dist/tests/sync.localStoreDurability.test.d.ts +2 -0
  36. package/dist/tests/sync.localStoreDurability.test.d.ts.map +1 -0
  37. package/dist/tests/sync.localStoreDurability.test.js +257 -0
  38. package/dist/tests/sync.localStoreDurability.test.js.map +1 -0
  39. package/dist/tests/testStorage.js +2 -2
  40. package/dist/tests/testStorage.js.map +1 -1
  41. package/package.json +4 -4
  42. package/src/exports.ts +7 -1
  43. package/src/localNode.ts +24 -3
  44. package/src/queue/StoreQueue.ts +12 -4
  45. package/src/storage/sqliteAsync/client.ts +17 -2
  46. package/src/storage/storageAsync.ts +14 -4
  47. package/src/storage/storageSync.ts +12 -2
  48. package/src/storage/types.ts +13 -1
  49. package/src/sync.ts +87 -17
  50. package/src/tests/SQLiteClientAsync.test.ts +55 -10
  51. package/src/tests/StoreQueue.test.ts +20 -0
  52. package/src/tests/sync.localStoreDurability.test.ts +357 -0
  53. 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(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
  /**
@@ -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
  });
@@ -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
+ });