@pylonsync/sync 0.3.212 → 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 CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.212",
6
+ "version": "0.3.213",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
@@ -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
- await this.initMultiTab();
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
- await this.refreshResolvedSession();
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
- await this.persistence.saveCursor(this.cursor);
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
- for (const entity of names) {
1211
- // Capture cursor + resolved session BEFORE the fetch so we can
1212
- // detect drift mid-reconcile. Two distinct races:
1213
- //
1214
- // 1. Cursor moves: a WS event for this (or another) entity
1215
- // landed while the page-paginated fetch was in flight. Our
1216
- // snapshot is stale; applying it would clobber the fresher
1217
- // WS-delivered row.
1218
- //
1219
- // 2. Session flips: the resolved tenant/user changed while
1220
- // the fetch was in flight (e.g., the app called
1221
- // /api/auth/select-org just after we issued the fetch).
1222
- // The server filtered the response under the OLD tenant
1223
- // context, so applying the result would tombstone rows
1224
- // that ARE visible under the NEW tenant. This is the
1225
- // "dashboard flashes data away on first load" bug — the
1226
- // engine starts before the app calls selectOrg, fetches
1227
- // under tenant=null, returns 0 rows, then the apply pass
1228
- // nukes every locally-cached row. Skip the apply when
1229
- // the session signature changed; the next reconcile
1230
- // (triggered by session-changed envelope) will re-fetch
1231
- // under the new context.
1232
- const cursorBeforeFetch = this.cursor.last_seq;
1233
- const sessionBeforeFetch = this.session.signature();
1234
- let serverRows: Row[];
1235
- try {
1236
- serverRows = await this.fetchEntityRows(entity);
1237
- } catch (err) {
1238
- // Network errors are expected (offline, transient 5xx). Skip
1239
- // this entity; the next reconcile trigger will retry.
1240
- const status = (err as { status?: number })?.status;
1241
- if (status === 403 || status === 404) {
1242
- // Entity is no longer readable (policy revoked) or removed
1243
- // from the manifest. Drop every local row for it — keeping
1244
- // them around just leaks invisible state.
1245
- await this.dropEntity(entity, tombstoneSeq);
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
- continue;
1248
- }
1249
- if (this.cursor.last_seq !== cursorBeforeFetch) {
1250
- // Cursor moved during fetch at least one WS event for this
1251
- // (or another) entity landed and might have a fresher value
1252
- // for a row our snapshot just captured. Bail out for this
1253
- // entity; reconcile() is triggered again on visibility-change
1254
- // and reconnect, and the WS event already carried the latest
1255
- // state for the affected row.
1256
- continue;
1257
- }
1258
- if (this.session.signature() !== sessionBeforeFetch) {
1259
- // Session changed (token flipped, tenant switched, user
1260
- // signed out in, etc.). The rows we fetched reflect the
1261
- // OLD session's policy view; applying them now would
1262
- // tombstone rows visible under the NEW session. Bail and let
1263
- // the session-changed envelope drive the next reconcile.
1264
- continue;
1265
- }
1266
- await this.applyEntityReconcile(entity, serverRows, tombstoneSeq);
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
- let next: ResolvedSession;
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
- next = {
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
- void this.pull().then(() => this.reconcile());
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