@pylonsync/sync 0.3.212 → 0.3.215
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/package.json +4 -1
- package/src/bootstrap-speedup.test.ts +507 -0
- package/src/idb-warm-load.test.ts +426 -0
- package/src/index.ts +236 -76
- package/src/persistence.ts +55 -0
package/package.json
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.3.
|
|
6
|
+
"version": "0.3.215",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "src/index.ts",
|
|
9
9
|
"types": "src/index.ts",
|
|
10
10
|
"scripts": {
|
|
11
11
|
"check": "tsc -p tsconfig.json --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"fake-indexeddb": "^6.2.5"
|
|
12
15
|
}
|
|
13
16
|
}
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
// Regression tests for the four bootstrap-speedup fixes:
|
|
2
|
+
//
|
|
3
|
+
// A. Parallelize reconcile entity fetches (Promise.all fan-out)
|
|
4
|
+
// B. Skip reconcile after cold-load pull (cursor === 0 fast path)
|
|
5
|
+
// C. Race multi-tab election || /api/auth/me on start()
|
|
6
|
+
// D. Fire-and-forget saveCursor on bootstrap
|
|
7
|
+
//
|
|
8
|
+
// Each test asserts the behavior change that the corresponding fix
|
|
9
|
+
// produces. A revert (back to sequential reconcile, unconditional
|
|
10
|
+
// reconcile after pull, sequential election+auth, awaited saveCursor)
|
|
11
|
+
// makes the named test fail.
|
|
12
|
+
|
|
13
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
import { SyncEngine, type ChangeEvent, type Row } from "./index";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Minimal fetch stub. Same shape as reconcile.test.ts so the helpers stay
|
|
19
|
+
// readable; duplicated rather than imported to keep the regression test
|
|
20
|
+
// file self-contained.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
type FetchHandler = (
|
|
24
|
+
url: string,
|
|
25
|
+
init?: RequestInit,
|
|
26
|
+
) => Promise<{ status: number; body: unknown }>;
|
|
27
|
+
|
|
28
|
+
function installFetch(handler: FetchHandler): () => void {
|
|
29
|
+
const original = globalThis.fetch;
|
|
30
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
31
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
32
|
+
const { status, body } = await handler(url, init);
|
|
33
|
+
return {
|
|
34
|
+
ok: status >= 200 && status < 300,
|
|
35
|
+
status,
|
|
36
|
+
json: async () => body,
|
|
37
|
+
text: async () => JSON.stringify(body),
|
|
38
|
+
} as Response;
|
|
39
|
+
}) as typeof fetch;
|
|
40
|
+
return () => {
|
|
41
|
+
globalThis.fetch = original;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeEngine(): SyncEngine {
|
|
46
|
+
return new SyncEngine({
|
|
47
|
+
baseUrl: "http://stub.invalid",
|
|
48
|
+
persist: false,
|
|
49
|
+
reconcileMinIntervalMs: 0,
|
|
50
|
+
multiTab: false,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function seedStore(engine: SyncEngine, entity: string, rows: Row[]): void {
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
const id = (row as { id?: unknown }).id;
|
|
57
|
+
if (typeof id !== "string") continue;
|
|
58
|
+
const ev: ChangeEvent = {
|
|
59
|
+
seq: 0,
|
|
60
|
+
entity,
|
|
61
|
+
row_id: id,
|
|
62
|
+
kind: "insert",
|
|
63
|
+
data: row,
|
|
64
|
+
timestamp: "",
|
|
65
|
+
};
|
|
66
|
+
engine.store.applyChange(ev);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Fix A — Parallel reconcile fan-out
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
describe("Fix A: parallel reconcile fan-out", () => {
|
|
75
|
+
let restore: (() => void) | null = null;
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
restore?.();
|
|
78
|
+
restore = null;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("fetches all entities concurrently, not sequentially", async () => {
|
|
82
|
+
// Each /api/entities/<E>/cursor handler sleeps 60ms. With sequential
|
|
83
|
+
// iteration (pre-fix) 3 entities take ~180ms. With Promise.all
|
|
84
|
+
// fan-out they take ~60ms. Assert clearly under the sequential
|
|
85
|
+
// baseline so a revert blows up.
|
|
86
|
+
const perEntityDelayMs = 60;
|
|
87
|
+
restore = installFetch(async (url) => {
|
|
88
|
+
if (url.includes("/api/entities/")) {
|
|
89
|
+
await new Promise((r) => setTimeout(r, perEntityDelayMs));
|
|
90
|
+
return {
|
|
91
|
+
status: 200,
|
|
92
|
+
body: { data: [], next_cursor: null, has_more: false },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return { status: 404, body: {} };
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const engine = makeEngine();
|
|
99
|
+
seedStore(engine, "A", [{ id: "a1" }]);
|
|
100
|
+
seedStore(engine, "B", [{ id: "b1" }]);
|
|
101
|
+
seedStore(engine, "C", [{ id: "c1" }]);
|
|
102
|
+
|
|
103
|
+
const t0 = Date.now();
|
|
104
|
+
await engine.reconcile(["A", "B", "C"]);
|
|
105
|
+
const elapsed = Date.now() - t0;
|
|
106
|
+
|
|
107
|
+
// Sequential would be ~180ms. Parallel should be ~60ms. Allow a
|
|
108
|
+
// generous ceiling (120ms) for CI jitter without losing the signal.
|
|
109
|
+
expect(elapsed).toBeLessThan(120);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("per-entity drift bail still fires under parallel execution", async () => {
|
|
113
|
+
// Mid-flight, flip the session signature so the post-await guard
|
|
114
|
+
// skips the apply for the in-flight entity. The other entities'
|
|
115
|
+
// applies still proceed normally. Reconcile must NOT tombstone
|
|
116
|
+
// either entity's local rows on the racing one (drift skip) or
|
|
117
|
+
// the non-racing one (signature held steady through its window).
|
|
118
|
+
let flipped = false;
|
|
119
|
+
const engine = makeEngine();
|
|
120
|
+
engine.session.observeSession({
|
|
121
|
+
userId: "u1",
|
|
122
|
+
tenantId: "org-a",
|
|
123
|
+
isAdmin: false,
|
|
124
|
+
roles: [],
|
|
125
|
+
});
|
|
126
|
+
restore = installFetch(async (url) => {
|
|
127
|
+
if (url.includes("/api/entities/Racing/cursor")) {
|
|
128
|
+
// First Racing fetch: flip the session mid-flight, return
|
|
129
|
+
// empty. Apply must be skipped (signature changed).
|
|
130
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
131
|
+
if (!flipped) {
|
|
132
|
+
flipped = true;
|
|
133
|
+
engine.session.observeSession({
|
|
134
|
+
userId: "u1",
|
|
135
|
+
tenantId: "org-b",
|
|
136
|
+
isAdmin: false,
|
|
137
|
+
roles: [],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
status: 200,
|
|
142
|
+
body: { data: [], next_cursor: null, has_more: false },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Stable entity: return its row so the apply path is exercised
|
|
146
|
+
// (no-op since the row already matches), proving the parallel
|
|
147
|
+
// task didn't get cancelled.
|
|
148
|
+
if (url.includes("/api/entities/Stable/cursor")) {
|
|
149
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
150
|
+
return {
|
|
151
|
+
status: 200,
|
|
152
|
+
body: {
|
|
153
|
+
data: [{ id: "s1", v: 1 }],
|
|
154
|
+
next_cursor: null,
|
|
155
|
+
has_more: false,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return { status: 404, body: {} };
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
seedStore(engine, "Racing", [{ id: "r1", v: 1 }]);
|
|
163
|
+
seedStore(engine, "Stable", [{ id: "s1", v: 1 }]);
|
|
164
|
+
|
|
165
|
+
await engine.reconcile(["Racing", "Stable"]);
|
|
166
|
+
|
|
167
|
+
// Racing row must survive — the parallel task bailed on the
|
|
168
|
+
// session-signature drift. (Pre-fix sequential code path also
|
|
169
|
+
// honored this guard, so we're really asserting that the parallel
|
|
170
|
+
// refactor didn't drop it.)
|
|
171
|
+
expect(engine.store.list("Racing").length).toBe(1);
|
|
172
|
+
// Stable row should still be present (apply was a no-op diff).
|
|
173
|
+
expect(engine.store.list("Stable").length).toBe(1);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Fix B — Skip reconcile after cold-load pull from cursor=0
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
describe("Fix B: cold-load skip reconcile", () => {
|
|
182
|
+
let restore: (() => void) | null = null;
|
|
183
|
+
afterEach(() => {
|
|
184
|
+
restore?.();
|
|
185
|
+
restore = null;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("lastPullStartedFromZero flag set after successful cold pull", async () => {
|
|
189
|
+
restore = installFetch(async (url) => {
|
|
190
|
+
if (url.includes("/api/sync/pull")) {
|
|
191
|
+
return {
|
|
192
|
+
status: 200,
|
|
193
|
+
body: {
|
|
194
|
+
changes: [],
|
|
195
|
+
cursor: { last_seq: 5 },
|
|
196
|
+
has_more: false,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return { status: 404, body: {} };
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const engine = makeEngine();
|
|
204
|
+
// Cursor starts at 0, so this pull is a "cold load."
|
|
205
|
+
await engine.pull();
|
|
206
|
+
|
|
207
|
+
const flag = (engine as unknown as { lastPullStartedFromZero: boolean })
|
|
208
|
+
.lastPullStartedFromZero;
|
|
209
|
+
expect(flag).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("lastPullStartedFromZero NOT set when cursor > 0", async () => {
|
|
213
|
+
let pullSeenSince = "";
|
|
214
|
+
restore = installFetch(async (url) => {
|
|
215
|
+
if (url.includes("/api/sync/pull")) {
|
|
216
|
+
const match = url.match(/since=(\d+)/);
|
|
217
|
+
pullSeenSince = match ? match[1] : "";
|
|
218
|
+
return {
|
|
219
|
+
status: 200,
|
|
220
|
+
body: {
|
|
221
|
+
changes: [],
|
|
222
|
+
cursor: { last_seq: 10 },
|
|
223
|
+
has_more: false,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return { status: 404, body: {} };
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const engine = makeEngine();
|
|
231
|
+
// Advance the cursor — subsequent pull is incremental, NOT a cold
|
|
232
|
+
// load. The post-WS reconcile should still run.
|
|
233
|
+
(engine as unknown as { cursor: { last_seq: number } }).cursor = {
|
|
234
|
+
last_seq: 7,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
await engine.pull();
|
|
238
|
+
expect(pullSeenSince).toBe("7");
|
|
239
|
+
|
|
240
|
+
const flag = (engine as unknown as { lastPullStartedFromZero: boolean })
|
|
241
|
+
.lastPullStartedFromZero;
|
|
242
|
+
expect(flag).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("flag survives one-shot consumption: reset to false manually", async () => {
|
|
246
|
+
restore = installFetch(async (url) => {
|
|
247
|
+
if (url.includes("/api/sync/pull")) {
|
|
248
|
+
return {
|
|
249
|
+
status: 200,
|
|
250
|
+
body: {
|
|
251
|
+
changes: [],
|
|
252
|
+
cursor: { last_seq: 1 },
|
|
253
|
+
has_more: false,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return { status: 404, body: {} };
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const engine = makeEngine();
|
|
261
|
+
await engine.pull();
|
|
262
|
+
|
|
263
|
+
const internal = engine as unknown as { lastPullStartedFromZero: boolean };
|
|
264
|
+
expect(internal.lastPullStartedFromZero).toBe(true);
|
|
265
|
+
// Simulate what onConnected does: read + reset.
|
|
266
|
+
internal.lastPullStartedFromZero = false;
|
|
267
|
+
expect(internal.lastPullStartedFromZero).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Fix C — Parallel election || /api/auth/me on bootstrap
|
|
273
|
+
//
|
|
274
|
+
// The trickiest fix to test because it requires two engines sharing a
|
|
275
|
+
// BroadcastChannel. We sidestep that here by asserting the engine-level
|
|
276
|
+
// invariant directly: fetchSessionBootstrap exists, is NOT gated by
|
|
277
|
+
// `isMultiTabLeader`, and the bootstrap call site (start()) discards
|
|
278
|
+
// its result when the engine ended up a follower.
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
describe("Fix C: race election || auth/me", () => {
|
|
282
|
+
let restore: (() => void) | null = null;
|
|
283
|
+
afterEach(() => {
|
|
284
|
+
restore?.();
|
|
285
|
+
restore = null;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("fetchSessionBootstrap returns ResolvedSession without isMultiTabLeader gate", async () => {
|
|
289
|
+
let authMeHits = 0;
|
|
290
|
+
restore = installFetch(async (url) => {
|
|
291
|
+
if (url.endsWith("/api/auth/me")) {
|
|
292
|
+
authMeHits += 1;
|
|
293
|
+
return {
|
|
294
|
+
status: 200,
|
|
295
|
+
body: {
|
|
296
|
+
user_id: "u1",
|
|
297
|
+
tenant_id: "org-x",
|
|
298
|
+
is_admin: false,
|
|
299
|
+
roles: ["editor"],
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return { status: 404, body: {} };
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const engine = makeEngine();
|
|
307
|
+
// Force follower state — refreshResolvedSession() would return
|
|
308
|
+
// immediately under this flag. fetchSessionBootstrap MUST still
|
|
309
|
+
// do the network fetch (that's the whole point of the parallel
|
|
310
|
+
// bootstrap path: we don't know we lost until the election
|
|
311
|
+
// resolves, by which time the fetch is in flight).
|
|
312
|
+
(engine as unknown as { isMultiTabLeader: boolean }).isMultiTabLeader = false;
|
|
313
|
+
|
|
314
|
+
const result = await (
|
|
315
|
+
engine as unknown as {
|
|
316
|
+
fetchSessionBootstrap(): Promise<{
|
|
317
|
+
userId: string | null;
|
|
318
|
+
tenantId: string | null;
|
|
319
|
+
isAdmin: boolean;
|
|
320
|
+
roles: string[];
|
|
321
|
+
} | null>;
|
|
322
|
+
}
|
|
323
|
+
).fetchSessionBootstrap();
|
|
324
|
+
|
|
325
|
+
expect(authMeHits).toBe(1);
|
|
326
|
+
expect(result).not.toBeNull();
|
|
327
|
+
expect(result?.userId).toBe("u1");
|
|
328
|
+
expect(result?.tenantId).toBe("org-x");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("fetchSessionBootstrap returns null on non-ok response", async () => {
|
|
332
|
+
restore = installFetch(async (url) => {
|
|
333
|
+
if (url.endsWith("/api/auth/me")) {
|
|
334
|
+
return { status: 401, body: {} };
|
|
335
|
+
}
|
|
336
|
+
return { status: 404, body: {} };
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const engine = makeEngine();
|
|
340
|
+
const result = await (
|
|
341
|
+
engine as unknown as {
|
|
342
|
+
fetchSessionBootstrap(): Promise<unknown>;
|
|
343
|
+
}
|
|
344
|
+
).fetchSessionBootstrap();
|
|
345
|
+
expect(result).toBeNull();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("fetchSessionBootstrap returns null on thrown fetch", async () => {
|
|
349
|
+
const original = globalThis.fetch;
|
|
350
|
+
globalThis.fetch = (async () => {
|
|
351
|
+
throw new Error("network down");
|
|
352
|
+
}) as unknown as typeof fetch;
|
|
353
|
+
restore = () => {
|
|
354
|
+
globalThis.fetch = original;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const engine = makeEngine();
|
|
358
|
+
const result = await (
|
|
359
|
+
engine as unknown as {
|
|
360
|
+
fetchSessionBootstrap(): Promise<unknown>;
|
|
361
|
+
}
|
|
362
|
+
).fetchSessionBootstrap();
|
|
363
|
+
expect(result).toBeNull();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("two tabs racing election: only the leader applies its bootstrap session", async () => {
|
|
367
|
+
// Multi-tab integration: two engines share a BroadcastChannel (Bun
|
|
368
|
+
// provides one in its test runtime). Both kick off
|
|
369
|
+
// fetchSessionBootstrap in parallel. Both fetches land — the
|
|
370
|
+
// network fetch isn't leader-gated; that's the whole speedup —
|
|
371
|
+
// but only the engine that won the election routes the result
|
|
372
|
+
// into the resolver via applySessionTransition. The follower's
|
|
373
|
+
// result is discarded by start().
|
|
374
|
+
//
|
|
375
|
+
// This test pins the invariant: a follower MUST NOT commit a
|
|
376
|
+
// session it fetched during the parallel bootstrap. If a future
|
|
377
|
+
// refactor "simplifies" the bootstrap back to gating the fetch on
|
|
378
|
+
// isMultiTabLeader, no behavior changes here (the test still
|
|
379
|
+
// passes); if the refactor instead drops the leader check at the
|
|
380
|
+
// APPLY point, follower starts double-committing — and this test
|
|
381
|
+
// fails because the follower would call applySessionTransition
|
|
382
|
+
// which broadcasts back to the leader.
|
|
383
|
+
|
|
384
|
+
const appName = "boot-race-" + Math.random().toString(36).slice(2);
|
|
385
|
+
let authMeHits = 0;
|
|
386
|
+
const original = globalThis.fetch;
|
|
387
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
388
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
389
|
+
if (url.endsWith("/api/auth/me")) {
|
|
390
|
+
authMeHits += 1;
|
|
391
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
392
|
+
return {
|
|
393
|
+
ok: true,
|
|
394
|
+
status: 200,
|
|
395
|
+
json: async () => ({
|
|
396
|
+
user_id: "u1",
|
|
397
|
+
tenant_id: null,
|
|
398
|
+
is_admin: false,
|
|
399
|
+
roles: [],
|
|
400
|
+
}),
|
|
401
|
+
text: async () => "",
|
|
402
|
+
} as Response;
|
|
403
|
+
}
|
|
404
|
+
if (url.includes("/api/sync/pull")) {
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
status: 200,
|
|
408
|
+
json: async () => ({
|
|
409
|
+
changes: [],
|
|
410
|
+
cursor: { last_seq: 0 },
|
|
411
|
+
has_more: false,
|
|
412
|
+
}),
|
|
413
|
+
text: async () => "",
|
|
414
|
+
} as Response;
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
status: 404,
|
|
419
|
+
json: async () => ({}),
|
|
420
|
+
text: async () => "",
|
|
421
|
+
} as Response;
|
|
422
|
+
}) as typeof fetch;
|
|
423
|
+
restore = () => {
|
|
424
|
+
globalThis.fetch = original;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// Two engines, same appName, multi-tab broker enabled. They share
|
|
428
|
+
// a BroadcastChannel and elect a leader.
|
|
429
|
+
const a = new SyncEngine({
|
|
430
|
+
baseUrl: "http://stub.invalid",
|
|
431
|
+
appName,
|
|
432
|
+
persist: false,
|
|
433
|
+
reconcileMinIntervalMs: 0,
|
|
434
|
+
// Polling avoids opening a real WebSocket (Bun's WebSocket
|
|
435
|
+
// global doesn't speak the test transport's protocol).
|
|
436
|
+
transport: "poll",
|
|
437
|
+
});
|
|
438
|
+
// Wait a moment so b's election startTime is strictly greater
|
|
439
|
+
// → a wins the election deterministically.
|
|
440
|
+
const startA = a.start();
|
|
441
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
442
|
+
const b = new SyncEngine({
|
|
443
|
+
baseUrl: "http://stub.invalid",
|
|
444
|
+
appName,
|
|
445
|
+
persist: false,
|
|
446
|
+
reconcileMinIntervalMs: 0,
|
|
447
|
+
transport: "poll",
|
|
448
|
+
});
|
|
449
|
+
const startB = b.start();
|
|
450
|
+
|
|
451
|
+
await Promise.all([startA, startB]);
|
|
452
|
+
// Let any trailing broadcasts / poll ticks settle.
|
|
453
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
454
|
+
|
|
455
|
+
// a is the leader, b is the follower.
|
|
456
|
+
const aLeader = (a as unknown as { isMultiTabLeader: boolean }).isMultiTabLeader;
|
|
457
|
+
const bLeader = (b as unknown as { isMultiTabLeader: boolean }).isMultiTabLeader;
|
|
458
|
+
expect(aLeader).toBe(true);
|
|
459
|
+
expect(bLeader).toBe(false);
|
|
460
|
+
|
|
461
|
+
// Both engines may have fetched /api/auth/me during their parallel
|
|
462
|
+
// bootstrap (that's the speedup). The invariant is that the
|
|
463
|
+
// follower's session state still came from the LEADER broadcast,
|
|
464
|
+
// not from its own discarded fetch. We verify by checking the
|
|
465
|
+
// follower's session resolved to the leader's view.
|
|
466
|
+
const aSession = a.session.signature();
|
|
467
|
+
const bSession = b.session.signature();
|
|
468
|
+
expect(bSession).toBe(aSession);
|
|
469
|
+
// Both saw auth/me at most once each (no retry storm).
|
|
470
|
+
expect(authMeHits).toBeGreaterThanOrEqual(1);
|
|
471
|
+
expect(authMeHits).toBeLessThanOrEqual(2);
|
|
472
|
+
|
|
473
|
+
a.stop();
|
|
474
|
+
b.stop();
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// Fix D — Fire-and-forget saveCursor on bootstrap
|
|
480
|
+
//
|
|
481
|
+
// The behavioral change is purely "didn't block start()". Hard to assert
|
|
482
|
+
// directly without timing flakiness; instead pin the invariant that
|
|
483
|
+
// start() completes even when saveCursor is artificially slow.
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
describe("Fix D: fire-and-forget bootstrap saveCursor", () => {
|
|
487
|
+
// This is the lightest-weight assertion that pins the fix: the
|
|
488
|
+
// bootstrap call site uses `void this.persistence.saveCursor(...)`,
|
|
489
|
+
// not `await`. We can't easily install a slow persistence layer into
|
|
490
|
+
// an engine that explicitly opted out of persistence, so the
|
|
491
|
+
// regression coverage here is the AST shape — verified by reading
|
|
492
|
+
// the file and asserting the call is not awaited.
|
|
493
|
+
test("source uses void (not await) for the bootstrap saveCursor", async () => {
|
|
494
|
+
const path = new URL("./index.ts", import.meta.url).pathname;
|
|
495
|
+
const src = await Bun.file(path).text();
|
|
496
|
+
// Find the bootstrap save block.
|
|
497
|
+
const marker = "// Save cursor after pull.";
|
|
498
|
+
const idx = src.indexOf(marker);
|
|
499
|
+
expect(idx).toBeGreaterThan(0);
|
|
500
|
+
const window = src.slice(idx, idx + 800);
|
|
501
|
+
// The fixed form uses `void this.persistence.saveCursor`; the
|
|
502
|
+
// pre-fix form was `await this.persistence.saveCursor`. Pin both
|
|
503
|
+
// sides of that change so a revert fails clearly.
|
|
504
|
+
expect(window).toContain("void this.persistence.saveCursor(this.cursor)");
|
|
505
|
+
expect(window).not.toContain("await this.persistence.saveCursor");
|
|
506
|
+
});
|
|
507
|
+
});
|