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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { logger } from "../logger.js";
|
|
3
|
+
import {
|
|
4
|
+
loadCoValueOrFail,
|
|
5
|
+
setupTestAccount,
|
|
6
|
+
setupTestNode,
|
|
7
|
+
waitFor,
|
|
8
|
+
} from "./testUtils.js";
|
|
9
|
+
|
|
10
|
+
const CLIENT_SKEW_MS = 20_000;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("clock drift across peers with clock sync enabled", () => {
|
|
17
|
+
test("worker can publish and self-remove after a skewed client with clock sync creates group, grant and chat", async () => {
|
|
18
|
+
const errorSpy = vi.spyOn(logger, "error");
|
|
19
|
+
|
|
20
|
+
const realNow = Math.floor(performance.timeOrigin + performance.now());
|
|
21
|
+
|
|
22
|
+
vi.spyOn(Date, "now").mockImplementation(() => {
|
|
23
|
+
const real = performance.timeOrigin + performance.now();
|
|
24
|
+
return Math.floor(real) + CLIENT_SKEW_MS;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const worker = await setupTestAccount({
|
|
28
|
+
isSyncServer: true,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const client = await setupTestAccount({
|
|
32
|
+
connected: true,
|
|
33
|
+
experimental_clockSyncFromServerPings: true,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const skewedNow = Date.now();
|
|
37
|
+
client.node.clockOffset.addSample({
|
|
38
|
+
serverTime: skewedNow - CLIENT_SKEW_MS,
|
|
39
|
+
localReceiveTime: skewedNow,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const group = client.node.createGroup();
|
|
43
|
+
|
|
44
|
+
const workerAccountOnClient = await loadCoValueOrFail(
|
|
45
|
+
client.node,
|
|
46
|
+
worker.accountID,
|
|
47
|
+
);
|
|
48
|
+
group.addMember(workerAccountOnClient, "admin");
|
|
49
|
+
|
|
50
|
+
const oneToOneChat = group.createMap();
|
|
51
|
+
oneToOneChat.set("kind", "OneToOneChat", "trusting");
|
|
52
|
+
oneToOneChat.set("published", false, "trusting");
|
|
53
|
+
|
|
54
|
+
await oneToOneChat.core.waitForSync();
|
|
55
|
+
await group.core.waitForSync();
|
|
56
|
+
|
|
57
|
+
const clientGroupTxs = group.core.getValidSortedTransactions();
|
|
58
|
+
const maxClientGroupMadeAt = Math.max(
|
|
59
|
+
...clientGroupTxs.map((tx) => tx.madeAt),
|
|
60
|
+
);
|
|
61
|
+
expect(maxClientGroupMadeAt).toBeLessThan(realNow + CLIENT_SKEW_MS - 5_000);
|
|
62
|
+
|
|
63
|
+
vi.restoreAllMocks();
|
|
64
|
+
const errorSpyAfter = vi.spyOn(logger, "error");
|
|
65
|
+
|
|
66
|
+
const chatOnWorker = await loadCoValueOrFail(worker.node, oneToOneChat.id);
|
|
67
|
+
const groupOnWorker = await loadCoValueOrFail(worker.node, group.id);
|
|
68
|
+
const workerAccountOnWorker = await loadCoValueOrFail(
|
|
69
|
+
worker.node,
|
|
70
|
+
worker.accountID,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
expect(chatOnWorker.get("kind")).toBe("OneToOneChat");
|
|
75
|
+
expect(groupOnWorker.roleOf(worker.accountID)).toBe("admin");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
chatOnWorker.set("published", true, "trusting");
|
|
79
|
+
groupOnWorker.removeMember(workerAccountOnWorker);
|
|
80
|
+
|
|
81
|
+
await chatOnWorker.core.waitForSync();
|
|
82
|
+
await groupOnWorker.core.waitForSync();
|
|
83
|
+
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
expect(oneToOneChat.get("published")).toBe(true);
|
|
86
|
+
expect(group.roleOf(worker.accountID)).not.toBe("admin");
|
|
87
|
+
expect(chatOnWorker.get("published")).toBe(true);
|
|
88
|
+
expect(groupOnWorker.roleOf(worker.accountID)).not.toBe("admin");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(oneToOneChat.get("published")).toBe(true);
|
|
92
|
+
expect(chatOnWorker.get("published")).toBe(true);
|
|
93
|
+
expect(group.roleOf(worker.accountID)).not.toBe("admin");
|
|
94
|
+
expect(groupOnWorker.roleOf(worker.accountID)).not.toBe("admin");
|
|
95
|
+
|
|
96
|
+
const forbiddenFragments = [
|
|
97
|
+
"invalid transaction",
|
|
98
|
+
"permission",
|
|
99
|
+
"rejected",
|
|
100
|
+
"not authorized",
|
|
101
|
+
"not authorised",
|
|
102
|
+
];
|
|
103
|
+
const offendingCalls = [...errorSpy.mock.calls, ...errorSpyAfter.mock.calls]
|
|
104
|
+
.map((call) => call.map((arg) => String(arg)).join(" "))
|
|
105
|
+
.filter((line) =>
|
|
106
|
+
forbiddenFragments.some((f) => line.toLowerCase().includes(f)),
|
|
107
|
+
);
|
|
108
|
+
expect(offendingCalls).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("experimental_clockSyncFromServerPings flag wiring", () => {
|
|
113
|
+
function seedOffset(
|
|
114
|
+
clockOffset: {
|
|
115
|
+
addSample: (s: { serverTime: number; localReceiveTime: number }) => void;
|
|
116
|
+
},
|
|
117
|
+
offsetMs: number,
|
|
118
|
+
) {
|
|
119
|
+
const localReceiveTime = Date.now();
|
|
120
|
+
clockOffset.addSample({
|
|
121
|
+
serverTime: localReceiveTime + offsetMs,
|
|
122
|
+
localReceiveTime,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
test("with the flag on, a seeded +10_000 ms offset pulls locally-stamped madeAt forward", () => {
|
|
127
|
+
const { node } = setupTestNode({
|
|
128
|
+
experimental_clockSyncFromServerPings: true,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
seedOffset(node.clockOffset, 10_000);
|
|
132
|
+
|
|
133
|
+
const group = node.createGroup();
|
|
134
|
+
const map = group.createMap();
|
|
135
|
+
|
|
136
|
+
const before = Date.now();
|
|
137
|
+
map.set("k", "v", "trusting");
|
|
138
|
+
const after = Date.now();
|
|
139
|
+
|
|
140
|
+
const txs = map.core.getValidSortedTransactions();
|
|
141
|
+
const lastTx = txs.at(-1);
|
|
142
|
+
expect(lastTx).toBeDefined();
|
|
143
|
+
expect(lastTx!.madeAt).toBeGreaterThanOrEqual(before + 9_900);
|
|
144
|
+
expect(lastTx!.madeAt).toBeLessThanOrEqual(after + 10_100);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("without the flag, a seeded +10_000 ms offset does NOT shift locally-stamped madeAt", () => {
|
|
148
|
+
const { node } = setupTestNode({
|
|
149
|
+
experimental_clockSyncFromServerPings: false,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
seedOffset(node.clockOffset, 10_000);
|
|
153
|
+
|
|
154
|
+
const group = node.createGroup();
|
|
155
|
+
const map = group.createMap();
|
|
156
|
+
|
|
157
|
+
const before = Date.now();
|
|
158
|
+
map.set("k", "v", "trusting");
|
|
159
|
+
const after = Date.now();
|
|
160
|
+
|
|
161
|
+
const txs = map.core.getValidSortedTransactions();
|
|
162
|
+
const lastTx = txs.at(-1);
|
|
163
|
+
expect(lastTx).toBeDefined();
|
|
164
|
+
expect(lastTx!.madeAt).toBeGreaterThanOrEqual(before);
|
|
165
|
+
expect(lastTx!.madeAt).toBeLessThanOrEqual(after + 100);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("clock sync pulls skewed client stamps toward server time", () => {
|
|
170
|
+
test("with both nodes flag-on, a client whose wall clock is 20s ahead authors transactions that land near real time on the worker", async () => {
|
|
171
|
+
const errorSpy = vi.spyOn(logger, "error");
|
|
172
|
+
|
|
173
|
+
const worker = await setupTestAccount({
|
|
174
|
+
isSyncServer: true,
|
|
175
|
+
experimental_clockSyncFromServerPings: true,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const realNowBeforeClient = Date.now();
|
|
179
|
+
|
|
180
|
+
const SKEW_MS = 20_000;
|
|
181
|
+
vi.spyOn(Date, "now").mockImplementation(() => {
|
|
182
|
+
const real = Math.floor(performance.timeOrigin + performance.now());
|
|
183
|
+
return real + SKEW_MS;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const client = await setupTestAccount({
|
|
187
|
+
connected: true,
|
|
188
|
+
experimental_clockSyncFromServerPings: true,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const localReceiveTime = Date.now();
|
|
192
|
+
client.node.clockOffset.addSample({
|
|
193
|
+
serverTime: localReceiveTime - SKEW_MS,
|
|
194
|
+
localReceiveTime,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(client.node.clockOffset.currentOffset()).toBeLessThanOrEqual(
|
|
198
|
+
-SKEW_MS + 100,
|
|
199
|
+
);
|
|
200
|
+
expect(client.node.clockOffset.currentOffset()).toBeGreaterThanOrEqual(
|
|
201
|
+
-SKEW_MS - 100,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const group = client.node.createGroup();
|
|
205
|
+
group.addMember("everyone", "writer");
|
|
206
|
+
|
|
207
|
+
const map = group.createMap();
|
|
208
|
+
map.set("from", "skewed-client", "trusting");
|
|
209
|
+
|
|
210
|
+
await map.core.waitForSync();
|
|
211
|
+
await group.core.waitForSync();
|
|
212
|
+
|
|
213
|
+
vi.restoreAllMocks();
|
|
214
|
+
const errorSpyAfter = vi.spyOn(logger, "error");
|
|
215
|
+
|
|
216
|
+
const realNowAfter = Date.now();
|
|
217
|
+
|
|
218
|
+
const mapOnWorker = await loadCoValueOrFail(worker.node, map.id);
|
|
219
|
+
|
|
220
|
+
await waitFor(() => {
|
|
221
|
+
expect(mapOnWorker.get("from")).toBe("skewed-client");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const clientTxsOnWorker = mapOnWorker.core.getValidSortedTransactions();
|
|
225
|
+
const setTx = clientTxsOnWorker.find((tx) => {
|
|
226
|
+
const changes = tx.changes as ReadonlyArray<{
|
|
227
|
+
op?: string;
|
|
228
|
+
key?: string;
|
|
229
|
+
value?: unknown;
|
|
230
|
+
}>;
|
|
231
|
+
return (
|
|
232
|
+
changes?.[0]?.key === "from" && changes[0]?.value === "skewed-client"
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(setTx).toBeDefined();
|
|
237
|
+
|
|
238
|
+
const TOLERANCE_MS = 2_000;
|
|
239
|
+
expect(setTx!.madeAt).toBeGreaterThanOrEqual(
|
|
240
|
+
realNowBeforeClient - TOLERANCE_MS,
|
|
241
|
+
);
|
|
242
|
+
expect(setTx!.madeAt).toBeLessThanOrEqual(realNowAfter + TOLERANCE_MS);
|
|
243
|
+
|
|
244
|
+
expect(setTx!.madeAt).toBeLessThan(realNowAfter + SKEW_MS - 5_000);
|
|
245
|
+
|
|
246
|
+
const forbiddenFragments = [
|
|
247
|
+
"invalid transaction",
|
|
248
|
+
"permission",
|
|
249
|
+
"rejected",
|
|
250
|
+
"not authorized",
|
|
251
|
+
"not authorised",
|
|
252
|
+
"out of order",
|
|
253
|
+
];
|
|
254
|
+
const offendingCalls = [...errorSpy.mock.calls, ...errorSpyAfter.mock.calls]
|
|
255
|
+
.map((call) => call.map((arg) => String(arg)).join(" "))
|
|
256
|
+
.filter((line) =>
|
|
257
|
+
forbiddenFragments.some((f) => line.toLowerCase().includes(f)),
|
|
258
|
+
);
|
|
259
|
+
expect(offendingCalls).toEqual([]);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { ClockOffset } from "../ClockOffset.js";
|
|
3
|
+
import { LocalNode } from "../localNode.js";
|
|
4
|
+
import { randomAgentAndSessionID } from "./testUtils.js";
|
|
5
|
+
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
|
6
|
+
|
|
7
|
+
const Crypto = await WasmCrypto.create();
|
|
8
|
+
|
|
9
|
+
function makeNode(opts: { experimental_clockSyncFromServerPings?: boolean }) {
|
|
10
|
+
const [admin, session] = randomAgentAndSessionID();
|
|
11
|
+
return new LocalNode(
|
|
12
|
+
admin.agentSecret,
|
|
13
|
+
session,
|
|
14
|
+
Crypto,
|
|
15
|
+
undefined,
|
|
16
|
+
undefined,
|
|
17
|
+
opts,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("LocalNode clock offset wiring", () => {
|
|
22
|
+
test("clockOffset is always present on the node, regardless of flag state", () => {
|
|
23
|
+
const withFlag = makeNode({ experimental_clockSyncFromServerPings: true });
|
|
24
|
+
const withoutFlag = makeNode({
|
|
25
|
+
experimental_clockSyncFromServerPings: false,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(withFlag.clockOffset).toBeInstanceOf(ClockOffset);
|
|
29
|
+
expect(withoutFlag.clockOffset).toBeInstanceOf(ClockOffset);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("with the flag enabled, seeding clockOffset with a +10_000 ms sample pulls stampNow() forward by ~10_000 ms", () => {
|
|
33
|
+
const flagged = makeNode({ experimental_clockSyncFromServerPings: true });
|
|
34
|
+
const control = makeNode({
|
|
35
|
+
experimental_clockSyncFromServerPings: false,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const localReceiveTime = Date.now();
|
|
39
|
+
flagged.clockOffset.addSample({
|
|
40
|
+
serverTime: localReceiveTime + 10_000,
|
|
41
|
+
localReceiveTime,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const controlStamp = control.stampNow();
|
|
45
|
+
const flaggedStamp = flagged.stampNow();
|
|
46
|
+
|
|
47
|
+
const delta = flaggedStamp - controlStamp;
|
|
48
|
+
expect(delta).toBeGreaterThanOrEqual(9_900);
|
|
49
|
+
expect(delta).toBeLessThanOrEqual(10_100);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("getClockOffsetDiagnostics() reflects current offset and sample count", () => {
|
|
53
|
+
const node = makeNode({ experimental_clockSyncFromServerPings: true });
|
|
54
|
+
|
|
55
|
+
expect(node.getClockOffsetDiagnostics()).toEqual({
|
|
56
|
+
currentOffset: 0,
|
|
57
|
+
sampleCount: 0,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const base = Date.now();
|
|
61
|
+
node.clockOffset.addSample({
|
|
62
|
+
serverTime: base + 400,
|
|
63
|
+
localReceiveTime: base,
|
|
64
|
+
});
|
|
65
|
+
node.clockOffset.addSample({
|
|
66
|
+
serverTime: base + 600,
|
|
67
|
+
localReceiveTime: base,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(node.getClockOffsetDiagnostics()).toEqual({
|
|
71
|
+
currentOffset: 500,
|
|
72
|
+
sampleCount: 2,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("without the flag, seeding clockOffset with a +10_000 ms sample does NOT affect stampNow()", () => {
|
|
77
|
+
const node = makeNode({ experimental_clockSyncFromServerPings: false });
|
|
78
|
+
|
|
79
|
+
const localReceiveTime = Date.now();
|
|
80
|
+
node.clockOffset.addSample({
|
|
81
|
+
serverTime: localReceiveTime + 10_000,
|
|
82
|
+
localReceiveTime,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const before = Date.now();
|
|
86
|
+
const stamp = node.stampNow();
|
|
87
|
+
const after = Date.now();
|
|
88
|
+
|
|
89
|
+
expect(stamp).toBeGreaterThanOrEqual(before);
|
|
90
|
+
expect(stamp).toBeLessThanOrEqual(after + 100);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -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
|
+
});
|