cojson 0.20.16 → 0.20.18

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 (36) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +17 -0
  3. package/dist/ClockOffset.d.ts +21 -0
  4. package/dist/ClockOffset.d.ts.map +1 -0
  5. package/dist/ClockOffset.js +55 -0
  6. package/dist/ClockOffset.js.map +1 -0
  7. package/dist/coValueCore/coValueCore.js +2 -2
  8. package/dist/coValueCore/coValueCore.js.map +1 -1
  9. package/dist/localNode.d.ts +16 -3
  10. package/dist/localNode.d.ts.map +1 -1
  11. package/dist/localNode.js +36 -5
  12. package/dist/localNode.js.map +1 -1
  13. package/dist/tests/ClockOffset.test.d.ts +2 -0
  14. package/dist/tests/ClockOffset.test.d.ts.map +1 -0
  15. package/dist/tests/ClockOffset.test.js +146 -0
  16. package/dist/tests/ClockOffset.test.js.map +1 -0
  17. package/dist/tests/clockDrift.integration.test.d.ts +2 -0
  18. package/dist/tests/clockDrift.integration.test.d.ts.map +1 -0
  19. package/dist/tests/clockDrift.integration.test.js +177 -0
  20. package/dist/tests/clockDrift.integration.test.js.map +1 -0
  21. package/dist/tests/localNode.clockOffset.test.d.ts +2 -0
  22. package/dist/tests/localNode.clockOffset.test.d.ts.map +1 -0
  23. package/dist/tests/localNode.clockOffset.test.js +70 -0
  24. package/dist/tests/localNode.clockOffset.test.js.map +1 -0
  25. package/dist/tests/testUtils.d.ts +2 -0
  26. package/dist/tests/testUtils.d.ts.map +1 -1
  27. package/dist/tests/testUtils.js +9 -2
  28. package/dist/tests/testUtils.js.map +1 -1
  29. package/package.json +4 -4
  30. package/src/ClockOffset.ts +83 -0
  31. package/src/coValueCore/coValueCore.ts +2 -2
  32. package/src/localNode.ts +31 -0
  33. package/src/tests/ClockOffset.test.ts +177 -0
  34. package/src/tests/clockDrift.integration.test.ts +261 -0
  35. package/src/tests/localNode.clockOffset.test.ts +92 -0
  36. 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
+ });
@@ -452,6 +452,7 @@ export function setupTestNode(
452
452
  secret?: AgentSecret;
453
453
  syncWhen?: SyncWhen;
454
454
  enableFullStorageReconciliation?: boolean;
455
+ experimental_clockSyncFromServerPings?: boolean;
455
456
  } = {},
456
457
  ) {
457
458
  const [admin, session] = opts.secret
@@ -464,6 +465,10 @@ export function setupTestNode(
464
465
  Crypto,
465
466
  opts.syncWhen,
466
467
  opts.enableFullStorageReconciliation,
468
+ {
469
+ experimental_clockSyncFromServerPings:
470
+ opts.experimental_clockSyncFromServerPings,
471
+ },
467
472
  );
468
473
 
469
474
  if (opts.isSyncServer) {
@@ -542,6 +547,10 @@ export function setupTestNode(
542
547
  Crypto,
543
548
  opts.syncWhen,
544
549
  opts.enableFullStorageReconciliation,
550
+ {
551
+ experimental_clockSyncFromServerPings:
552
+ opts.experimental_clockSyncFromServerPings,
553
+ },
545
554
  );
546
555
 
547
556
  if (opts.isSyncServer) {
@@ -556,6 +565,8 @@ export function setupTestNode(
556
565
  connected: opts.connected,
557
566
  isSyncServer: opts.isSyncServer,
558
567
  enableFullStorageReconciliation: opts.enableFullStorageReconciliation,
568
+ experimental_clockSyncFromServerPings:
569
+ opts.experimental_clockSyncFromServerPings,
559
570
  });
560
571
  },
561
572
  disconnect: () => {
@@ -577,6 +588,7 @@ export async function setupTestAccount(
577
588
  storage?: StorageAPI;
578
589
  accountID?: RawAccountID;
579
590
  accountSecret?: AgentSecret;
591
+ experimental_clockSyncFromServerPings?: boolean;
580
592
  } = {},
581
593
  ) {
582
594
  const ctx =
@@ -593,6 +605,8 @@ export async function setupTestAccount(
593
605
  accountID: opts.accountID,
594
606
  accountSecret: opts.accountSecret,
595
607
  sessionID: Crypto.newRandomSessionID(opts.accountID),
608
+ experimental_clockSyncFromServerPings:
609
+ opts.experimental_clockSyncFromServerPings,
596
610
  }),
597
611
  accountID: opts.accountID,
598
612
  accountSecret: opts.accountSecret,
@@ -602,6 +616,8 @@ export async function setupTestAccount(
602
616
  crypto: Crypto,
603
617
  creationProps: { name: "Client" },
604
618
  storage: opts.storage,
619
+ experimental_clockSyncFromServerPings:
620
+ opts.experimental_clockSyncFromServerPings,
605
621
  });
606
622
 
607
623
  if (opts.isSyncServer) {