@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/package.json +1 -1
- package/src/index.ts +732 -661
- package/src/local-store.ts +74 -0
- package/src/multi-tab-orchestrator.test.ts +173 -0
- package/src/multi-tab-orchestrator.ts +366 -0
- package/src/multi-tab.test.ts +196 -0
- package/src/multi-tab.ts +366 -0
- package/src/mutation-queue.ts +12 -2
- package/src/op-queue.test.ts +91 -0
- package/src/op-queue.ts +73 -0
- package/src/reconcile.test.ts +31 -33
- package/src/round6-codex.test.ts +328 -0
- package/src/scenarios.test.ts +606 -0
- package/src/server-subscriptions.test.ts +99 -0
- package/src/server-subscriptions.ts +78 -0
- package/src/session-chain.test.ts +133 -0
- package/src/session-resolver.test.ts +94 -0
- package/src/session-resolver.ts +133 -0
- package/src/subscription-coordinator.test.ts +209 -0
- package/src/subscription-coordinator.ts +471 -0
- package/src/test-harness/env.ts +191 -0
- package/src/test-harness/index.ts +16 -0
- package/src/test-harness/server.ts +433 -0
- package/src/test-harness/transport.ts +256 -0
- package/src/transports/factory.test.ts +87 -0
- package/src/transports/index.ts +42 -0
- package/src/transports/polling.test.ts +102 -0
- package/src/transports/polling.ts +63 -0
- package/src/transports/reconnect.test.ts +57 -0
- package/src/transports/reconnect.ts +50 -0
- package/src/transports/sse.ts +140 -0
- package/src/transports/types.ts +116 -0
- package/src/transports/websocket.test.ts +310 -0
- package/src/transports/websocket.ts +222 -0
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
|
-
|
|
160
|
-
|
|
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
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
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
|
-
*
|
|
219
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
private
|
|
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
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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
|
|
276
|
-
private crdtSubscribers: Map<string, number> = new Map();
|
|
309
|
+
private serverSubs!: ServerSubscriptions;
|
|
277
310
|
|
|
278
|
-
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
|
|
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.
|
|
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,
|
|
470
|
-
//
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
529
|
-
//
|
|
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
|
-
|
|
554
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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.
|
|
620
|
-
this.
|
|
621
|
-
this.
|
|
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.
|
|
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
|
|
1092
|
-
if (
|
|
1093
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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 =
|
|
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 (
|
|
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
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
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
|
|
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
|
-
|
|
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.
|
|
1399
|
-
// re-creations
|
|
1400
|
-
//
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1485
|
-
//
|
|
1486
|
-
//
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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
|
-
|
|
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.
|
|
1522
|
-
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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
|
-
*
|
|
1721
|
-
*
|
|
1722
|
-
*
|
|
1723
|
-
*
|
|
1724
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2099
|
-
|
|
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)) {
|