@pylonsync/sync 0.3.202 → 0.3.203

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/src/index.ts CHANGED
@@ -14,7 +14,18 @@ import {
14
14
  type TransportConfig,
15
15
  } from "./transport";
16
16
  import { LocalStore } from "./local-store";
17
- import { MutationQueue } from "./mutation-queue";
17
+ import { MutationQueue, type PendingMutation } from "./mutation-queue";
18
+ import { MultiTabOrchestrator } from "./multi-tab-orchestrator";
19
+ import { OpQueue } from "./op-queue";
20
+ import { ServerSubscriptions } from "./server-subscriptions";
21
+ import { SessionResolver } from "./session-resolver";
22
+ import { SubscriptionCoordinator } from "./subscription-coordinator";
23
+ import {
24
+ createTransport,
25
+ type Transport,
26
+ type TransportHost,
27
+ type TransportKind,
28
+ } from "./transports";
18
29
  import { generateClientId, generateId } from "./ids";
19
30
  export { IndexedDBPersistence, persistChange } from "./persistence";
20
31
  export {
@@ -126,6 +137,15 @@ export interface SyncEngineConfig {
126
137
  * edge proxy to forward to the dual-thread listener instead.
127
138
  */
128
139
  pingIntervalMs?: number;
140
+ /**
141
+ * Multi-tab coordination via BroadcastChannel. When multiple tabs of
142
+ * the same origin run the engine, one is elected leader and owns
143
+ * the WebSocket, pull/push/reconcile, and IndexedDB writes;
144
+ * follower tabs mirror state via cross-tab broadcasts. Default
145
+ * `true` in browsers. Set `false` to force every tab to behave as
146
+ * its own leader (the pre-multi-tab semantics).
147
+ */
148
+ multiTab?: boolean;
129
149
  }
130
150
 
131
151
  /**
@@ -156,13 +176,13 @@ export class SyncEngine {
156
176
  private config: SyncEngineConfig;
157
177
  private cursor: SyncCursor = { last_seq: 0 };
158
178
  private running = false;
159
- private ws: WebSocket | null = null;
160
- private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
179
+ /** Real-time transport owns its own socket / timers / backoff.
180
+ * The engine just calls start/stop/send and consumes inbound events
181
+ * via the TransportHost callbacks (set up below in `transportHost`).
182
+ * Constructed in start() because followers don't open a transport
183
+ * at all and SSR-only consumers never reach start(). */
184
+ private transport: Transport | null = null;
161
185
  private _connectionStatus: SyncConnectionStatus = "offline";
162
- /** Monotonic attempt counter for exponential backoff. Reset to 0 on a
163
- * successful connection so the next reconnect starts fresh rather than
164
- * inheriting the previous storm's cooldown. */
165
- private reconnectAttempts = 0;
166
186
  private persistence: import("./persistence").IndexedDBPersistence | null = null;
167
187
 
168
188
  /**
@@ -203,38 +223,34 @@ export class SyncEngine {
203
223
  * changes — so the cursor from the previous identity is meaningless.
204
224
  * Compared on every pull; a mismatch triggers an automatic resync.
205
225
  *
206
- * Uses `undefined` as the "never observed" sentinel so we can distinguish
207
- * "first pull ever" from "explicitly anonymous". A first pull doesn't
208
- * reset (nothing to reset), but every later transition including
209
- * null→token does.
210
- */
211
- private lastSeenToken: string | null | undefined = undefined;
212
-
213
- /**
214
- * Latest server-resolved auth/session state. Refreshed on every pull()
215
- * by fetching /api/auth/me in parallel. Exposed to consumers via
216
- * `resolvedSession` so React hooks can subscribe via the store.
226
+ * Owns the resolved session, the last-seen token, the last-seen
227
+ * tenant, and the null→X / X→Y / token-flip verdicts that used to
228
+ * be inlined across pull / refresh / reconcile. The engine acts on
229
+ * the verdicts (reset, pull, notify); the resolver decides nothing
230
+ * on its own. See session-resolver.ts.
217
231
  *
218
- * Subscribers re-render when this updates we reuse the store's
219
- * notifier rather than introduce a second pub/sub so every change the
220
- * app cares about goes through one channel.
232
+ * Exposed (read-only) so tests and plugins can inspect or simulate
233
+ * identity transitions without re-implementing the comparison.
221
234
  */
222
- private _resolvedSession: ResolvedSession = {
223
- userId: null,
224
- tenantId: null,
225
- isAdmin: false,
226
- roles: [],
227
- };
228
- private lastSeenTenant: string | null | undefined = undefined;
235
+ readonly session: SessionResolver = new SessionResolver();
229
236
 
230
237
  /**
231
- * Timer for the "stable connection" check. On `onopen` we start a 5s
232
- * timer; if the socket stays up that long we reset reconnectAttempts.
233
- * If it closes first, the timer gets cleared and the backoff grows so
234
- * the client can't hammer the server on auth failures.
235
- */
236
- private wsStableTimer: ReturnType<typeof setTimeout> | null = null;
237
- private pingTimer: ReturnType<typeof setInterval> | null = null;
238
+ * Multi-tab orchestrator owns the cross-tab protocol: broker
239
+ * lifecycle, election, inbound message dispatch, outbound broadcasts.
240
+ * Engine receives inbound events via hooks (see `multiTabHooks()`).
241
+ *
242
+ * Constructed lazily in `start()` because SSR-only consumers that
243
+ * never reach start() shouldn't pay for the broker. */
244
+ private orchestrator: MultiTabOrchestrator | null = null;
245
+ /** Mirror of `orchestrator.isLeader()` kept on the engine for the
246
+ * many `if (this.isMultiTabLeader)` gates throughout the codebase.
247
+ * Updated via the orchestrator's onInitialLeader / onLatePromote /
248
+ * onDemote hooks. Defaults to false so a tab joining an existing
249
+ * election stays a passive follower until the orchestrator
250
+ * explicitly promotes us — a `true` default would let a late
251
+ * joiner whose orchestrator never fires onPromote (because it was
252
+ * never leader) silently run as a leader. */
253
+ private isMultiTabLeader = false;
238
254
 
239
255
  /**
240
256
  * Serialized apply queue. Every change-event apply — from WS onmessage,
@@ -247,6 +263,27 @@ export class SyncEngine {
247
263
  */
248
264
  private applyQueue: Promise<void> = Promise.resolve();
249
265
 
266
+ /**
267
+ * Serialized channel for outbound network ops (pull, push, reconcile,
268
+ * refresh, resetReplica). Replaces the per-op `inFlightX` mutexes +
269
+ * the fire-and-forget `void refreshResolvedSession()` calls that used
270
+ * to race against in-flight pulls and reconciles. Apply stays
271
+ * separate (see `applyQueue` above) so WS events don't block on a
272
+ * pull's HTTP round-trip.
273
+ */
274
+ private opQueue: OpQueue = new OpQueue();
275
+
276
+ /**
277
+ * Serialized chain for session-transition application. Multiple
278
+ * concurrent triggers (`refreshResolvedSession` from app code +
279
+ * a `session-changed` envelope landing over WS + a multi-tab
280
+ * `session` broadcast) all enqueue here. Without it, two
281
+ * inspect-then-commit pairs interleave on the microtask queue
282
+ * and the older session can commit AFTER the newer, leaving the
283
+ * engine pinned to a stale tenant.
284
+ */
285
+ private sessionChain: Promise<void> = Promise.resolve();
286
+
250
287
  /**
251
288
  * Registered consumers for binary WebSocket frames. SyncEngine itself
252
289
  * doesn't decode binary — it just owns the WS connection and routes
@@ -262,34 +299,23 @@ export class SyncEngine {
262
299
  private binaryHandlers: Set<(bytes: Uint8Array) => void> = new Set();
263
300
 
264
301
  /**
265
- * Active CRDT subscriptions, keyed `${entity}\x00${rowId}`. Tracked
266
- * here so a WS reconnect can re-send the same subscriptions to the
267
- * fresh socket the server clears its per-client subscription state
268
- * on disconnect (in `WsHub::handle_ws_connection`'s Close path), so
269
- * without re-sending the binary frames would stop arriving on the
270
- * new connection.
271
- *
272
- * Refcount-aware via `crdtSubscribers` so two `useLoroDoc` callers on
273
- * the same row don't unsubscribe each other when one unmounts.
302
+ * Server-side ephemeral subscriptions (CRDT row subs, reactive query
303
+ * subs, future kinds). Owns the WS replay bookkeeping each kind
304
+ * registers the message that re-creates its server-side state, and
305
+ * `ws.onopen` replays the bundle on reconnect. Kind-specific concerns
306
+ * (CRDT refcount, reactive handler routing) stay below as their own
307
+ * maps. See server-subscriptions.ts.
274
308
  */
275
- private crdtSubscriptions: Set<string> = new Set();
276
- private crdtSubscribers: Map<string, number> = new Map();
309
+ private serverSubs!: ServerSubscriptions;
277
310
 
278
- /**
279
- * Reactive query subscriptions registered via `subscribeReactive`.
280
- * Two maps:
281
- * - `reactiveSpecs`: sub_id {fn_name, args} for re-registration
282
- * on WS reconnect. Server-side state evaporates on disconnect.
283
- * - `reactiveHandlers`: sub_id handler that receives result + error
284
- * pushes. The React hook owns these handlers and unsubscribes on
285
- * unmount.
286
- *
287
- * Both maps are keyed by the same client-minted `sub_id` so they
288
- * stay in sync. Cleared together by `unsubscribeReactive`.
289
- */
290
- private reactiveSpecs: Map<string, ReactiveSpec> = new Map();
291
- private reactiveHandlers: Map<string, (msg: ReactiveMessage) => void> =
292
- new Map();
311
+ /** Coordinator for every "this tab wants live updates" subscription —
312
+ * CRDT row subs + reactive query subs, leader bookkeeping + follower
313
+ * forwarding. The engine delegates `subscribeCrdt` / `unsubscribeCrdt`
314
+ * / `subscribeReactive` / `unsubscribeReactive` to it, and routes
315
+ * inbound multi-tab `sub-register` / `sub-unregister` / `reactive-msg`
316
+ * envelopes through it. Constructed lazily in start() because
317
+ * serverSubs isn't built until then. */
318
+ private subscriptions!: SubscriptionCoordinator;
293
319
 
294
320
  /**
295
321
  * Listeners notified when the server signals a per-subscriber row
@@ -319,7 +345,7 @@ export class SyncEngine {
319
345
 
320
346
  /** Read the cached resolved session. Null user = anonymous. */
321
347
  resolvedSession(): ResolvedSession {
322
- return this._resolvedSession;
348
+ return this.session.resolved();
323
349
  }
324
350
 
325
351
  /**
@@ -352,6 +378,22 @@ export class SyncEngine {
352
378
  this.mutations = new MutationQueue();
353
379
  this.storage = config.storage ?? defaultStorage();
354
380
  this.clientId = generateClientId(this.storage);
381
+ // ServerSubscriptions defers sending until the WS is open (the
382
+ // sendWs helper short-circuits otherwise) so registering before
383
+ // start() is safe — the spec gets replayed on `ws.onopen`.
384
+ this.serverSubs = new ServerSubscriptions((msg) => this.sendWs(msg));
385
+ this.subscriptions = new SubscriptionCoordinator(this.serverSubs, {
386
+ isLeader: () => this.isMultiTabLeader,
387
+ broadcastToTabs: (payload) => this.broadcastToTabs(payload),
388
+ });
389
+ // When multi-tab coordination is explicitly disabled, this engine
390
+ // is always its own sole leader — even before start(). Tests that
391
+ // construct an engine and call reconcile()/pull() directly without
392
+ // start() rely on this. The dynamic election paths (broker-driven
393
+ // promote/demote) only matter when multiTab is enabled.
394
+ if (this.config.multiTab === false) {
395
+ this.isMultiTabLeader = true;
396
+ }
355
397
  }
356
398
 
357
399
  /**
@@ -465,10 +507,33 @@ export class SyncEngine {
465
507
  // into a permanent "Loading…" state.
466
508
  this._hydrated = true;
467
509
 
510
+ // Multi-tab coordination: elect a leader before deciding whether
511
+ // to open the WS / pull / poll. Followers stay passive and mirror
512
+ // applied changes broadcast by the leader. The election settles
513
+ // in ~250ms; if the broker is unavailable (no BroadcastChannel)
514
+ // every tab is implicitly its own leader.
515
+ await this.initMultiTab();
516
+
517
+ if (!this.isMultiTabLeader) {
518
+ // Follower path: rely on the leader's broadcasts for session +
519
+ // applied changes. Nothing else to do here — the broker is
520
+ // wired to forward inbound messages into the engine.
521
+ return;
522
+ }
523
+
524
+ // Leader path. If any subscribeCrdt / subscribeReactive call came
525
+ // in before start() (or before initMultiTab settled), it took the
526
+ // default-follower branch and tried to broadcast to a not-yet-
527
+ // running broker. Now that we know we ARE the leader, populate
528
+ // serverSubs from local interest so the WS subscribe frames go
529
+ // out on the next connect. Idempotent w.r.t. subscribe calls that
530
+ // happen later through the normal leader branch.
531
+ this.seedServerSubsFromLocalInterest();
532
+
468
533
  // Seed the server-resolved session before the first pull so
469
- // `useSession` subscribers see the right tenant from frame one, and
470
- // `lastSeenTenant` is populated before any subsequent flip can race
471
- // with it.
534
+ // `useSession` subscribers see the right tenant from frame one,
535
+ // and the resolver's lastSeenTenant is populated before any
536
+ // subsequent flip can race with it.
472
537
  await this.refreshResolvedSession();
473
538
 
474
539
  // Pull from server, then connect real-time transport.
@@ -505,16 +570,179 @@ export class SyncEngine {
505
570
  // webhook on a sibling Fly machine" / "missed WS event" gap.
506
571
  this.attachVisibilityListener();
507
572
 
508
- const transport = this.config.transport ?? "websocket";
509
- if (transport === "websocket") {
510
- this.connectWs();
511
- } else if (transport === "sse") {
512
- this.connectSse();
513
- } else if (transport === "poll") {
514
- this.startPolling();
573
+ this.transport = createTransport(this.transportKind(), this.transportHost());
574
+ this.transport.start();
575
+ }
576
+
577
+ /**
578
+ * Multi-tab election. Brings up the orchestrator and runs the
579
+ * initial election. Sets `isMultiTabLeader` via the orchestrator's
580
+ * hooks (onInitialLeader / onLatePromote / onDemote).
581
+ *
582
+ * On platforms without BroadcastChannel (Node, jsdom, very old
583
+ * Safari) the orchestrator declares self leader and returns
584
+ * immediately.
585
+ */
586
+ private async initMultiTab(): Promise<void> {
587
+ this.orchestrator = new MultiTabOrchestrator(
588
+ {
589
+ enabled: this.config.multiTab !== false,
590
+ appName: this.config.appName,
591
+ },
592
+ this.subscriptions,
593
+ this.multiTabHooks(),
594
+ );
595
+ await this.orchestrator.init();
596
+ }
597
+
598
+ /** Hooks the orchestrator calls back into for inbound multi-tab
599
+ * events that need engine state. Cases that only touch
600
+ * subscriptions are dispatched directly by the orchestrator. */
601
+ private multiTabHooks() {
602
+ return {
603
+ onInitialLeader: () => {
604
+ this.isMultiTabLeader = true;
605
+ },
606
+ onLatePromote: () => {
607
+ this.isMultiTabLeader = true;
608
+ void this.onMultiTabPromoted();
609
+ },
610
+ onDemote: () => {
611
+ this.isMultiTabLeader = false;
612
+ this.onMultiTabDemoted();
613
+ },
614
+ onAppliedReceived: (
615
+ changes: ChangeEvent[],
616
+ targetCursor: SyncCursor | undefined,
617
+ ) => {
618
+ // `fromBroadcast: true` suppresses re-broadcast in case a
619
+ // promotion lands between this enqueue and the apply.
620
+ if (changes.length > 0) {
621
+ void this.enqueueApply(changes, targetCursor, { fromBroadcast: true });
622
+ } else if (targetCursor && targetCursor.last_seq > this.cursor.last_seq) {
623
+ this.cursor = targetCursor;
624
+ }
625
+ },
626
+ onReconciledReceived: (
627
+ entity: string,
628
+ upserts: Row[],
629
+ removalIds: string[],
630
+ tombstoneSeq: number,
631
+ ) => {
632
+ void this.enqueueReconcile(entity, upserts, removalIds, tombstoneSeq, {
633
+ fromBroadcast: true,
634
+ });
635
+ },
636
+ onResetReceived: () => {
637
+ void this.resetReplicaInner();
638
+ },
639
+ onSessionReceived: (resolved: ResolvedSession) => {
640
+ // Funnel through the shared session chain so concurrent triggers
641
+ // (broadcast + local notifySessionChanged) commit in arrival
642
+ // order. Without that the older tenant could win and pin the
643
+ // engine to a stale session.
644
+ void this.applySessionTransition(resolved, /* broadcast */ false);
645
+ },
646
+ onMutationsForwarded: (ops: PendingMutation[]) => {
647
+ for (const op of ops) {
648
+ this.mutations.add(op.change);
649
+ }
650
+ void this.push();
651
+ },
652
+ onMutationsAcked: (opIds: string[]) => {
653
+ for (const id of opIds) this.mutations.markApplied(id);
654
+ this.mutations.clear();
655
+ },
656
+ onMutationsFailed: (ops: { opId: string; error: string }[]) => {
657
+ for (const op of ops) {
658
+ this.mutations.markFailed(op.opId, op.error);
659
+ }
660
+ },
661
+ onBinaryReceived: (bytes: Uint8Array) => {
662
+ for (const h of this.binaryHandlers) {
663
+ try {
664
+ h(bytes);
665
+ } catch (err) {
666
+ console.warn("[sync] binary handler threw:", err);
667
+ }
668
+ }
669
+ },
670
+ onPeerLeft: (_tabId: string) => {
671
+ // Orchestrator already scrubbed subscription state for the
672
+ // departed tab. The engine has no additional cleanup, but the
673
+ // hook exists for future needs (e.g., per-peer presence).
674
+ },
675
+ };
676
+ }
677
+
678
+ /** Seed serverSubs with every subscription this tab currently wants.
679
+ * Called from both the initial-leader path (subscribes that
680
+ * happened before start() took the follower branch and broadcast
681
+ * to a not-yet-running broker, so the registers were lost) and the
682
+ * late-promotion path (the previous leader owned the subs and we
683
+ * need to claim them). */
684
+ private seedServerSubsFromLocalInterest(): void {
685
+ this.subscriptions.seedFromLocalInterest();
686
+ }
687
+
688
+ /** Late promotion: the previous leader dropped while we were
689
+ * running as a follower. Take over network ops now AND drain any
690
+ * pending mutations through push() — a mutation we'd forwarded to
691
+ * the previous leader may have died with it before the server
692
+ * acked, and we need to ship it ourselves. op_id makes the
693
+ * server-side retry idempotent: a previously-applied op returns
694
+ * `replayed` and we just mark it applied locally. */
695
+ private async onMultiTabPromoted(): Promise<void> {
696
+ if (!this.running) return;
697
+ try {
698
+ // Re-register every locally-wanted server subscription with our
699
+ // own serverSubs. Previous leader had these; now we own the WS,
700
+ // so we need to send the subscribe frames on the next connect.
701
+ this.seedServerSubsFromLocalInterest();
702
+ // Ask the rest of the tabs to re-forward their subs so we can
703
+ // serve them from our new WS. They'll respond via `sub-register`.
704
+ this.broadcastToTabs({ type: "request-sub-replay" });
705
+
706
+ await this.refreshResolvedSession();
707
+ await this.pull();
708
+ // Drain the queue — replay every pending op that was either
709
+ // forwarded to a now-dead leader or queued locally while we
710
+ // were a follower. The server's op_id dedupe absorbs duplicates.
711
+ if (this.mutations.pending().length > 0) {
712
+ void this.push();
713
+ }
714
+ this.attachVisibilityListener();
715
+ // Late promotion: build a transport (if one doesn't exist yet — a
716
+ // tab might have spent its whole life as follower with no
717
+ // transport at all) and start it.
718
+ if (!this.transport) {
719
+ this.transport = createTransport(this.transportKind(), this.transportHost());
720
+ }
721
+ this.transport.start();
722
+ } catch {
723
+ /* best-effort — next reconnect cycle catches up */
724
+ }
725
+ }
726
+
727
+ /** Demotion: another tab took over as leader. Tear down our
728
+ * transport (WS socket, ping timer, reconnect timer, poll timer —
729
+ * whichever applies) and stop driving network ops; the new leader
730
+ * will broadcast applied changes that our followers-mirror path
731
+ * picks up. */
732
+ private onMultiTabDemoted(): void {
733
+ if (this.transport) {
734
+ this.transport.stop();
735
+ this.transport = null;
515
736
  }
516
737
  }
517
738
 
739
+ /** Broadcast a payload to other tabs in this origin. Delegates to
740
+ * the orchestrator; no-op when the orchestrator isn't running
741
+ * (SSR-only consumers that never reach `start()`). */
742
+ private broadcastToTabs(payload: unknown): void {
743
+ this.orchestrator?.broadcastRaw(payload);
744
+ }
745
+
518
746
  private visibilityHandler: (() => void) | null = null;
519
747
  private attachVisibilityListener(): void {
520
748
  if (this.config.reconcileOnVisibility === false) return;
@@ -525,16 +753,16 @@ export class SyncEngine {
525
753
  if (!this.running) return;
526
754
  // Reconcile fires only on tab-becomes-visible; the debounce in
527
755
  // reconcile() collapses bursts from rapid background/foreground
528
- // flips. Pull runs alongside so cursor catches up to anything
529
- // emitted while the tab was hidden.
756
+ // flips. Pull is enqueued FIRST so the opQueue runs it before
757
+ // reconcile that way reconcile sees the fresh cursor and
758
+ // doesn't duplicate the cursor-catch-up work pull is already
759
+ // doing. They're serial via the queue, not concurrent.
530
760
  void this.pull();
531
761
  void this.reconcile();
532
762
  };
533
763
  document.addEventListener("visibilitychange", this.visibilityHandler);
534
764
  }
535
765
 
536
- private pollTimer: ReturnType<typeof setInterval> | null = null;
537
-
538
766
  /**
539
767
  * Serialize a batch of change applies behind any in-flight applies, and
540
768
  * advance the cursor monotonically when the batch lands. Both the WS
@@ -550,40 +778,21 @@ export class SyncEngine {
550
778
  */
551
779
  private enqueueApply(
552
780
  changes: ChangeEvent[],
553
- options:
554
- | SyncCursor
555
- | { targetCursor?: SyncCursor; skipSeqGuard?: boolean; advanceCursor?: boolean } = {},
781
+ targetCursor?: SyncCursor,
782
+ opts: { fromBroadcast?: boolean } = {},
556
783
  ): Promise<void> {
557
- // Back-compat: callers that pass a SyncCursor positional arg get
558
- // the same semantics as before. New callers can pass an options
559
- // object — `skipSeqGuard` lets reconcile bypass the seq filter
560
- // (its synthetic events fabricate seqs that don't fit the natural
561
- // monotonic order), and `advanceCursor: false` keeps the cursor
562
- // pinned where it was so reconcile doesn't fake-advance past the
563
- // server's real position.
564
- const opts: { targetCursor?: SyncCursor; skipSeqGuard?: boolean; advanceCursor?: boolean } =
565
- options && typeof options === "object" && "last_seq" in options
566
- ? { targetCursor: options as SyncCursor }
567
- : (options as {
568
- targetCursor?: SyncCursor;
569
- skipSeqGuard?: boolean;
570
- advanceCursor?: boolean;
571
- });
572
- const skipSeqGuard = opts.skipSeqGuard ?? false;
573
- const advanceCursor = opts.advanceCursor ?? true;
574
- const targetCursor = opts.targetCursor;
575
-
576
784
  const prev = this.applyQueue;
577
785
  const next = prev.then(async () => {
578
- const filtered = skipSeqGuard
579
- ? changes
580
- : changes.filter(
581
- (c) => typeof c.seq === "number" && c.seq > this.cursor.last_seq,
582
- );
786
+ // Per-event monotonic filter: re-applies of an already-seen seq
787
+ // are skipped before touching the store. Without that, a
788
+ // retransmit (WS + pull window overlap) would have us run
789
+ // applyChange twice against the local store.
790
+ const filtered = changes.filter(
791
+ (c) => typeof c.seq === "number" && c.seq > this.cursor.last_seq,
792
+ );
583
793
  if (filtered.length > 0) {
584
794
  await this.store.applyChangesAsync(filtered);
585
795
  }
586
- if (!advanceCursor) return;
587
796
  // Pick the cursor target. Explicit `targetCursor` (from pull) wins
588
797
  // — pull's response carries the server's authoritative current_seq
589
798
  // even when no changes landed in this window. Otherwise derive
@@ -599,6 +808,23 @@ export class SyncEngine {
599
808
  await this.persistence.saveCursor(this.cursor);
600
809
  }
601
810
  }
811
+ // Multi-tab: leader fans the batch out so follower replicas
812
+ // converge without their own WS. Skip when we ourselves
813
+ // RECEIVED this batch from another tab — otherwise a tab that
814
+ // was promoted between receiving and applying would re-broadcast
815
+ // its own copy, and even though the seq filter dedupes on
816
+ // arrival the round-trip is wasted bandwidth.
817
+ if (
818
+ this.isMultiTabLeader &&
819
+ !opts.fromBroadcast &&
820
+ filtered.length > 0
821
+ ) {
822
+ this.broadcastToTabs({
823
+ type: "applied",
824
+ changes: filtered,
825
+ targetCursor: candidate ?? undefined,
826
+ });
827
+ }
602
828
  });
603
829
  // Errors stay scoped to this batch — don't poison the chain for
604
830
  // future applies.
@@ -606,369 +832,65 @@ export class SyncEngine {
606
832
  return next;
607
833
  }
608
834
 
609
- private startPolling(): void {
610
- const interval = this.config.pollInterval ?? 1000;
611
- this.pollTimer = setInterval(() => {
612
- this.push().then(() => this.pull());
613
- }, interval);
835
+ /**
836
+ * Reconcile path. Routes through the same applyQueue as WS/pull so
837
+ * a reconcile batch can't interleave with a fresher change event
838
+ * mid-apply — but reconcile carries no real seqs, so the seq filter
839
+ * and cursor advance from `enqueueApply` are deliberately absent.
840
+ * Pending mutations are protected upstream (in applyEntityReconcile)
841
+ * before the batch is ever built.
842
+ */
843
+ private enqueueReconcile(
844
+ entity: string,
845
+ upserts: Row[],
846
+ removalIds: string[],
847
+ tombstoneSeq: number,
848
+ opts: { fromBroadcast?: boolean } = {},
849
+ ): Promise<void> {
850
+ const prev = this.applyQueue;
851
+ const next = prev.then(async () => {
852
+ await this.store.applyReconcileBatch(
853
+ entity,
854
+ upserts,
855
+ removalIds,
856
+ tombstoneSeq,
857
+ );
858
+ // Leader fans the reconcile batch out so each follower can
859
+ // converge without its own fetch. Suppress when we ourselves
860
+ // received this batch via the channel (promotion mid-flight
861
+ // would otherwise echo it).
862
+ if (this.isMultiTabLeader && !opts.fromBroadcast) {
863
+ this.broadcastToTabs({
864
+ type: "reconciled",
865
+ entity,
866
+ upserts,
867
+ removalIds,
868
+ tombstoneSeq,
869
+ });
870
+ }
871
+ });
872
+ this.applyQueue = next.catch(() => {});
873
+ return next;
614
874
  }
615
875
 
616
876
  /** Stop the sync engine. */
617
877
  stop(): void {
618
878
  this.running = false;
619
- if (this.ws) {
620
- this.ws.close();
621
- this.ws = null;
622
- }
623
- if (this.reconnectTimer) {
624
- clearTimeout(this.reconnectTimer);
625
- this.reconnectTimer = null;
626
- }
627
- if (this.pollTimer) {
628
- clearInterval(this.pollTimer);
629
- this.pollTimer = null;
630
- }
631
- if (this.pingTimer) {
632
- clearInterval(this.pingTimer);
633
- this.pingTimer = null;
879
+ if (this.transport) {
880
+ this.transport.stop();
881
+ this.transport = null;
634
882
  }
635
883
  if (this.visibilityHandler && typeof document !== "undefined") {
636
884
  document.removeEventListener("visibilitychange", this.visibilityHandler);
637
885
  this.visibilityHandler = null;
638
886
  }
639
- this.setConnectionStatus("offline");
640
- }
641
-
642
- /** Connect to the WebSocket server for real-time updates. */
643
- private connectWs(): void {
644
- if (!this.running) return;
645
-
646
- const wsUrl = this.config.wsUrl ?? this.deriveWsUrl();
647
- // Browser WebSocket has no header API — the server accepts the token
648
- // as a `bearer.<percent-encoded-token>` subprotocol (RFC 6455 §1.9).
649
- // Native clients can still set Authorization: Bearer via headers.
650
- const token =
651
- this.config.token ??
652
- this.storage.get(this.tokenStorageKey()) ??
653
- undefined;
654
- try {
655
- if (token) {
656
- const proto = `bearer.${encodeURIComponent(token)}`;
657
- this.ws = new WebSocket(wsUrl, proto);
658
- } else {
659
- this.ws = new WebSocket(wsUrl);
660
- }
661
- } catch {
662
- this.scheduleReconnect();
663
- return;
664
- }
665
-
666
- // Backoff reset is delayed — a socket that opens then closes inside
667
- // a few seconds (auth failure, server 1008) would otherwise let the
668
- // reconnect loop fire at ~2/sec forever. Only call the connection
669
- // "stable" after it's stayed up long enough to have been doing work.
670
- this.ws.onopen = () => {
671
- // We only flip to "connected" once the socket actually opens.
672
- // The 5s stable-window timer below decides when to RESET the
673
- // backoff; status flips immediately because UI consumers want
674
- // to clear the "reconnecting" indicator the moment data starts
675
- // flowing again.
676
- this.setConnectionStatus("connected");
677
- if (this.wsStableTimer) clearTimeout(this.wsStableTimer);
678
- this.wsStableTimer = setTimeout(() => {
679
- this.reconnectAttempts = 0;
680
- this.wsStableTimer = null;
681
- }, 5_000);
682
- // Client-side keepalive ping. Default 25s — pure liveness, since
683
- // the dedicated :port+1 server uses a dual-thread design that
684
- // wakes the writer instantly on every broadcast (no mutex
685
- // contention with the reader, no ping-bounded latency).
686
- //
687
- // The HTTP-multiplexed `/api/sync/ws` fallback path is still
688
- // single-threaded (tiny_http's CustomStream hides the TcpStream
689
- // so we can't set a kernel-level read timeout). On that path,
690
- // broadcast latency IS bounded by this interval — apps that
691
- // can't route to the :port+1 listener can pass
692
- // `init({ pingIntervalMs: 200 })` to trade traffic for latency.
693
- const pingIntervalMs = this.config.pingIntervalMs ?? 25_000;
694
- if (this.pingTimer) clearInterval(this.pingTimer);
695
- this.pingTimer = setInterval(() => {
696
- if (this.ws?.readyState !== WebSocket.OPEN) return;
697
- try {
698
- this.ws.send('{"type":"ping"}');
699
- } catch {
700
- // ignore — onclose will trigger reconnect
701
- }
702
- }, pingIntervalMs);
703
- // Re-send any active CRDT subscriptions across the new socket.
704
- // The server purged them on disconnect (`unsubscribe_all`), so
705
- // without this resync a tab that was subscribed before a network
706
- // blip would silently stop receiving binary CRDT frames.
707
- for (const key of this.crdtSubscriptions) {
708
- const [entity, rowId] = key.split("\x00");
709
- this.sendWs({ type: "crdt-subscribe", entity, rowId });
710
- }
711
- // Re-register every reactive subscription on the fresh socket.
712
- // The server's ReactiveRegistry tears down on disconnect (via
713
- // `disconnect_client`) so without this resync the handlers
714
- // would silently stop receiving result pushes.
715
- for (const [sub_id, spec] of this.reactiveSpecs) {
716
- this.sendWs({
717
- type: "reactive-subscribe",
718
- sub_id,
719
- fn_name: spec.fn_name,
720
- args: spec.args,
721
- });
722
- }
723
- // Pull-on-open catches every event broadcast in the gap between
724
- // the prior `pull()` returning and this socket actually opening.
725
- // The WS has no replay-on-connect (it's just a fanout), so events
726
- // emitted to other live clients during that window would otherwise
727
- // be lost forever to this tab. Reconcile fires after the pull
728
- // since pull is the cheap incremental path; reconcile is the
729
- // server-truth backstop for anything pull couldn't replay.
730
- void this.pull().then(() => this.reconcile());
731
- };
732
-
733
- // Bind binaryType BEFORE installing the handler so the first
734
- // server-pushed binary frame (CRDT snapshot or update) decodes
735
- // correctly. Default in browsers is "blob"; we want raw bytes
736
- // synchronously available so the binary-handler closure doesn't
737
- // need to await a Blob.arrayBuffer() round-trip.
738
- this.ws.binaryType = "arraybuffer";
739
-
740
- this.ws.onmessage = (event) => {
741
- // Binary frame: route to whatever consumer registered via
742
- // onBinaryFrame(). Pylon's CRDT broadcast (server-side
743
- // notify_crdt) ships every CRDT-mode write as a binary
744
- // [type|entity|row_id|payload] frame; @pylonsync/loro is the
745
- // intended decoder. SyncEngine itself stays binary-agnostic so
746
- // the next binary use case (file streaming, video chunks…)
747
- // can register without churning this layer.
748
- if (event.data instanceof ArrayBuffer) {
749
- for (const handler of this.binaryHandlers) {
750
- try {
751
- handler(new Uint8Array(event.data));
752
- } catch (err) {
753
- console.warn("[sync] binary handler threw:", err);
754
- }
755
- }
756
- return;
757
- }
758
-
759
- try {
760
- const msg = JSON.parse(event.data as string);
761
-
762
- // Sync change event. Persist BEFORE advancing the cursor so a
763
- // crash can't leave `last_seq` ahead of the replica on disk.
764
- // The shared apply queue serializes WS messages with each other
765
- // AND with concurrent pull() calls, so seq order is preserved
766
- // and the cursor only advances monotonically.
767
- if (msg.seq && msg.entity && msg.kind) {
768
- const change = msg as ChangeEvent;
769
- void this.enqueueApply([change]);
770
- return;
771
- }
772
-
773
- // Presence event.
774
- if (msg.type === "presence") {
775
- this.store.notify();
776
- return;
777
- }
778
-
779
- // Server-driven revocation: a subscriber whose read policy
780
- // was revoked mid-session for a specific row. Drop the row
781
- // from the local replica at the current cursor seq so the
782
- // tombstone supersedes any racing late-arriving WS update
783
- // for the same row, and notify any LoroDoc subscriber
784
- // (registered via `addRowEvictionListener`) so collaborative
785
- // doc handles unmount cleanly.
786
- //
787
- // Distinct from a regular Delete change event because this
788
- // envelope has no global seq — the row's underlying data
789
- // hasn't been deleted, only the recipient's visibility of
790
- // it. Other subscribers (with matching policy) keep their
791
- // row intact.
792
- if (
793
- msg.type === "row-revoked" &&
794
- typeof msg.entity === "string" &&
795
- typeof msg.row_id === "string"
796
- ) {
797
- // Server includes its current high-water seq when known —
798
- // use it as the tombstone seq so an in-flight stale frame
799
- // with `seq <= server_seq` is filtered locally. A
800
- // legitimate re-grant at a higher seq still lands.
801
- const revokeSeq =
802
- typeof msg.seq === "number" && msg.seq > 0
803
- ? msg.seq
804
- : this.cursor.last_seq;
805
- this.handleRowRevocation(msg.entity, msg.row_id, revokeSeq);
806
- return;
807
- }
808
-
809
- // Session mutated server-side. Fires for select-org / clear-org
810
- // / session revoke — every tab connected as this user gets the
811
- // envelope (cross-machine too via the cluster bus). Trigger
812
- // a fresh /api/auth/me read which updates the cached session
813
- // AND, on tenant flip, resets the replica so stale rows from
814
- // the previous tenant disappear. App code calling
815
- // /api/auth/select-org via raw fetch no longer needs the
816
- // manual `notifySessionChanged()` step.
817
- if (msg.type === "session-changed") {
818
- void this.refreshResolvedSession();
819
- return;
820
- }
821
-
822
- // Reactive query push: the server-side ReactiveRegistry re-ran
823
- // a subscribed handler and the result hash changed. Route to
824
- // the handler registered by `subscribeReactive` so the React
825
- // hook re-renders.
826
- if (msg.type === "reactive-result" && typeof msg.sub_id === "string") {
827
- const handler = this.reactiveHandlers.get(msg.sub_id);
828
- if (handler) {
829
- handler({ kind: "result", result: msg.result });
830
- }
831
- return;
832
- }
833
- if (msg.type === "reactive-error" && typeof msg.sub_id === "string") {
834
- const handler = this.reactiveHandlers.get(msg.sub_id);
835
- if (handler) {
836
- handler({
837
- kind: "error",
838
- code: typeof msg.code === "string" ? msg.code : "REACTIVE_ERROR",
839
- message: typeof msg.message === "string" ? msg.message : "",
840
- });
841
- }
842
- return;
843
- }
844
- } catch {
845
- // Ignore malformed messages.
846
- }
847
- };
848
-
849
- this.ws.onclose = () => {
850
- this.ws = null;
851
- // Socket closed before the stable-window timer fired — treat this
852
- // as an unstable connection and DO NOT reset reconnectAttempts.
853
- // The growing backoff protects the server from a tight loop.
854
- if (this.wsStableTimer) {
855
- clearTimeout(this.wsStableTimer);
856
- this.wsStableTimer = null;
857
- }
858
- if (this.pingTimer) {
859
- clearInterval(this.pingTimer);
860
- this.pingTimer = null;
861
- }
862
- // Surface the disconnect to UI consumers immediately. If
863
- // `running` flipped to false (engine stopped), `stop()` already
864
- // set "offline" — don't override that.
865
- if (this.running) {
866
- this.setConnectionStatus("reconnecting");
867
- }
868
- this.scheduleReconnect();
869
- };
870
-
871
- this.ws.onerror = () => {
872
- // onclose will fire after this.
873
- };
874
- }
875
-
876
- private scheduleReconnect(): void {
877
- if (!this.running) return;
878
- this.reconnectAttempts += 1;
879
- const delay = this.computeBackoff();
880
- this.reconnectTimer = setTimeout(() => {
881
- this.reconnectTimer = null;
882
- // Pull any missed changes, then reconnect.
883
- this.pull().then(() => this.connectWs());
884
- }, delay);
885
- }
886
-
887
- /**
888
- * Exponential backoff with full jitter for reconnects.
889
- *
890
- * Thundering-herd fix: when the server restarts, every connected client
891
- * fires `onclose` at nearly the same instant. Without jitter they all
892
- * reconnect at `baseDelay` and hammer the newly-booted server; after a
893
- * few cycles the reconnect waves align and the server never recovers.
894
- *
895
- * Full-jitter (`delay = random(0, exp)`) spreads clients evenly across
896
- * the backoff window so the second-wave load is flat, not spiky.
897
- * Algorithm from AWS Architecture Blog "Exponential Backoff and Jitter"
898
- * — the "Full Jitter" variant, which has the lowest collision rate.
899
- *
900
- * The `reconnectDelay` config value seeds the exponential base. Max
901
- * delay caps at 30s so users don't wait minutes on a long outage.
902
- */
903
- private computeBackoff(): number {
904
- const base = this.config.reconnectDelay ?? 1000;
905
- const maxDelay = 30_000;
906
- // exp = base * 2^(attempts-1), clamped to maxDelay
907
- const attempt = Math.max(1, this.reconnectAttempts);
908
- const exp = Math.min(maxDelay, base * Math.pow(2, attempt - 1));
909
- // Full jitter: delay is uniform random in [0, exp].
910
- return Math.floor(Math.random() * exp);
911
- }
912
-
913
- /** Connect via Server-Sent Events. */
914
- private connectSse(): void {
915
- if (!this.running) return;
916
-
917
- const base = this.config.baseUrl;
918
- const url = new URL(base);
919
- const port = parseInt(url.port || "4321", 10);
920
- const sseUrl = `http://${url.hostname}:${port + 2}/events`;
921
-
922
- try {
923
- const es = new EventSource(sseUrl);
924
- es.onmessage = (event) => {
925
- try {
926
- const msg = JSON.parse(event.data);
927
- if (msg.seq && msg.entity && msg.kind) {
928
- const change = msg as ChangeEvent;
929
- void this.enqueueApply([change]);
930
- }
931
- } catch {
932
- // Ignore malformed events.
933
- }
934
- };
935
- es.onerror = () => {
936
- es.close();
937
- // Same jittered backoff as the WS path so SSE clients don't form
938
- // a second reconnect wave on server restart.
939
- this.reconnectAttempts += 1;
940
- setTimeout(() => {
941
- if (this.running) {
942
- this.pull().then(() => this.connectSse());
943
- }
944
- }, this.computeBackoff());
945
- };
946
- } catch {
947
- // EventSource not available — fall back to polling.
948
- this.startPolling();
887
+ if (this.orchestrator) {
888
+ this.orchestrator.stop();
889
+ this.orchestrator = null;
949
890
  }
891
+ this.setConnectionStatus("offline");
950
892
  }
951
893
 
952
- private deriveWsUrl(): string {
953
- const base = this.config.baseUrl;
954
- const url = new URL(base);
955
- const scheme = url.protocol === "https:" ? "wss" : "ws";
956
-
957
- // Always multiplex WS on the same origin via `/api/sync/ws`. The
958
- // Pylon runtime accepts the Upgrade on its main HTTP port (4321),
959
- // so any reverse proxy that already forwards `/api/*` carries the
960
- // WebSocket through too (Vite's `ws: true` proxy, Next.js rewrites,
961
- // CDNs with WS support).
962
- //
963
- // The legacy port+1 fallback (`:4322` for a `:4321` API) is still
964
- // available on the runtime, but we don't derive it client-side
965
- // anymore: any setup where the page origin (e.g. Vite on :3000)
966
- // wasn't equal to the API origin would compute ws://localhost:3001
967
- // — which doesn't exist and bypasses the dev-server proxy. The
968
- // `/api/sync/ws` path goes through whatever proxies `/api/*`,
969
- // which is the same code path prod already relies on.
970
- return `${scheme}://${url.host}/api/sync/ws`;
971
- }
972
894
 
973
895
  /**
974
896
  * Drop local cursor + store + notify. Safe to call from any state.
@@ -988,6 +910,13 @@ export class SyncEngine {
988
910
  * in-memory state could fix.
989
911
  */
990
912
  async resetReplica(): Promise<void> {
913
+ // Public callers go through the queue so a reset can't race with
914
+ // an in-flight pull / push / reconcile. Internal callers that
915
+ // already hold the queue slot use `resetReplicaInner` directly.
916
+ return this.opQueue.enqueue("reset", () => this.resetReplicaInner());
917
+ }
918
+
919
+ private async resetReplicaInner(): Promise<void> {
991
920
  this.cursor = { last_seq: 0 };
992
921
  this.store.clearAll();
993
922
  if (this.persistence) {
@@ -998,6 +927,12 @@ export class SyncEngine {
998
927
  /* best-effort */
999
928
  }
1000
929
  }
930
+ // Leader broadcasts the reset so follower replicas wipe their
931
+ // own copies in lockstep — otherwise a follower keeps stale
932
+ // rows under the old identity until its own pull catches up.
933
+ if (this.isMultiTabLeader) {
934
+ this.broadcastToTabs({ type: "reset" });
935
+ }
1001
936
  }
1002
937
 
1003
938
  /**
@@ -1081,24 +1016,35 @@ export class SyncEngine {
1081
1016
  );
1082
1017
  }
1083
1018
 
1084
- /** Pull changes from the server. */
1019
+ /** Pull changes from the server. Coalesces concurrent callers via
1020
+ * the op queue and serializes against push / reconcile / reset, so
1021
+ * the cursor can't be read mid-reset and the change-log delta can't
1022
+ * interleave with a sweeping reconcile. The 410 RESYNC retry path
1023
+ * recurses into `pullInner` directly to avoid self-deadlock on the
1024
+ * queue. */
1085
1025
  async pull(): Promise<void> {
1026
+ return this.opQueue.enqueue("pull", () => this.pullInner());
1027
+ }
1028
+
1029
+ private async pullInner(): Promise<void> {
1030
+ // Followers don't talk to the network — the leader broadcasts
1031
+ // every applied change over the multi-tab channel, and our local
1032
+ // applyQueue picks it up there.
1033
+ if (!this.isMultiTabLeader) return;
1086
1034
  // Identity change detection. If the token flipped since the last pull
1087
1035
  // (anonymous → signed in, user A → user B, signed in → signed out),
1088
1036
  // the server's visible set changed under us and the cursor we saved
1089
1037
  // reflects the previous identity. Reset before pulling so we rebuild
1090
1038
  // the replica from seq=0 under the new identity.
1091
- const tokenNow = this.currentToken();
1092
- if (
1093
- this.lastSeenToken !== undefined &&
1094
- this.lastSeenToken !== tokenNow
1095
- ) {
1096
- await this.resetReplica();
1039
+ const { tokenChanged } = this.session.observeToken(this.currentToken());
1040
+ if (tokenChanged) {
1041
+ // We're holding the "pull" slot in the op queue — bypass the
1042
+ // queue's reset path to avoid self-deadlock.
1043
+ await this.resetReplicaInner();
1097
1044
  // Token flipped → the cached tenant is for the previous user. Pull
1098
1045
  // the fresh session in parallel with the cursor catch-up below.
1099
1046
  void this.refreshResolvedSession();
1100
1047
  }
1101
- this.lastSeenToken = tokenNow;
1102
1048
 
1103
1049
  try {
1104
1050
  // Snapshot pagination: when the cursor is 0 and the server's
@@ -1142,7 +1088,11 @@ export class SyncEngine {
1142
1088
  // tight loop that the server reads as abuse.
1143
1089
  const status = (err as { status?: number })?.status;
1144
1090
  if (status === 429) {
1145
- this.reconnectAttempts += 3;
1091
+ // Push the next reconnect noticeably further out so a rate-
1092
+ // limited pull doesn't drive a tight 429 / reconnect / pull
1093
+ // / 429 loop the server reads as abuse. The +3 attempts skips
1094
+ // straight to a longer backoff window.
1095
+ this.transport?.bumpReconnect(3);
1146
1096
  }
1147
1097
  // 410 RESYNC_REQUIRED: cursor is from a previous server lifetime, or
1148
1098
  // it fell off the retention window. Drop local state + cursor and
@@ -1158,8 +1108,11 @@ export class SyncEngine {
1158
1108
  const attempt = this.consecutive_410s;
1159
1109
  this.consecutive_410s += 1;
1160
1110
  if (attempt === 0) {
1161
- await this.resetReplica();
1162
- await this.pull();
1111
+ // Bypass the queue here — we ARE the pull op holding the
1112
+ // queue slot. Calling the public pull() would re-enqueue and
1113
+ // share our own promise back to us (deadlock).
1114
+ await this.resetReplicaInner();
1115
+ await this.pullInner();
1163
1116
  } else {
1164
1117
  // Already retried once and still 410. Stop. Schedule a
1165
1118
  // back-off retry tied to the WS reconnect path so we don't
@@ -1190,11 +1143,6 @@ export class SyncEngine {
1190
1143
  * entity twice within seconds. Configurable via `reconcileMinIntervalMs`. */
1191
1144
  private lastReconcileAt = 0;
1192
1145
 
1193
- /** In-flight reconcile promise — coalesces concurrent callers so a
1194
- * visibility-change firing during an in-progress reconcile doesn't
1195
- * double the work. */
1196
- private inFlightReconcile: Promise<void> | null = null;
1197
-
1198
1146
  /**
1199
1147
  * Reconcile the local replica against server truth.
1200
1148
  *
@@ -1231,21 +1179,28 @@ export class SyncEngine {
1231
1179
  * no arg, every entity with local rows is checked.
1232
1180
  */
1233
1181
  async reconcile(entities?: string[]): Promise<void> {
1234
- if (this.inFlightReconcile) return this.inFlightReconcile;
1235
1182
  const minIntervalMs = this.config.reconcileMinIntervalMs ?? 2_000;
1236
1183
  const now = Date.now();
1237
1184
  if (entities === undefined && now - this.lastReconcileAt < minIntervalMs) {
1238
1185
  return;
1239
1186
  }
1240
- const work = this.reconcileInner(entities).finally(() => {
1241
- this.inFlightReconcile = null;
1242
- this.lastReconcileAt = Date.now();
1187
+ // Coalesce concurrent reconciles to a single op via the queue's
1188
+ // keyed dedupe — multiple callers in the same tick share one fetch.
1189
+ // Reconcile waits behind any in-flight pull / refresh / push so it
1190
+ // can't apply rows captured under a stale session.
1191
+ return this.opQueue.enqueue("reconcile", async () => {
1192
+ try {
1193
+ await this.reconcileInner(entities);
1194
+ } finally {
1195
+ this.lastReconcileAt = Date.now();
1196
+ }
1243
1197
  });
1244
- this.inFlightReconcile = work;
1245
- return work;
1246
1198
  }
1247
1199
 
1248
1200
  private async reconcileInner(entities?: string[]): Promise<void> {
1201
+ // Same reasoning as pullInner: the leader reconciles, broadcasts
1202
+ // results, and follower replicas converge via the channel.
1203
+ if (!this.isMultiTabLeader) return;
1249
1204
  const names = entities ?? this.store.entityNames();
1250
1205
  if (names.length === 0) return;
1251
1206
  // Tombstone seq for any local row the server doesn't return. Using
@@ -1275,7 +1230,7 @@ export class SyncEngine {
1275
1230
  // (triggered by session-changed envelope) will re-fetch
1276
1231
  // under the new context.
1277
1232
  const cursorBeforeFetch = this.cursor.last_seq;
1278
- const sessionBeforeFetch = sessionSignature(this._resolvedSession);
1233
+ const sessionBeforeFetch = this.session.signature();
1279
1234
  let serverRows: Row[];
1280
1235
  try {
1281
1236
  serverRows = await this.fetchEntityRows(entity);
@@ -1300,7 +1255,7 @@ export class SyncEngine {
1300
1255
  // state for the affected row.
1301
1256
  continue;
1302
1257
  }
1303
- if (sessionSignature(this._resolvedSession) !== sessionBeforeFetch) {
1258
+ if (this.session.signature() !== sessionBeforeFetch) {
1304
1259
  // Session changed (token flipped, tenant switched, user
1305
1260
  // signed out → in, etc.). The rows we fetched reflect the
1306
1261
  // OLD session's policy view; applying them now would
@@ -1342,92 +1297,40 @@ export class SyncEngine {
1342
1297
  tombstoneSeq: number,
1343
1298
  ): Promise<void> {
1344
1299
  // Invariant: rows with in-flight or failed mutations are
1345
- // off-limits to reconcile. Neither the "server row missing from
1346
- // local snapshot" apply branch nor the "local row missing from
1347
- // server snapshot" tombstone branch may touch them. A hydrated
1348
- // offline mutation that hasn't been pushed yet would otherwise
1349
- // look like a phantom local-only row and get tombstoned before
1350
- // push has a chance to ship it.
1300
+ // off-limits to reconcile. Neither the upsert branch nor the
1301
+ // tombstone branch may touch them. A hydrated offline mutation
1302
+ // that hasn't been pushed yet would otherwise look like a phantom
1303
+ // local-only row and get tombstoned before push has a chance to
1304
+ // ship it.
1351
1305
  // Test: `hydrated_offline_mutations_survive_startup_reconcile`.
1352
1306
  const pendingKeys = this.mutations.pendingRowKeys();
1353
1307
  const serverIds = new Set<string>();
1354
- const changes: ChangeEvent[] = [];
1308
+ const upserts: Row[] = [];
1355
1309
  for (const row of serverRows) {
1356
1310
  const id = (row as { id?: unknown }).id;
1357
1311
  if (typeof id !== "string" || id.length === 0) continue;
1358
1312
  serverIds.add(id);
1359
- // Skip any row whose canonical state is still being decided
1360
- // by an in-flight mutation — applying the server snapshot
1361
- // would clobber the user's pending edit.
1362
1313
  if (pendingKeys.has(`${entity}/${id}`)) continue;
1363
1314
  const local = this.store.get(entity, id);
1364
- if (!local) {
1365
- changes.push({
1366
- seq: tombstoneSeq + 1,
1367
- entity,
1368
- row_id: id,
1369
- kind: "insert",
1370
- data: row,
1371
- timestamp: "",
1372
- });
1373
- } else if (rowsDiffer(local, row)) {
1374
- changes.push({
1375
- seq: tombstoneSeq + 1,
1376
- entity,
1377
- row_id: id,
1378
- kind: "update",
1379
- data: row,
1380
- timestamp: "",
1381
- });
1315
+ if (!local || rowsDiffer(local, row)) {
1316
+ upserts.push(row);
1382
1317
  }
1383
1318
  }
1384
- if (changes.length > 0) {
1385
- // Reconcile applies route through the same serialized queue as
1386
- // WS/pull so a stale reconcile response can't interleave with a
1387
- // newer WS/pull update mid-batch. The synthetic seqs reconcile
1388
- // fabricates (tombstoneSeq + 1) collide with WS-issued seqs so
1389
- // we skip the monotonic guard and don't advance the cursor —
1390
- // the cursor still reflects the server's last_seq, not our
1391
- // fabricated reconcile seqs.
1392
- await this.enqueueApply(changes, {
1393
- skipSeqGuard: true,
1394
- advanceCursor: false,
1395
- });
1396
- }
1397
1319
  // Removals: every local row whose id isn't in the server set is
1398
- // stale. Tombstone with the current cursor so future legitimate
1399
- // re-creations still flow through. Synthesize Delete events for
1400
- // each removal and route through the apply queue so the order
1401
- // relative to WS/pull updates is preserved — a removal here is
1402
- // really "server says this row is gone as of tombstoneSeq", which
1403
- // matters if a later WS update reinstates it on the same row id.
1404
- const locals = this.store.list(entity);
1405
- const removalChanges: ChangeEvent[] = [];
1406
- for (const local of locals) {
1320
+ // stale. The reconcile primitive tombstones at `tombstoneSeq` so
1321
+ // future legitimate re-creations (with strictly greater seqs)
1322
+ // still flow through.
1323
+ const removalIds: string[] = [];
1324
+ for (const local of this.store.list(entity)) {
1407
1325
  const id = (local as { id?: unknown }).id;
1408
1326
  if (typeof id !== "string") continue;
1409
- // Pending mutations protect the row from the removal pass too
1410
- // — a queued insert that hasn't been pushed yet would otherwise
1411
- // look like a phantom local-only row and get tombstoned, only
1412
- // for push() to later resurrect it.
1413
1327
  if (pendingKeys.has(`${entity}/${id}`)) continue;
1414
- if (!serverIds.has(id)) {
1415
- removalChanges.push({
1416
- seq: tombstoneSeq,
1417
- entity,
1418
- row_id: id,
1419
- kind: "delete",
1420
- data: undefined,
1421
- timestamp: "",
1422
- });
1423
- }
1424
- }
1425
- if (removalChanges.length > 0) {
1426
- await this.enqueueApply(removalChanges, {
1427
- skipSeqGuard: true,
1428
- advanceCursor: false,
1429
- });
1328
+ if (!serverIds.has(id)) removalIds.push(id);
1430
1329
  }
1330
+ if (upserts.length === 0 && removalIds.length === 0) return;
1331
+ // Route through the apply queue so a reconcile batch can't
1332
+ // interleave with a fresher WS/pull change event mid-apply.
1333
+ await this.enqueueReconcile(entity, upserts, removalIds, tombstoneSeq);
1431
1334
  }
1432
1335
 
1433
1336
  private async dropEntity(
@@ -1454,7 +1357,9 @@ export class SyncEngine {
1454
1357
  }
1455
1358
 
1456
1359
  /**
1457
- * Fetch `/api/auth/me` and update the cached `_resolvedSession`. Callers:
1360
+ * Fetch `/api/auth/me` and feed the result into the SessionResolver,
1361
+ * acting on the verdict it returns (reset replica + pull on a real
1362
+ * tenant flip, notify subscribers if any field changed). Callers:
1458
1363
  * - `start()` — initial load
1459
1364
  * - the token-flip branch in `pull()`
1460
1365
  * - `notifySessionChanged()` — app code invokes this after it mutates
@@ -1466,6 +1371,11 @@ export class SyncEngine {
1466
1371
  * token-flip path, for the same reason (visible set changed).
1467
1372
  */
1468
1373
  async refreshResolvedSession(): Promise<void> {
1374
+ // Followers don't fetch /api/auth/me — the leader does and
1375
+ // broadcasts the result, which `handleMultiTabMessage` routes
1376
+ // into the resolver.
1377
+ if (!this.isMultiTabLeader) return;
1378
+ let next: ResolvedSession;
1469
1379
  try {
1470
1380
  const res = await this.rawFetch("/api/auth/me");
1471
1381
  if (!res.ok) return;
@@ -1475,66 +1385,71 @@ export class SyncEngine {
1475
1385
  is_admin?: boolean;
1476
1386
  roles?: string[];
1477
1387
  };
1478
- const next: ResolvedSession = {
1388
+ next = {
1479
1389
  userId: raw.user_id ?? null,
1480
1390
  tenantId: raw.tenant_id ?? null,
1481
1391
  isAdmin: raw.is_admin ?? false,
1482
1392
  roles: raw.roles ?? [],
1483
1393
  };
1484
- const tenantNow = next.tenantId;
1485
- // First observation seeds lastSeenTenant without a reset we have
1486
- // nothing to invalidate yet. Subsequent changes flip the replica
1487
- // AND immediately re-pull so subscribers see the new tenant's
1488
- // rows. Without the re-pull, resetReplica() leaves the store
1489
- // empty until the next periodic poll, and `db.useQuery` returns
1490
- // [] for the entire interval — exactly the "I switched orgs
1491
- // but the dashboard is empty" symptom the cloud team reported.
1492
- // The 410 RESYNC_REQUIRED path (see ~line 1499) does the same
1493
- // dance for the same reason.
1494
- if (
1495
- this.lastSeenTenant !== undefined &&
1496
- this.lastSeenTenant !== tenantNow
1497
- ) {
1498
- // Two flavors of "tenant changed":
1499
- //
1500
- // - null → X : session first-resolution. The engine started
1501
- // before the app called /api/auth/select-org;
1502
- // /api/auth/me returned tenant_id=null at start
1503
- // and is only now reporting the real value.
1504
- // The cached IndexedDB rows ARE for tenant X
1505
- // (that's where the user's data lives), so
1506
- // wiping them would tombstone valid state and
1507
- // produce the "rows render then flash away"
1508
- // symptom multi-tenant apps were hitting. Skip
1509
- // the reset; pull under the new tenant fills
1510
- // any gaps via the existing cursor catch-up.
1511
- //
1512
- // - X Y : actual org switch. Cached rows belong to
1513
- // the OLD tenant and must not bleed into the
1514
- // new context, so resetReplica is correct.
1515
- const firstResolution = this.lastSeenTenant === null && tenantNow !== null;
1516
- if (!firstResolution) {
1394
+ } catch {
1395
+ // Swallow /api/auth/me errors are transient and the next pull
1396
+ // will retry. Don't take down the sync loop for this.
1397
+ return;
1398
+ }
1399
+ await this.applySessionTransition(next, /* broadcast */ true);
1400
+ }
1401
+
1402
+ /**
1403
+ * Apply a freshly-observed session through the resolver and act on
1404
+ * the verdict. Serialized via `sessionChain` so concurrent triggers
1405
+ * (refreshResolvedSession from app code + multi-tab `session`
1406
+ * broadcast + WS `session-changed` envelope) run in arrival order
1407
+ * and the latest tenant wins — without this, two interleaved
1408
+ * inspect-then-commit pairs could commit an older session AFTER
1409
+ * the newer one.
1410
+ */
1411
+ private applySessionTransition(
1412
+ next: ResolvedSession,
1413
+ broadcast: boolean,
1414
+ ): Promise<void> {
1415
+ const prev = this.sessionChain;
1416
+ // Swallow errors when storing back to the chain so a single
1417
+ // thrown transition doesn't poison the FIFO for everyone after.
1418
+ // Callers receive the swallowed chain — they shouldn't see (or
1419
+ // need to handle) errors from a session refresh.
1420
+ this.sessionChain = prev.then(async () => {
1421
+ // Defer the null→X / X→Y / first-resolution distinction to the
1422
+ // resolver, but DON'T commit the new resolved session until
1423
+ // after the engine has finished acting on the verdict — that
1424
+ // closes the brief window where useSession would report the
1425
+ // new tenant while useQuery still has the old tenant's rows.
1426
+ const verdict = this.session.inspectSession(next);
1427
+ if (verdict.tenantChanged) {
1428
+ if (verdict.replicaInvalidated) {
1429
+ // Route reset through the public (queued) method so the
1430
+ // wipe serializes against in-flight pulls / WS-event
1431
+ // applies / pushes. sessionChain serializes session
1432
+ // transitions but NOT the apply queue — without queuing
1433
+ // the reset, a concurrent applyChangesAsync could write
1434
+ // rows AFTER we clear the store, leaving stale data under
1435
+ // the new identity.
1517
1436
  await this.resetReplica();
1518
1437
  }
1519
- await this.pull();
1438
+ if (this.isMultiTabLeader) {
1439
+ // Only the leader pulls — followers receive subsequent
1440
+ // applied broadcasts that close the catch-up window.
1441
+ await this.pull();
1442
+ }
1520
1443
  }
1521
- this.lastSeenTenant = tenantNow;
1522
- const prev = this._resolvedSession;
1523
- const changed =
1524
- prev.userId !== next.userId ||
1525
- prev.tenantId !== next.tenantId ||
1526
- prev.isAdmin !== next.isAdmin ||
1527
- prev.roles.join(",") !== next.roles.join(",");
1528
- if (changed) {
1529
- this._resolvedSession = next;
1530
- // Piggy-back on the store notifier so `useSession` re-renders via
1531
- // useSyncExternalStore without a second pub/sub channel.
1444
+ this.session.commitObservation(next);
1445
+ if (verdict.identityChanged) {
1532
1446
  this.store.notify();
1533
1447
  }
1534
- } catch {
1535
- // Swallow /api/auth/me errors are transient and the next pull
1536
- // will retry. Don't take down the sync loop for this.
1537
- }
1448
+ if (broadcast && this.isMultiTabLeader) {
1449
+ this.broadcastToTabs({ type: "session", resolved: next });
1450
+ }
1451
+ }).catch(() => {});
1452
+ return this.sessionChain;
1538
1453
  }
1539
1454
 
1540
1455
  private async rawFetch(path: string): Promise<Response> {
@@ -1716,34 +1631,32 @@ export class SyncEngine {
1716
1631
  return parsed;
1717
1632
  }
1718
1633
 
1719
- /**
1720
- * In-flight push promise. Used as a mutex so a slow push can't be restarted
1721
- * by the poll timer or a user mutation, which would resend the same batch
1722
- * and cause duplicate writes on the server. The mutation `op_id` keeps
1723
- * that safe at the protocol level (the server deduplicates), but shipping
1724
- * the same batch twice is still wasted bandwidth hold them instead.
1725
- *
1726
- * Callers always get the SAME promise while a push is running; chain a
1727
- * `.then(() => next push)` if you need a follow-up push after this one.
1728
- */
1729
- private inFlightPush: Promise<void> | null = null;
1730
-
1731
- /** Push pending mutations to the server. Coalesces concurrent callers. */
1634
+ /** Push pending mutations to the server. Coalesces concurrent callers
1635
+ * via the op queue's keyed dedupe a slow push can't be restarted
1636
+ * by the poll timer or a user mutation, which would resend the same
1637
+ * batch (the mutation `op_id` keeps that safe at the protocol level,
1638
+ * but shipping the same batch twice is still wasted bandwidth). Also
1639
+ * serializes against pull / reconcile / resetReplica so a push can't
1640
+ * observe a half-reset cursor or a mid-reconcile replica. */
1732
1641
  async push(): Promise<void> {
1733
- if (this.inFlightPush) {
1734
- return this.inFlightPush;
1735
- }
1736
- const work = this.pushInner().finally(() => {
1737
- this.inFlightPush = null;
1738
- });
1739
- this.inFlightPush = work;
1740
- return work;
1642
+ return this.opQueue.enqueue("push", () => this.pushInner());
1741
1643
  }
1742
1644
 
1743
1645
  private async pushInner(): Promise<void> {
1744
1646
  const pending = this.mutations.pending();
1745
1647
  if (pending.length === 0) return;
1746
1648
 
1649
+ // Multi-tab follower: we don't own the network. Forward the
1650
+ // pending batch to the leader and let it push. The leader
1651
+ // broadcasts `mutations-acked` when the server confirms; that
1652
+ // path clears our queue. Note we don't clear locally here — if
1653
+ // the leader dies before pushing, on promotion we still have
1654
+ // the queue and can ship it ourselves.
1655
+ if (!this.isMultiTabLeader) {
1656
+ this.broadcastToTabs({ type: "mutations", ops: pending });
1657
+ return;
1658
+ }
1659
+
1747
1660
  try {
1748
1661
  const resp = await this.request<PushResponse>("POST", "/api/sync/push", {
1749
1662
  changes: pending.map((m) => m.change),
@@ -1808,6 +1721,31 @@ export class SyncEngine {
1808
1721
  }
1809
1722
  }
1810
1723
 
1724
+ // Broadcast per-op outcomes BEFORE clearing locally so followers
1725
+ // can update their queue status. Filter strictly by current
1726
+ // status — pending[i] is the LIVE PendingMutation that markApplied
1727
+ // / markFailed just mutated, so `m.status` tells us exactly what
1728
+ // happened. Ops that stayed "pending" (server-side in-flight
1729
+ // dedupe) get neither — the leader will retry, and a later push
1730
+ // will broadcast a real ack.
1731
+ const ackedOpIds: string[] = [];
1732
+ const failedOps: { opId: string; error: string }[] = [];
1733
+ for (const m of pending) {
1734
+ const opId = m.change.op_id;
1735
+ if (typeof opId !== "string") continue;
1736
+ if (m.status === "applied") {
1737
+ ackedOpIds.push(opId);
1738
+ } else if (m.status === "failed") {
1739
+ failedOps.push({ opId, error: m.error ?? "unknown" });
1740
+ }
1741
+ }
1742
+ if (ackedOpIds.length > 0) {
1743
+ this.broadcastToTabs({ type: "mutations-acked", opIds: ackedOpIds });
1744
+ }
1745
+ if (failedOps.length > 0) {
1746
+ this.broadcastToTabs({ type: "mutations-failed", ops: failedOps });
1747
+ }
1748
+
1811
1749
  this.mutations.clear();
1812
1750
 
1813
1751
  // Catch-up pull: if the server confirmed an apply at a seq
@@ -1974,9 +1912,11 @@ export class SyncEngine {
1974
1912
  return { ...this.cursor };
1975
1913
  }
1976
1914
 
1977
- /** Whether the WebSocket is currently connected. */
1915
+ /** Whether the real-time transport is currently open. True for an
1916
+ * open WebSocket / EventSource and for a running poll loop; false
1917
+ * for a follower tab (no transport) or before start() / after stop(). */
1978
1918
  get connected(): boolean {
1979
- return this.ws?.readyState === WebSocket.OPEN;
1919
+ return this.transport?.isOpen() ?? false;
1980
1920
  }
1981
1921
 
1982
1922
  // -----------------------------------------------------------------------
@@ -2016,13 +1956,7 @@ export class SyncEngine {
2016
1956
  * intervening unsubscribe just bumps the refcount.
2017
1957
  */
2018
1958
  subscribeCrdt(entity: string, rowId: string): void {
2019
- const key = `${entity}\x00${rowId}`;
2020
- const prev = this.crdtSubscribers.get(key) ?? 0;
2021
- this.crdtSubscribers.set(key, prev + 1);
2022
- if (prev === 0) {
2023
- this.crdtSubscriptions.add(key);
2024
- this.sendWs({ type: "crdt-subscribe", entity, rowId });
2025
- }
1959
+ this.subscriptions.subscribeCrdt(entity, rowId);
2026
1960
  }
2027
1961
 
2028
1962
  /**
@@ -2035,16 +1969,7 @@ export class SyncEngine {
2035
1969
  * invocation in dev from over-decrementing past zero.
2036
1970
  */
2037
1971
  unsubscribeCrdt(entity: string, rowId: string): void {
2038
- const key = `${entity}\x00${rowId}`;
2039
- const prev = this.crdtSubscribers.get(key) ?? 0;
2040
- if (prev <= 0) return;
2041
- if (prev === 1) {
2042
- this.crdtSubscribers.delete(key);
2043
- this.crdtSubscriptions.delete(key);
2044
- this.sendWs({ type: "crdt-unsubscribe", entity, rowId });
2045
- } else {
2046
- this.crdtSubscribers.set(key, prev - 1);
2047
- }
1972
+ this.subscriptions.unsubscribeCrdt(entity, rowId);
2048
1973
  }
2049
1974
 
2050
1975
  // -----------------------------------------------------------------------
@@ -2079,24 +2004,183 @@ export class SyncEngine {
2079
2004
  args: unknown,
2080
2005
  handler: (msg: ReactiveMessage) => void,
2081
2006
  ): void {
2082
- this.reactiveSpecs.set(sub_id, { fn_name, args });
2083
- this.reactiveHandlers.set(sub_id, handler);
2084
- this.sendWs({ type: "reactive-subscribe", sub_id, fn_name, args });
2007
+ this.subscriptions.subscribeReactive(sub_id, fn_name, args, handler);
2085
2008
  }
2086
2009
 
2087
2010
  /** Tear down a reactive subscription. Sends the unsubscribe to the
2088
2011
  * server and clears local state. No-op for unknown sub_ids — React
2089
2012
  * StrictMode double-unmount won't error. */
2090
2013
  unsubscribeReactive(sub_id: string): void {
2091
- if (!this.reactiveSpecs.has(sub_id)) return;
2092
- this.reactiveSpecs.delete(sub_id);
2093
- this.reactiveHandlers.delete(sub_id);
2094
- this.sendWs({ type: "reactive-unsubscribe", sub_id });
2014
+ this.subscriptions.unsubscribeReactive(sub_id);
2095
2015
  }
2096
2016
 
2017
+ /** Send a JSON message via the active transport. No-op when no
2018
+ * transport exists (follower tab) or the transport doesn't support
2019
+ * uplink (SSE, polling). Subscribe / presence / topic / ping frames
2020
+ * all route here. */
2097
2021
  private sendWs(msg: unknown): void {
2098
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
2099
- this.ws.send(JSON.stringify(msg));
2022
+ this.transport?.send(msg);
2023
+ }
2024
+
2025
+ /** Resolved transport kind, with the websocket default applied. */
2026
+ private transportKind(): TransportKind {
2027
+ return this.config.transport ?? "websocket";
2028
+ }
2029
+
2030
+ /** Build the host surface the transport calls back into. One object
2031
+ * shared across transport lifetime — the engine fields it reads (
2032
+ * config, transport state, callbacks) are stable references. */
2033
+ private transportHost(): TransportHost {
2034
+ return {
2035
+ baseUrl: this.config.baseUrl,
2036
+ wsUrl: this.config.wsUrl,
2037
+ pingIntervalMs: this.config.pingIntervalMs,
2038
+ reconnectDelayMs: this.config.reconnectDelay,
2039
+ pollIntervalMs: this.config.pollInterval,
2040
+ getToken: () => this.currentToken() ?? undefined,
2041
+ isLeader: () => this.isMultiTabLeader,
2042
+ isRunning: () => this.running,
2043
+ onChangeEvent: (ev) => {
2044
+ void this.enqueueApply([ev]);
2045
+ },
2046
+ onJsonMessage: (msg) => this.dispatchInboundMessage(msg),
2047
+ onBinaryFrame: (bytes) => this.dispatchBinaryFrame(bytes),
2048
+ onConnected: () => {
2049
+ // Re-send every active server-subscription (CRDT rows,
2050
+ // reactive queries, future kinds) across the new socket. The
2051
+ // server purges per-client subscription state on disconnect,
2052
+ // so without this resync the subscriber's first event would
2053
+ // never arrive.
2054
+ this.serverSubs.replay();
2055
+ // Pull-on-open catches every event broadcast in the gap
2056
+ // between the prior pull() returning and the socket actually
2057
+ // opening. Reconcile fires after the pull since pull is the
2058
+ // cheap incremental path; reconcile is the server-truth
2059
+ // backstop for anything pull couldn't replay.
2060
+ void this.pull().then(() => this.reconcile());
2061
+ },
2062
+ onDisconnected: () => {
2063
+ /* Engine has no work on disconnect — the transport's own
2064
+ * reconnect loop drives the recovery. The connection-status
2065
+ * flip already happened inside the transport. */
2066
+ },
2067
+ setStatus: (s) => this.setConnectionStatus(s),
2068
+ performPollTick: async () => {
2069
+ await this.push().then(() => this.pull());
2070
+ },
2071
+ performReconnectPull: async () => {
2072
+ // Wrapped in try so a transient pull failure doesn't kill the
2073
+ // reconnect chain; the next attempt will retry.
2074
+ try {
2075
+ await this.pull();
2076
+ } catch {
2077
+ /* best-effort */
2078
+ }
2079
+ },
2080
+ };
2081
+ }
2082
+
2083
+ /** Dispatch a typed JSON envelope inbound from the transport. The
2084
+ * transport already filtered out ChangeEvents (those go through
2085
+ * onChangeEvent → enqueueApply). Anything else lands here. */
2086
+ private dispatchInboundMessage(msg: Record<string, unknown>): void {
2087
+ // Presence event.
2088
+ if (msg.type === "presence") {
2089
+ this.store.notify();
2090
+ return;
2091
+ }
2092
+
2093
+ // Server-driven revocation: a subscriber whose read policy was
2094
+ // revoked mid-session for a specific row. Drop the row from the
2095
+ // local replica at the current cursor seq so the tombstone
2096
+ // supersedes any racing late-arriving WS update for the same
2097
+ // row, and notify any LoroDoc subscriber (registered via
2098
+ // `addRowEvictionListener`) so collaborative doc handles unmount
2099
+ // cleanly.
2100
+ //
2101
+ // Distinct from a regular Delete change event because this
2102
+ // envelope has no global seq — the row's underlying data hasn't
2103
+ // been deleted, only the recipient's visibility of it. Other
2104
+ // subscribers (with matching policy) keep their row intact.
2105
+ if (
2106
+ msg.type === "row-revoked" &&
2107
+ typeof msg.entity === "string" &&
2108
+ typeof msg.row_id === "string"
2109
+ ) {
2110
+ const revokeSeq =
2111
+ typeof msg.seq === "number" && msg.seq > 0
2112
+ ? (msg.seq as number)
2113
+ : this.cursor.last_seq;
2114
+ this.handleRowRevocation(msg.entity, msg.row_id, revokeSeq);
2115
+ return;
2116
+ }
2117
+
2118
+ // Session mutated server-side. Fires for select-org / clear-org
2119
+ // / session revoke — every tab connected as this user gets the
2120
+ // envelope (cross-machine too via the cluster bus). Trigger a
2121
+ // fresh /api/auth/me read which updates the cached session AND,
2122
+ // on tenant flip, resets the replica so stale rows from the
2123
+ // previous tenant disappear.
2124
+ if (msg.type === "session-changed") {
2125
+ void this.refreshResolvedSession();
2126
+ return;
2127
+ }
2128
+
2129
+ // Reactive query push: the server-side ReactiveRegistry re-ran a
2130
+ // subscribed handler and the result hash changed. Route to the
2131
+ // local handler if we own the subscription AND forward to follower
2132
+ // tabs via the multi-tab channel so a follower's
2133
+ // `useReactiveQuery` handler fires too.
2134
+ if (msg.type === "reactive-result" && typeof msg.sub_id === "string") {
2135
+ const payload = { kind: "result" as const, result: msg.result };
2136
+ this.subscriptions.handleReactiveMessage(msg.sub_id, payload);
2137
+ this.broadcastToTabs({
2138
+ type: "reactive-msg",
2139
+ sub_id: msg.sub_id,
2140
+ payload,
2141
+ });
2142
+ return;
2143
+ }
2144
+ if (msg.type === "reactive-error" && typeof msg.sub_id === "string") {
2145
+ const errPayload = {
2146
+ kind: "error" as const,
2147
+ code: typeof msg.code === "string" ? msg.code : "REACTIVE_ERROR",
2148
+ message: typeof msg.message === "string" ? msg.message : "",
2149
+ };
2150
+ this.subscriptions.handleReactiveMessage(msg.sub_id, errPayload);
2151
+ this.broadcastToTabs({
2152
+ type: "reactive-msg",
2153
+ sub_id: msg.sub_id,
2154
+ payload: errPayload,
2155
+ });
2156
+ return;
2157
+ }
2158
+ }
2159
+
2160
+ /** Route a binary frame to local consumers AND, when at least one
2161
+ * follower tab forwarded a CRDT sub, mirror over the multi-tab
2162
+ * channel so followers see Loro updates too. */
2163
+ private dispatchBinaryFrame(bytes: Uint8Array): void {
2164
+ for (const handler of this.binaryHandlers) {
2165
+ try {
2166
+ handler(bytes);
2167
+ } catch (err) {
2168
+ console.warn("[sync] binary handler threw:", err);
2169
+ }
2170
+ }
2171
+ // Forward to follower tabs ONLY when at least one follower is
2172
+ // currently forwarded for a CRDT row. The engine is binary-
2173
+ // agnostic — it can't peek inside the frame to route per-row —
2174
+ // so this is a tab-level gate: no forwarders = no broadcast.
2175
+ // Saves bandwidth in the common single-tab case.
2176
+ //
2177
+ // Trade-off: when ANY follower has forwarded a CRDT sub on ANY
2178
+ // key, we broadcast EVERY binary frame regardless of which row
2179
+ // it's for. Acceptable for now; the lever to pull if it shows
2180
+ // up in profiling is a binaryRoutes map keyed by the Loro doc
2181
+ // id parsed from the frame header.
2182
+ if (this.subscriptions.hasCrdtForwarders()) {
2183
+ this.broadcastToTabs({ type: "binary", bytes });
2100
2184
  }
2101
2185
  }
2102
2186
 
@@ -2212,19 +2296,6 @@ function rowsDiffer(a: Row, b: Row): boolean {
2212
2296
  return stableStringify(a) !== stableStringify(b);
2213
2297
  }
2214
2298
 
2215
- /**
2216
- * Compact, comparable fingerprint of a resolved session. Used by
2217
- * reconcile() to detect mid-fetch identity flips — if the signature
2218
- * differs, the rows we just fetched were policy-filtered under a
2219
- * stale auth context and applying them would tombstone rows visible
2220
- * under the new context. Roles array is sorted+joined so insertion
2221
- * order doesn't trip the equality check.
2222
- */
2223
- function sessionSignature(s: ResolvedSession): string {
2224
- const roles = (s.roles ?? []).slice().sort().join(",");
2225
- return `${s.userId ?? ""}|${s.tenantId ?? ""}|${s.isAdmin ? "1" : "0"}|${roles}`;
2226
- }
2227
-
2228
2299
  function stableStringify(value: unknown): string {
2229
2300
  if (value === null || typeof value !== "object") return JSON.stringify(value);
2230
2301
  if (Array.isArray(value)) {