@pylonsync/sync 0.3.211 → 0.3.213
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 +1 -1
- package/src/bootstrap-speedup.test.ts +507 -0
- package/src/index.ts +164 -69
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -512,12 +512,30 @@ export class SyncEngine {
|
|
|
512
512
|
// applied changes broadcast by the leader. The election settles
|
|
513
513
|
// in ~250ms; if the broker is unavailable (no BroadcastChannel)
|
|
514
514
|
// every tab is implicitly its own leader.
|
|
515
|
-
|
|
515
|
+
//
|
|
516
|
+
// Bootstrap parallelization: the election (~250ms) and
|
|
517
|
+
// /api/auth/me (~60ms) are independent — kick both off, then
|
|
518
|
+
// await election first. If we lose the election we discard the
|
|
519
|
+
// session result and let the leader broadcast its session over
|
|
520
|
+
// the multi-tab channel; the "leader-only network writes"
|
|
521
|
+
// invariant is preserved because no peers have observed our
|
|
522
|
+
// pending /api/auth/me request and no apply has happened yet.
|
|
523
|
+
const electionPromise = this.initMultiTab();
|
|
524
|
+
const sessionPromise = this.fetchSessionBootstrap().catch(() => null);
|
|
525
|
+
await electionPromise;
|
|
516
526
|
|
|
517
527
|
if (!this.isMultiTabLeader) {
|
|
518
528
|
// Follower path: rely on the leader's broadcasts for session +
|
|
519
529
|
// applied changes. Nothing else to do here — the broker is
|
|
520
|
-
// wired to forward inbound messages into the engine.
|
|
530
|
+
// wired to forward inbound messages into the engine. The
|
|
531
|
+
// sessionPromise we kicked off above resolves into the void;
|
|
532
|
+
// the leader's broadcast will deliver the authoritative view.
|
|
533
|
+
// Swallow any pending error so it doesn't surface as an
|
|
534
|
+
// unhandled rejection.
|
|
535
|
+
void sessionPromise.then(
|
|
536
|
+
() => {},
|
|
537
|
+
() => {},
|
|
538
|
+
);
|
|
521
539
|
return;
|
|
522
540
|
}
|
|
523
541
|
|
|
@@ -533,15 +551,29 @@ export class SyncEngine {
|
|
|
533
551
|
// Seed the server-resolved session before the first pull so
|
|
534
552
|
// `useSession` subscribers see the right tenant from frame one,
|
|
535
553
|
// and the resolver's lastSeenTenant is populated before any
|
|
536
|
-
// subsequent flip can race with it.
|
|
537
|
-
|
|
554
|
+
// subsequent flip can race with it. We pre-fired the HTTP fetch
|
|
555
|
+
// above (in parallel with election); apply its result now.
|
|
556
|
+
// Falls through to a normal refresh on network/parse error so
|
|
557
|
+
// we don't get stuck without a session.
|
|
558
|
+
const bootstrapSession = await sessionPromise;
|
|
559
|
+
if (bootstrapSession !== null) {
|
|
560
|
+
await this.applySessionTransition(bootstrapSession, /* broadcast */ true);
|
|
561
|
+
} else {
|
|
562
|
+
await this.refreshResolvedSession();
|
|
563
|
+
}
|
|
538
564
|
|
|
539
565
|
// Pull from server, then connect real-time transport.
|
|
540
566
|
await this.pull();
|
|
541
567
|
|
|
542
|
-
// Save cursor after pull.
|
|
568
|
+
// Save cursor after pull. Fire-and-forget on bootstrap — the
|
|
569
|
+
// enqueueApply path already persists per-batch as pull lands rows,
|
|
570
|
+
// so this final save is belt-and-braces. Awaiting it adds 5-30ms
|
|
571
|
+
// of IDB tail latency to the critical path before transport.start
|
|
572
|
+
// runs; the apply path's idempotent op_id-keyed merge handles the
|
|
573
|
+
// worst case (one re-applied batch on next cold pull if the tab
|
|
574
|
+
// crashes between this line and the saveCursor task completing).
|
|
543
575
|
if (this.persistence) {
|
|
544
|
-
|
|
576
|
+
void this.persistence.saveCursor(this.cursor);
|
|
545
577
|
}
|
|
546
578
|
|
|
547
579
|
// First-load reconciliation pass — closes the "phantom row" gap when
|
|
@@ -1046,6 +1078,12 @@ export class SyncEngine {
|
|
|
1046
1078
|
void this.refreshResolvedSession();
|
|
1047
1079
|
}
|
|
1048
1080
|
|
|
1081
|
+
// Capture whether this pull started from cursor=0 BEFORE the
|
|
1082
|
+
// snapshot loop mutates the cursor. On successful exhaustion the
|
|
1083
|
+
// WS onConnected hook reads the flag to skip the redundant
|
|
1084
|
+
// bootstrap reconcile (the snapshot path already returned every
|
|
1085
|
+
// policy-visible row, per-entity refetch right after is waste).
|
|
1086
|
+
const startedFromZero = this.cursor.last_seq === 0;
|
|
1049
1087
|
try {
|
|
1050
1088
|
// Snapshot pagination: when the cursor is 0 and the server's
|
|
1051
1089
|
// table is larger than a single batch, the response carries
|
|
@@ -1080,6 +1118,11 @@ export class SyncEngine {
|
|
|
1080
1118
|
break;
|
|
1081
1119
|
}
|
|
1082
1120
|
}
|
|
1121
|
+
// Snapshot+tail loop exhausted without throwing: if we started
|
|
1122
|
+
// from cursor=0 we just hydrated the full replica from server
|
|
1123
|
+
// truth. Record it so onConnected skips the reconcile that would
|
|
1124
|
+
// otherwise re-fetch every entity via cursor pagination.
|
|
1125
|
+
this.lastPullStartedFromZero = startedFromZero;
|
|
1083
1126
|
} catch (err) {
|
|
1084
1127
|
// Swallow network + transient errors so the poll/reconnect loop
|
|
1085
1128
|
// keeps trying — but on 429 bump the backoff counter so the next
|
|
@@ -1137,6 +1180,17 @@ export class SyncEngine {
|
|
|
1137
1180
|
* that doesn't throw a 410. */
|
|
1138
1181
|
private consecutive_410s = 0;
|
|
1139
1182
|
|
|
1183
|
+
/** Set by pullInner whenever the just-completed pull started with
|
|
1184
|
+
* `cursor.last_seq === 0` (cold load OR post-reset). The WS
|
|
1185
|
+
* onConnected hook reads this to skip the reconcile() that would
|
|
1186
|
+
* otherwise fire immediately after the bootstrap pull — the
|
|
1187
|
+
* snapshot path of pull already returned every row visible under
|
|
1188
|
+
* current policy, so per-entity reconcile fetches right after are
|
|
1189
|
+
* pure waste (~300ms on the critical path). One-shot: the flag is
|
|
1190
|
+
* cleared on read so a subsequent reconnect-after-disconnect still
|
|
1191
|
+
* runs reconcile normally. */
|
|
1192
|
+
private lastPullStartedFromZero = false;
|
|
1193
|
+
|
|
1140
1194
|
/** Timestamp of the last `reconcile()` invocation. Used to debounce —
|
|
1141
1195
|
* reconcile runs on connect, WS reconnect, AND visibility-change, so
|
|
1142
1196
|
* a quick tab-flick after a normal reconnect shouldn't refetch every
|
|
@@ -1207,64 +1261,73 @@ export class SyncEngine {
|
|
|
1207
1261
|
// the current cursor means future inserts (which have higher seqs)
|
|
1208
1262
|
// bypass the tombstone — re-creation server-side still propagates.
|
|
1209
1263
|
const tombstoneSeq = this.cursor.last_seq;
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
//
|
|
1239
|
-
//
|
|
1240
|
-
const
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1264
|
+
// Fan out the per-entity fetches in parallel. Bootstrap reconcile
|
|
1265
|
+
// used to serialize 5 entities × ~60ms each → 300ms of dead time
|
|
1266
|
+
// on the critical path before channels render. The per-entity
|
|
1267
|
+
// drift checks (cursor + session signature) are captured inside
|
|
1268
|
+
// each task's closure, so each entity still bails individually
|
|
1269
|
+
// if its OWN fetch raced a WS event or a session flip — parallel
|
|
1270
|
+
// fan-out doesn't weaken either guard.
|
|
1271
|
+
await Promise.all(
|
|
1272
|
+
names.map(async (entity) => {
|
|
1273
|
+
// Capture cursor + resolved session BEFORE the fetch so we can
|
|
1274
|
+
// detect drift mid-reconcile. Two distinct races:
|
|
1275
|
+
//
|
|
1276
|
+
// 1. Cursor moves: a WS event for this (or another) entity
|
|
1277
|
+
// landed while the page-paginated fetch was in flight. Our
|
|
1278
|
+
// snapshot is stale; applying it would clobber the fresher
|
|
1279
|
+
// WS-delivered row.
|
|
1280
|
+
//
|
|
1281
|
+
// 2. Session flips: the resolved tenant/user changed while
|
|
1282
|
+
// the fetch was in flight (e.g., the app called
|
|
1283
|
+
// /api/auth/select-org just after we issued the fetch).
|
|
1284
|
+
// The server filtered the response under the OLD tenant
|
|
1285
|
+
// context, so applying the result would tombstone rows
|
|
1286
|
+
// that ARE visible under the NEW tenant. This is the
|
|
1287
|
+
// "dashboard flashes data away on first load" bug — the
|
|
1288
|
+
// engine starts before the app calls selectOrg, fetches
|
|
1289
|
+
// under tenant=null, returns 0 rows, then the apply pass
|
|
1290
|
+
// nukes every locally-cached row. Skip the apply when
|
|
1291
|
+
// the session signature changed; the next reconcile
|
|
1292
|
+
// (triggered by session-changed envelope) will re-fetch
|
|
1293
|
+
// under the new context.
|
|
1294
|
+
const cursorBeforeFetch = this.cursor.last_seq;
|
|
1295
|
+
const sessionBeforeFetch = this.session.signature();
|
|
1296
|
+
let serverRows: Row[];
|
|
1297
|
+
try {
|
|
1298
|
+
serverRows = await this.fetchEntityRows(entity);
|
|
1299
|
+
} catch (err) {
|
|
1300
|
+
// Network errors are expected (offline, transient 5xx). Skip
|
|
1301
|
+
// this entity; the next reconcile trigger will retry.
|
|
1302
|
+
const status = (err as { status?: number })?.status;
|
|
1303
|
+
if (status === 403 || status === 404) {
|
|
1304
|
+
// Entity is no longer readable (policy revoked) or removed
|
|
1305
|
+
// from the manifest. Drop every local row for it — keeping
|
|
1306
|
+
// them around just leaks invisible state.
|
|
1307
|
+
await this.dropEntity(entity, tombstoneSeq);
|
|
1308
|
+
}
|
|
1309
|
+
return;
|
|
1246
1310
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
}
|
|
1311
|
+
if (this.cursor.last_seq !== cursorBeforeFetch) {
|
|
1312
|
+
// Cursor moved during fetch — at least one WS event for this
|
|
1313
|
+
// (or another) entity landed and might have a fresher value
|
|
1314
|
+
// for a row our snapshot just captured. Bail out for this
|
|
1315
|
+
// entity; reconcile() is triggered again on visibility-change
|
|
1316
|
+
// and reconnect, and the WS event already carried the latest
|
|
1317
|
+
// state for the affected row.
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
if (this.session.signature() !== sessionBeforeFetch) {
|
|
1321
|
+
// Session changed (token flipped, tenant switched, user
|
|
1322
|
+
// signed out → in, etc.). The rows we fetched reflect the
|
|
1323
|
+
// OLD session's policy view; applying them now would
|
|
1324
|
+
// tombstone rows visible under the NEW session. Bail and let
|
|
1325
|
+
// the session-changed envelope drive the next reconcile.
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
await this.applyEntityReconcile(entity, serverRows, tombstoneSeq);
|
|
1329
|
+
}),
|
|
1330
|
+
);
|
|
1268
1331
|
}
|
|
1269
1332
|
|
|
1270
1333
|
/** Fetch every row for an entity. Uses cursor pagination so big tables
|
|
@@ -1375,17 +1438,36 @@ export class SyncEngine {
|
|
|
1375
1438
|
// broadcasts the result, which `handleMultiTabMessage` routes
|
|
1376
1439
|
// into the resolver.
|
|
1377
1440
|
if (!this.isMultiTabLeader) return;
|
|
1378
|
-
|
|
1441
|
+
const next = await this.fetchSessionBootstrap();
|
|
1442
|
+
if (next === null) return;
|
|
1443
|
+
await this.applySessionTransition(next, /* broadcast */ true);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* Pure HTTP fetch of /api/auth/me → ResolvedSession. Unlike
|
|
1448
|
+
* `refreshResolvedSession`, this does NOT gate on `isMultiTabLeader`
|
|
1449
|
+
* — bootstrap callers in `start()` fire this in PARALLEL with the
|
|
1450
|
+
* multi-tab election to overlap two independent latency windows
|
|
1451
|
+
* (election ~250ms || auth/me ~60ms). At that point no other tabs'
|
|
1452
|
+
* messages have been observed yet, so there's no broadcast-policy
|
|
1453
|
+
* violation; the caller is responsible for discarding the result
|
|
1454
|
+
* if it lost the election.
|
|
1455
|
+
*
|
|
1456
|
+
* Returns null on HTTP error / network failure / parse error — the
|
|
1457
|
+
* caller's next pull cycle (or the WS `session-changed` envelope)
|
|
1458
|
+
* will retry. Errors must not abort bootstrap.
|
|
1459
|
+
*/
|
|
1460
|
+
private async fetchSessionBootstrap(): Promise<ResolvedSession | null> {
|
|
1379
1461
|
try {
|
|
1380
1462
|
const res = await this.rawFetch("/api/auth/me");
|
|
1381
|
-
if (!res.ok) return;
|
|
1463
|
+
if (!res.ok) return null;
|
|
1382
1464
|
const raw = (await res.json()) as {
|
|
1383
1465
|
user_id?: string | null;
|
|
1384
1466
|
tenant_id?: string | null;
|
|
1385
1467
|
is_admin?: boolean;
|
|
1386
1468
|
roles?: string[];
|
|
1387
1469
|
};
|
|
1388
|
-
|
|
1470
|
+
return {
|
|
1389
1471
|
userId: raw.user_id ?? null,
|
|
1390
1472
|
tenantId: raw.tenant_id ?? null,
|
|
1391
1473
|
isAdmin: raw.is_admin ?? false,
|
|
@@ -1394,9 +1476,8 @@ export class SyncEngine {
|
|
|
1394
1476
|
} catch {
|
|
1395
1477
|
// Swallow — /api/auth/me errors are transient and the next pull
|
|
1396
1478
|
// will retry. Don't take down the sync loop for this.
|
|
1397
|
-
return;
|
|
1479
|
+
return null;
|
|
1398
1480
|
}
|
|
1399
|
-
await this.applySessionTransition(next, /* broadcast */ true);
|
|
1400
1481
|
}
|
|
1401
1482
|
|
|
1402
1483
|
/**
|
|
@@ -2057,7 +2138,21 @@ export class SyncEngine {
|
|
|
2057
2138
|
// opening. Reconcile fires after the pull since pull is the
|
|
2058
2139
|
// cheap incremental path; reconcile is the server-truth
|
|
2059
2140
|
// backstop for anything pull couldn't replay.
|
|
2060
|
-
|
|
2141
|
+
//
|
|
2142
|
+
// Cold-load fast path: if pull just hydrated a full snapshot
|
|
2143
|
+
// from cursor=0, the snapshot already returned every row
|
|
2144
|
+
// visible under current policy. The reconcile pass that would
|
|
2145
|
+
// normally follow is pure waste — same rows, second time,
|
|
2146
|
+
// ~60ms × N entities. Skip it once; visibility-change and
|
|
2147
|
+
// reconnect-after-disconnect paths invoke reconcile() directly
|
|
2148
|
+
// (not gated by this flag) so the safety net still triggers.
|
|
2149
|
+
void this.pull().then(() => {
|
|
2150
|
+
if (this.lastPullStartedFromZero) {
|
|
2151
|
+
this.lastPullStartedFromZero = false;
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
return this.reconcile();
|
|
2155
|
+
});
|
|
2061
2156
|
},
|
|
2062
2157
|
onDisconnected: () => {
|
|
2063
2158
|
/* Engine has no work on disconnect — the transport's own
|