@pylonsync/sync 0.3.213 → 0.3.216
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/idb-warm-load.test.ts +426 -0
- package/src/index.ts +412 -12
- package/src/multi-tab-orchestrator.ts +80 -0
- package/src/persistence.ts +55 -0
- package/src/room-push.test.ts +197 -0
- package/src/room-subscriptions.test.ts +189 -0
- package/src/room-subscriptions.ts +301 -0
- package/src/test-harness/env.ts +6 -0
- package/src/test-harness/transport.ts +23 -0
package/src/index.ts
CHANGED
|
@@ -17,6 +17,12 @@ import { LocalStore } from "./local-store";
|
|
|
17
17
|
import { MutationQueue, type PendingMutation } from "./mutation-queue";
|
|
18
18
|
import { MultiTabOrchestrator } from "./multi-tab-orchestrator";
|
|
19
19
|
import { OpQueue } from "./op-queue";
|
|
20
|
+
import {
|
|
21
|
+
RoomSubscriptions,
|
|
22
|
+
type RoomError,
|
|
23
|
+
type RoomMember,
|
|
24
|
+
type RoomSubscriber,
|
|
25
|
+
} from "./room-subscriptions";
|
|
20
26
|
import { ServerSubscriptions } from "./server-subscriptions";
|
|
21
27
|
import { SessionResolver } from "./session-resolver";
|
|
22
28
|
import { SubscriptionCoordinator } from "./subscription-coordinator";
|
|
@@ -27,6 +33,13 @@ import {
|
|
|
27
33
|
type TransportKind,
|
|
28
34
|
} from "./transports";
|
|
29
35
|
import { generateClientId, generateId } from "./ids";
|
|
36
|
+
export {
|
|
37
|
+
RoomSubscriptions,
|
|
38
|
+
type RoomError,
|
|
39
|
+
type RoomErrorCode,
|
|
40
|
+
type RoomMember,
|
|
41
|
+
type RoomSubscriber,
|
|
42
|
+
} from "./room-subscriptions";
|
|
30
43
|
export { IndexedDBPersistence, persistChange } from "./persistence";
|
|
31
44
|
export {
|
|
32
45
|
buildRequest,
|
|
@@ -202,6 +215,24 @@ export class SyncEngine {
|
|
|
202
215
|
return this._hydrated;
|
|
203
216
|
}
|
|
204
217
|
|
|
218
|
+
/**
|
|
219
|
+
* True when the engine drained at least one row OR a saved cursor
|
|
220
|
+
* out of IndexedDB during `start()`. Distinguishes a returning user
|
|
221
|
+
* (cached replica may contain rows the server has since deleted) from
|
|
222
|
+
* a true first-time user (cache empty, pull-from-0 IS canonical
|
|
223
|
+
* truth).
|
|
224
|
+
*
|
|
225
|
+
* Used by the WS `onConnected` fast-path: `lastPullStartedFromZero`
|
|
226
|
+
* only fires the reconcile-skip when this flag is ALSO false. A
|
|
227
|
+
* returning user whose IDB cursor somehow rolled back to 0 (rare:
|
|
228
|
+
* partial wipe, corrupt write) must still get the reconcile pass —
|
|
229
|
+
* otherwise rows deleted on the server while the tab was closed
|
|
230
|
+
* survive forever.
|
|
231
|
+
*
|
|
232
|
+
* Read-only after start() observes the IDB load.
|
|
233
|
+
*/
|
|
234
|
+
private _hadCachedReplica = false;
|
|
235
|
+
|
|
205
236
|
readonly store: LocalStore;
|
|
206
237
|
readonly mutations: MutationQueue;
|
|
207
238
|
|
|
@@ -317,6 +348,27 @@ export class SyncEngine {
|
|
|
317
348
|
* serverSubs isn't built until then. */
|
|
318
349
|
private subscriptions!: SubscriptionCoordinator;
|
|
319
350
|
|
|
351
|
+
/** Room presence subscriptions. Replaces the per-component
|
|
352
|
+
* setInterval(GET /api/rooms/<room>, 5s) polling loop the `useRoom`
|
|
353
|
+
* hook used to run for every channel. New server protocol
|
|
354
|
+
* (v0.3.214+): the client sends `room-subscribe` / `room-unsubscribe`
|
|
355
|
+
* over the existing WS, and the server pushes `room-snapshot` /
|
|
356
|
+
* `room-update` whenever membership changes.
|
|
357
|
+
*
|
|
358
|
+
* This engine field is leader-only (followers forward register /
|
|
359
|
+
* unregister calls over the multi-tab channel — the leader's
|
|
360
|
+
* registry is the single source of truth on the wire). Constructed
|
|
361
|
+
* lazily in start() so SSR-only callers don't pay for it. */
|
|
362
|
+
private rooms!: RoomSubscriptions;
|
|
363
|
+
|
|
364
|
+
/** Per-room set of follower tabIds that have forwarded a
|
|
365
|
+
* `room-sub-register`. Leader-only. Mirrors `crdtForwarders` in the
|
|
366
|
+
* SubscriptionCoordinator — the WS room sub stays alive until both
|
|
367
|
+
* the local refcount AND this set are empty. A separate map on the
|
|
368
|
+
* engine (instead of inside RoomSubscriptions) because the registry
|
|
369
|
+
* is leader-local and forwarder bookkeeping is multi-tab specific. */
|
|
370
|
+
private roomForwarders: Map<string, Set<string>> = new Map();
|
|
371
|
+
|
|
320
372
|
/**
|
|
321
373
|
* Listeners notified when the server signals a per-subscriber row
|
|
322
374
|
* revocation (`row-revoked` envelope). Used by `@pylonsync/loro`
|
|
@@ -386,6 +438,16 @@ export class SyncEngine {
|
|
|
386
438
|
isLeader: () => this.isMultiTabLeader,
|
|
387
439
|
broadcastToTabs: (payload) => this.broadcastToTabs(payload),
|
|
388
440
|
});
|
|
441
|
+
this.rooms = new RoomSubscriptions((msg) => {
|
|
442
|
+
// Leader: send over the WS. Followers don't open a transport;
|
|
443
|
+
// the leader-side WS is the only path to the server.
|
|
444
|
+
if (!this.isMultiTabLeader) return false;
|
|
445
|
+
if (this.transport?.isOpen()) {
|
|
446
|
+
this.transport.send(msg);
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
});
|
|
389
451
|
// When multi-tab coordination is explicitly disabled, this engine
|
|
390
452
|
// is always its own sole leader — even before start(). Tests that
|
|
391
453
|
// construct an engine and call reconcile()/pull() directly without
|
|
@@ -433,12 +495,38 @@ export class SyncEngine {
|
|
|
433
495
|
const shouldPersist = this.config.persist !== false && typeof indexedDB !== "undefined";
|
|
434
496
|
if (shouldPersist) {
|
|
435
497
|
try {
|
|
436
|
-
const { IndexedDBPersistence
|
|
498
|
+
const { IndexedDBPersistence } = await import("./persistence");
|
|
437
499
|
this.persistence = new IndexedDBPersistence(this.config.appName);
|
|
438
500
|
await this.persistence.open();
|
|
439
501
|
|
|
440
|
-
//
|
|
441
|
-
|
|
502
|
+
// Warm-load entities + cursor in ONE readonly transaction so
|
|
503
|
+
// the hydrated rows and the cursor we'll advance from are a
|
|
504
|
+
// consistent snapshot. Separate reads could (in a multi-tab
|
|
505
|
+
// race) interleave a mid-load save and read (rows@C, cursor@C+1)
|
|
506
|
+
// — the pull would then skip seqs we never applied. The
|
|
507
|
+
// post-load timing log surfaces cold-IDB pages so a regression
|
|
508
|
+
// (50MB cache, slow disk) is observable.
|
|
509
|
+
const idbLoadStart =
|
|
510
|
+
typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
511
|
+
const { entities: cached, cursor: cachedCursor, hadCache } =
|
|
512
|
+
await this.persistence.loadSnapshot();
|
|
513
|
+
const idbLoadMs =
|
|
514
|
+
(typeof performance !== "undefined" ? performance.now() : Date.now()) -
|
|
515
|
+
idbLoadStart;
|
|
516
|
+
if (idbLoadMs > 100) {
|
|
517
|
+
console.warn(
|
|
518
|
+
`[persistence] cold IDB load took ${idbLoadMs.toFixed(0)}ms (${
|
|
519
|
+
Object.keys(cached).length
|
|
520
|
+
} entities)`,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
// Record whether IDB had a prior session's state. The cold-load
|
|
524
|
+
// fast-path in onConnected (skip post-pull reconcile when the
|
|
525
|
+
// pull was a full snapshot from cursor=0) is only safe when
|
|
526
|
+
// there was no cached replica to begin with — a returning user
|
|
527
|
+
// whose pull-from-cursor misses an offline server-side delete
|
|
528
|
+
// depends on that reconcile pass to catch the ghost row.
|
|
529
|
+
this._hadCachedReplica = hadCache;
|
|
442
530
|
let hydrated = false;
|
|
443
531
|
for (const [entity, rows] of Object.entries(cached)) {
|
|
444
532
|
for (const row of rows) {
|
|
@@ -459,10 +547,13 @@ export class SyncEngine {
|
|
|
459
547
|
else this.store.notify(); // notify even on empty cache so useQuery
|
|
460
548
|
// sees `isHydrated()` flip and can drop its initial loading state.
|
|
461
549
|
|
|
462
|
-
//
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
550
|
+
// Apply the cached cursor BEFORE pull so the first pull is a
|
|
551
|
+
// delta against where we left off, not a full re-snapshot.
|
|
552
|
+
// Already part of the single loadAll() tx above — assigning
|
|
553
|
+
// here can't race a concurrent save because pull/push haven't
|
|
554
|
+
// started yet (initMultiTab is still ahead).
|
|
555
|
+
if (cachedCursor) {
|
|
556
|
+
this.cursor = cachedCursor;
|
|
466
557
|
}
|
|
467
558
|
|
|
468
559
|
// Auto-save changes to IndexedDB. Returns a Promise so the async
|
|
@@ -699,14 +790,108 @@ export class SyncEngine {
|
|
|
699
790
|
}
|
|
700
791
|
}
|
|
701
792
|
},
|
|
702
|
-
onPeerLeft: (
|
|
703
|
-
// Orchestrator already scrubbed
|
|
704
|
-
// departed tab.
|
|
705
|
-
//
|
|
793
|
+
onPeerLeft: (tabId: string) => {
|
|
794
|
+
// Orchestrator already scrubbed CRDT / reactive sub state for
|
|
795
|
+
// the departed tab. Engine cleans up room forwarder sets —
|
|
796
|
+
// a follower that crashed without sending `room-sub-unregister`
|
|
797
|
+
// would otherwise keep the leader's hold (and therefore the WS
|
|
798
|
+
// sub) alive for a room nobody actually wants.
|
|
799
|
+
if (!this.isMultiTabLeader) return;
|
|
800
|
+
for (const [room, set] of [...this.roomForwarders]) {
|
|
801
|
+
if (!set.delete(tabId)) continue;
|
|
802
|
+
if (set.size === 0) {
|
|
803
|
+
this.roomForwarders.delete(room);
|
|
804
|
+
const release = this.leaderForwarderHolds.get(room);
|
|
805
|
+
if (release) {
|
|
806
|
+
this.leaderForwarderHolds.delete(room);
|
|
807
|
+
release();
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
onRoomSubRegister: (roomId: string, fromTabId: string) => {
|
|
813
|
+
let set = this.roomForwarders.get(roomId);
|
|
814
|
+
if (!set) {
|
|
815
|
+
set = new Set();
|
|
816
|
+
this.roomForwarders.set(roomId, set);
|
|
817
|
+
}
|
|
818
|
+
const wasFirst = set.size === 0;
|
|
819
|
+
set.add(fromTabId);
|
|
820
|
+
// First forwarder for this room: acquire a leader-internal
|
|
821
|
+
// hold on the registry so the WS sub stays alive as long as
|
|
822
|
+
// any tab (leader or follower) still cares. The hold is a
|
|
823
|
+
// refcount increment with a noop callback — it doesn't fire
|
|
824
|
+
// on snapshots / updates (the leader's own subscribers, if
|
|
825
|
+
// any, get the pulse; the forwarded tab gets the snapshot via
|
|
826
|
+
// the `room-fanout-*` channel). Without this hold, a leader
|
|
827
|
+
// with no local subscribers would never enter the registry
|
|
828
|
+
// and inbound `room-snapshot` for forwarded rooms would drop
|
|
829
|
+
// on the floor.
|
|
830
|
+
if (wasFirst) {
|
|
831
|
+
const noop: RoomSubscriber = () => {};
|
|
832
|
+
const release = this.rooms.register(roomId, noop);
|
|
833
|
+
this.leaderForwarderHolds.set(roomId, release);
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
onRoomSubUnregister: (roomId: string, fromTabId: string) => {
|
|
837
|
+
const set = this.roomForwarders.get(roomId);
|
|
838
|
+
if (!set) return;
|
|
839
|
+
set.delete(fromTabId);
|
|
840
|
+
if (set.size > 0) return;
|
|
841
|
+
this.roomForwarders.delete(roomId);
|
|
842
|
+
// Release the leader-internal hold IF no local subscriber on
|
|
843
|
+
// this tab cares either. The registry's refcount will then
|
|
844
|
+
// drop to zero and `room-unsubscribe` ships.
|
|
845
|
+
const release = this.leaderForwarderHolds.get(roomId);
|
|
846
|
+
if (release) {
|
|
847
|
+
this.leaderForwarderHolds.delete(roomId);
|
|
848
|
+
release();
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
onRoomFanoutSnapshot: (roomId: string, members: unknown) => {
|
|
852
|
+
if (Array.isArray(members)) {
|
|
853
|
+
this.rooms.applySnapshot(roomId, members as RoomMember[]);
|
|
854
|
+
}
|
|
855
|
+
},
|
|
856
|
+
onRoomFanoutUpdate: (
|
|
857
|
+
roomId: string,
|
|
858
|
+
action: "join" | "leave" | "presence" | "broadcast",
|
|
859
|
+
member: unknown,
|
|
860
|
+
data: unknown,
|
|
861
|
+
) => {
|
|
862
|
+
this.rooms.applyUpdate(
|
|
863
|
+
roomId,
|
|
864
|
+
action,
|
|
865
|
+
(member ?? undefined) as RoomMember | undefined,
|
|
866
|
+
data,
|
|
867
|
+
);
|
|
868
|
+
},
|
|
869
|
+
onRoomFanoutError: (roomId: string, error: unknown) => {
|
|
870
|
+
const err = (error ?? { code: "UNKNOWN" }) as RoomError;
|
|
871
|
+
this.rooms.applyError(roomId, err);
|
|
872
|
+
},
|
|
873
|
+
onReplayRoomSubs: () => {
|
|
874
|
+
// Follower path: re-broadcast `room-sub-register` for every
|
|
875
|
+
// room we still care about. New leader rebuilds its forwarder
|
|
876
|
+
// set and resends `room-subscribe` on its WS.
|
|
877
|
+
if (this.isMultiTabLeader) return;
|
|
878
|
+
for (const roomId of this.rooms.roomIds()) {
|
|
879
|
+
this.broadcastToTabs({
|
|
880
|
+
type: "room-sub-register",
|
|
881
|
+
room: roomId,
|
|
882
|
+
});
|
|
883
|
+
}
|
|
706
884
|
},
|
|
707
885
|
};
|
|
708
886
|
}
|
|
709
887
|
|
|
888
|
+
/** Leader-side hold tokens for room subs forwarded by followers. The
|
|
889
|
+
* leader subscribes via its own registry with a no-op callback so
|
|
890
|
+
* the registry's first-add gate ships `room-subscribe` exactly
|
|
891
|
+
* once; the release tokens here let us undo that hold when the
|
|
892
|
+
* last follower stops caring. */
|
|
893
|
+
private leaderForwarderHolds: Map<string, () => void> = new Map();
|
|
894
|
+
|
|
710
895
|
/** Seed serverSubs with every subscription this tab currently wants.
|
|
711
896
|
* Called from both the initial-leader path (subscribes that
|
|
712
897
|
* happened before start() took the follower branch and broadcast
|
|
@@ -951,6 +1136,14 @@ export class SyncEngine {
|
|
|
951
1136
|
private async resetReplicaInner(): Promise<void> {
|
|
952
1137
|
this.cursor = { last_seq: 0 };
|
|
953
1138
|
this.store.clearAll();
|
|
1139
|
+
// The cache is now empty. The next pull will start from 0 and
|
|
1140
|
+
// return a full snapshot — that's a true cold start, so the
|
|
1141
|
+
// onConnected fast-path may skip the post-pull reconcile. Without
|
|
1142
|
+
// this flip, a sign-out → sign-in inside the same tab would
|
|
1143
|
+
// forever re-run reconcile after every pull because
|
|
1144
|
+
// `_hadCachedReplica` was set to true at start() time and never
|
|
1145
|
+
// cleared.
|
|
1146
|
+
this._hadCachedReplica = false;
|
|
954
1147
|
if (this.persistence) {
|
|
955
1148
|
try {
|
|
956
1149
|
await this.persistence.clear();
|
|
@@ -2103,6 +2296,126 @@ export class SyncEngine {
|
|
|
2103
2296
|
this.transport?.send(msg);
|
|
2104
2297
|
}
|
|
2105
2298
|
|
|
2299
|
+
// -----------------------------------------------------------------------
|
|
2300
|
+
// Room presence subscriptions
|
|
2301
|
+
//
|
|
2302
|
+
// Replaces the SDK's per-component `setInterval(GET /api/rooms/<room>,
|
|
2303
|
+
// 5s)` polling loop. Wire shape (server v0.3.214+):
|
|
2304
|
+
//
|
|
2305
|
+
// client → server:
|
|
2306
|
+
// { type: "room-subscribe", room: "channel:foo" }
|
|
2307
|
+
// { type: "room-unsubscribe", room: "channel:foo" }
|
|
2308
|
+
// server → client:
|
|
2309
|
+
// { type: "room-snapshot", room, members: [...] }
|
|
2310
|
+
// { type: "room-update", room, action: "join"|"leave"|"presence"|"broadcast",
|
|
2311
|
+
// member?, data? }
|
|
2312
|
+
// { type: "error", code: "NOT_IN_ROOM", room } // server gate
|
|
2313
|
+
//
|
|
2314
|
+
// The actual `POST /api/rooms/join` still happens over HTTP (it carries
|
|
2315
|
+
// initial presence + identity, returns the snapshot). This API only
|
|
2316
|
+
// moves the "stay subscribed to membership deltas" loop off polling
|
|
2317
|
+
// and onto the WS push channel. setPresence + broadcast also stay on
|
|
2318
|
+
// HTTP for now — the WS-RPC envelope for those lands in v0.3.217.
|
|
2319
|
+
// -----------------------------------------------------------------------
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* Subscribe to a room's membership over WebSocket. The callback fires
|
|
2323
|
+
* whenever the room's `members` list OR `error` state changes — read
|
|
2324
|
+
* the current snapshot via `getRoomMembers(roomId)` and the latest
|
|
2325
|
+
* error via `getRoomError(roomId)` inside the callback.
|
|
2326
|
+
*
|
|
2327
|
+
* Refcounted: multiple subscribers for the same `roomId` share one
|
|
2328
|
+
* `room-subscribe` on the wire. Returns an unsubscribe function;
|
|
2329
|
+
* the last unsubscribe ships `room-unsubscribe`.
|
|
2330
|
+
*
|
|
2331
|
+
* Follower tabs forward the register / unregister over the multi-tab
|
|
2332
|
+
* channel so the leader's WS carries one subscribe per room across
|
|
2333
|
+
* the whole origin. Inbound snapshot / update / error envelopes land
|
|
2334
|
+
* on the leader's WS and the leader fans them out cross-tab so each
|
|
2335
|
+
* follower's local registry routes to its own subscribers.
|
|
2336
|
+
*
|
|
2337
|
+
* Idempotent w.r.t. wire frames: a re-subscribe with no intervening
|
|
2338
|
+
* full unsubscribe doesn't re-send `room-subscribe`. ServerSubscriptions-
|
|
2339
|
+
* style replay on reconnect is built in — the registry resends
|
|
2340
|
+
* `room-subscribe` for every active room on WS reopen.
|
|
2341
|
+
*/
|
|
2342
|
+
subscribeRoom(roomId: string, callback: RoomSubscriber): () => void {
|
|
2343
|
+
if (this.isMultiTabLeader) {
|
|
2344
|
+
// Leader path: register against the local registry which owns
|
|
2345
|
+
// the wire. If a follower had already forwarded this room, the
|
|
2346
|
+
// WS sub is already alive — `register()` short-circuits the
|
|
2347
|
+
// re-send via the registry's first-add gate.
|
|
2348
|
+
return this.rooms.register(roomId, callback);
|
|
2349
|
+
}
|
|
2350
|
+
// Follower path: register locally for callback routing, then ask
|
|
2351
|
+
// the leader to subscribe on our behalf (only on first local
|
|
2352
|
+
// subscriber for the room — multiple mounts in the same follower
|
|
2353
|
+
// tab share one `room-sub-register`, mirroring the leader-side
|
|
2354
|
+
// ServerSubscriptions first-add gate). The leader echoes snapshots
|
|
2355
|
+
// / updates / errors back over the broadcast channel and our local
|
|
2356
|
+
// registry's notify() fires our callback.
|
|
2357
|
+
const hadRoomBefore = this.rooms.has(roomId);
|
|
2358
|
+
const unsubscribe = this.rooms.register(roomId, callback);
|
|
2359
|
+
if (!hadRoomBefore) {
|
|
2360
|
+
this.broadcastToTabs({
|
|
2361
|
+
type: "room-sub-register",
|
|
2362
|
+
room: roomId,
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
return () => {
|
|
2366
|
+
unsubscribe();
|
|
2367
|
+
// If this was the LAST subscriber on this tab for the room,
|
|
2368
|
+
// tell the leader so it can drop us from the forwarder set.
|
|
2369
|
+
if (!this.rooms.has(roomId)) {
|
|
2370
|
+
this.broadcastToTabs({
|
|
2371
|
+
type: "room-sub-unregister",
|
|
2372
|
+
room: roomId,
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
/** Force-unsubscribe every local subscriber of a room and ship a
|
|
2379
|
+
* `room-unsubscribe`. Used by the `useRoom` hook's manual `leave()`
|
|
2380
|
+
* action so a deliberate exit propagates to the server immediately. */
|
|
2381
|
+
unsubscribeRoom(roomId: string): void {
|
|
2382
|
+
this.rooms.unregisterRoom(roomId);
|
|
2383
|
+
if (!this.isMultiTabLeader) {
|
|
2384
|
+
this.broadcastToTabs({
|
|
2385
|
+
type: "room-sub-unregister",
|
|
2386
|
+
room: roomId,
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/** Read the current cached members snapshot for `roomId`. Returns
|
|
2392
|
+
* `null` when no snapshot has landed yet (distinct from `[]` for
|
|
2393
|
+
* an empty room). */
|
|
2394
|
+
getRoomMembers(roomId: string): RoomMember[] | null {
|
|
2395
|
+
return this.rooms?.members(roomId) ?? null;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
/** Read the latest error for `roomId` (e.g. NOT_IN_ROOM). null when
|
|
2399
|
+
* none. */
|
|
2400
|
+
getRoomError(roomId: string): RoomError | null {
|
|
2401
|
+
return this.rooms?.error(roomId) ?? null;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
/** Active transport kind. Used by the `useRoom` hook to decide
|
|
2405
|
+
* between WS push and HTTP polling fallback — only the WS transport
|
|
2406
|
+
* supports the room-subscribe push protocol; SSE and polling fall
|
|
2407
|
+
* back to the legacy 5s GET /api/rooms/<room>. */
|
|
2408
|
+
getActiveTransportType(): TransportType {
|
|
2409
|
+
return this.transportKind();
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/** True when the active transport is a WebSocket AND the socket is
|
|
2413
|
+
* currently open. The `useRoom` hook gates its WS-push path on this
|
|
2414
|
+
* — when false, fall back to polling. */
|
|
2415
|
+
isWebSocketConnected(): boolean {
|
|
2416
|
+
return this.transportKind() === "websocket" && this.connected;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2106
2419
|
/** Resolved transport kind, with the websocket default applied. */
|
|
2107
2420
|
private transportKind(): TransportKind {
|
|
2108
2421
|
return this.config.transport ?? "websocket";
|
|
@@ -2133,6 +2446,12 @@ export class SyncEngine {
|
|
|
2133
2446
|
// so without this resync the subscriber's first event would
|
|
2134
2447
|
// never arrive.
|
|
2135
2448
|
this.serverSubs.replay();
|
|
2449
|
+
// Room subscriptions live in their own registry (not under
|
|
2450
|
+
// serverSubs) because they carry per-room state (members
|
|
2451
|
+
// snapshot, error). Replay them on the same beat so the
|
|
2452
|
+
// server starts pushing room-snapshot/room-update again for
|
|
2453
|
+
// every room the user is still subscribed to.
|
|
2454
|
+
this.rooms.replay();
|
|
2136
2455
|
// Pull-on-open catches every event broadcast in the gap
|
|
2137
2456
|
// between the prior pull() returning and the socket actually
|
|
2138
2457
|
// opening. Reconcile fires after the pull since pull is the
|
|
@@ -2147,10 +2466,20 @@ export class SyncEngine {
|
|
|
2147
2466
|
// reconnect-after-disconnect paths invoke reconcile() directly
|
|
2148
2467
|
// (not gated by this flag) so the safety net still triggers.
|
|
2149
2468
|
void this.pull().then(() => {
|
|
2150
|
-
|
|
2469
|
+
// Cold-load fast-path: skip reconcile only when this WAS a
|
|
2470
|
+
// true cold start (no IDB cache → the pull-from-0 returned
|
|
2471
|
+
// every visible row, reconcile would refetch the same set).
|
|
2472
|
+
// A returning user whose pull happened to start from 0
|
|
2473
|
+
// (cursor rolled back, partial cache wipe) MUST still run
|
|
2474
|
+
// reconcile to catch rows deleted on the server while the
|
|
2475
|
+
// tab was closed — the snapshot path only returns currently-
|
|
2476
|
+
// visible rows, never tombstones, so ghost rows on the
|
|
2477
|
+
// cached side persist without the reconcile pass.
|
|
2478
|
+
if (this.lastPullStartedFromZero && !this._hadCachedReplica) {
|
|
2151
2479
|
this.lastPullStartedFromZero = false;
|
|
2152
2480
|
return;
|
|
2153
2481
|
}
|
|
2482
|
+
this.lastPullStartedFromZero = false;
|
|
2154
2483
|
return this.reconcile();
|
|
2155
2484
|
});
|
|
2156
2485
|
},
|
|
@@ -2221,6 +2550,77 @@ export class SyncEngine {
|
|
|
2221
2550
|
return;
|
|
2222
2551
|
}
|
|
2223
2552
|
|
|
2553
|
+
// Room push: snapshot / update / error envelopes from the WS.
|
|
2554
|
+
// Leader receives them on its socket and routes through the local
|
|
2555
|
+
// registry; if any follower forwarded a sub for the room, fan the
|
|
2556
|
+
// envelope out cross-tab so follower-local registries pick up the
|
|
2557
|
+
// same state. The followers receive `room-fanout-*` envelopes
|
|
2558
|
+
// through the orchestrator and apply them to their own registries.
|
|
2559
|
+
if (msg.type === "room-snapshot" && typeof msg.room === "string") {
|
|
2560
|
+
const room = msg.room;
|
|
2561
|
+
const members = Array.isArray(msg.members)
|
|
2562
|
+
? (msg.members as RoomMember[])
|
|
2563
|
+
: [];
|
|
2564
|
+
this.rooms.applySnapshot(room, members);
|
|
2565
|
+
if ((this.roomForwarders.get(room)?.size ?? 0) > 0) {
|
|
2566
|
+
this.broadcastToTabs({
|
|
2567
|
+
type: "room-fanout-snapshot",
|
|
2568
|
+
room,
|
|
2569
|
+
members,
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
if (msg.type === "room-update" && typeof msg.room === "string") {
|
|
2575
|
+
const room = msg.room;
|
|
2576
|
+
const action = msg.action as
|
|
2577
|
+
| "join"
|
|
2578
|
+
| "leave"
|
|
2579
|
+
| "presence"
|
|
2580
|
+
| "broadcast"
|
|
2581
|
+
| undefined;
|
|
2582
|
+
if (!action) return;
|
|
2583
|
+
const member = (msg.member as RoomMember | undefined) ?? undefined;
|
|
2584
|
+
const data = msg.data;
|
|
2585
|
+
this.rooms.applyUpdate(room, action, member, data);
|
|
2586
|
+
if ((this.roomForwarders.get(room)?.size ?? 0) > 0) {
|
|
2587
|
+
this.broadcastToTabs({
|
|
2588
|
+
type: "room-fanout-update",
|
|
2589
|
+
room,
|
|
2590
|
+
action,
|
|
2591
|
+
member,
|
|
2592
|
+
data,
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
if (
|
|
2598
|
+
msg.type === "error" &&
|
|
2599
|
+
typeof msg.room === "string" &&
|
|
2600
|
+
typeof msg.code === "string"
|
|
2601
|
+
) {
|
|
2602
|
+
// Server-side gate failure on a room subscribe. Surface to the
|
|
2603
|
+
// hook via the registry so the React side can render an error
|
|
2604
|
+
// state. Server only emits this in the `room-subscribe` reject
|
|
2605
|
+
// path right now, but the dispatch is keyed by `code` so future
|
|
2606
|
+
// error codes (rate-limited, room-full, etc.) route here too.
|
|
2607
|
+
const room = msg.room;
|
|
2608
|
+
const code = msg.code === "NOT_IN_ROOM" ? "NOT_IN_ROOM" : "UNKNOWN";
|
|
2609
|
+
const error: RoomError = {
|
|
2610
|
+
code,
|
|
2611
|
+
message: typeof msg.message === "string" ? msg.message : undefined,
|
|
2612
|
+
};
|
|
2613
|
+
this.rooms.applyError(room, error);
|
|
2614
|
+
if ((this.roomForwarders.get(room)?.size ?? 0) > 0) {
|
|
2615
|
+
this.broadcastToTabs({
|
|
2616
|
+
type: "room-fanout-error",
|
|
2617
|
+
room,
|
|
2618
|
+
error,
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
return;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2224
2624
|
// Reactive query push: the server-side ReactiveRegistry re-ran a
|
|
2225
2625
|
// subscribed handler and the result hash changed. Route to the
|
|
2226
2626
|
// local handler if we own the subscription AND forward to follower
|
|
@@ -87,6 +87,31 @@ export interface MultiTabOrchestratorHooks {
|
|
|
87
87
|
/** A peer tab disappeared (broker observed `bye`). Engine and
|
|
88
88
|
* SubscriptionCoordinator both clean up state for the departed tab. */
|
|
89
89
|
onPeerLeft(tabId: string): void;
|
|
90
|
+
|
|
91
|
+
/** Follower → leader: a follower wants to subscribe to a room.
|
|
92
|
+
* Leader-only. Engine increments the per-room forwarder set and
|
|
93
|
+
* sends `room-subscribe` to the server if no one else owned it. */
|
|
94
|
+
onRoomSubRegister?(roomId: string, fromTabId: string): void;
|
|
95
|
+
/** Follower → leader: a follower's last room subscriber unmounted.
|
|
96
|
+
* Leader-only. Engine decrements the forwarder set and sends
|
|
97
|
+
* `room-unsubscribe` when both the local refcount and the forwarder
|
|
98
|
+
* set are empty. */
|
|
99
|
+
onRoomSubUnregister?(roomId: string, fromTabId: string): void;
|
|
100
|
+
/** Leader → followers: a room-snapshot landed on the WS. Followers
|
|
101
|
+
* apply it to their local room registry so their subscribers fire. */
|
|
102
|
+
onRoomFanoutSnapshot?(roomId: string, members: unknown): void;
|
|
103
|
+
/** Leader → followers: a room-update landed. */
|
|
104
|
+
onRoomFanoutUpdate?(
|
|
105
|
+
roomId: string,
|
|
106
|
+
action: "join" | "leave" | "presence" | "broadcast",
|
|
107
|
+
member: unknown,
|
|
108
|
+
data: unknown,
|
|
109
|
+
): void;
|
|
110
|
+
/** Leader → followers: the server rejected a room-subscribe. */
|
|
111
|
+
onRoomFanoutError?(roomId: string, error: unknown): void;
|
|
112
|
+
/** New leader → followers: re-forward your locally-wanted room subs.
|
|
113
|
+
* Triggered alongside CRDT/reactive replay after a leader change. */
|
|
114
|
+
onReplayRoomSubs?(): void;
|
|
90
115
|
}
|
|
91
116
|
|
|
92
117
|
export interface MultiTabOrchestratorConfig {
|
|
@@ -359,6 +384,61 @@ export class MultiTabOrchestrator {
|
|
|
359
384
|
// already has the bundle.
|
|
360
385
|
if (this._isLeader) return;
|
|
361
386
|
this.subscriptions.replayForwardedSubs();
|
|
387
|
+
// Followers also re-forward their active room subscriptions
|
|
388
|
+
// so the new leader can rebuild its forwarder sets and resend
|
|
389
|
+
// `room-subscribe` on the WS.
|
|
390
|
+
this.hooks.onReplayRoomSubs?.();
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
case "room-sub-register": {
|
|
394
|
+
// Follower → leader. Leaders only — a follower receiving this
|
|
395
|
+
// (own broadcast echo, or stale leader transition) ignores.
|
|
396
|
+
if (!this._isLeader) return;
|
|
397
|
+
const room = msg.room as string | undefined;
|
|
398
|
+
if (typeof room === "string") {
|
|
399
|
+
this.hooks.onRoomSubRegister?.(room, fromTabId);
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
case "room-sub-unregister": {
|
|
404
|
+
if (!this._isLeader) return;
|
|
405
|
+
const room = msg.room as string | undefined;
|
|
406
|
+
if (typeof room === "string") {
|
|
407
|
+
this.hooks.onRoomSubUnregister?.(room, fromTabId);
|
|
408
|
+
}
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case "room-fanout-snapshot": {
|
|
412
|
+
// Leader → follower. Leader ignores its own echo.
|
|
413
|
+
if (this._isLeader) return;
|
|
414
|
+
const room = msg.room as string | undefined;
|
|
415
|
+
const members = msg.members;
|
|
416
|
+
if (typeof room === "string") {
|
|
417
|
+
this.hooks.onRoomFanoutSnapshot?.(room, members);
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
case "room-fanout-update": {
|
|
422
|
+
if (this._isLeader) return;
|
|
423
|
+
const room = msg.room as string | undefined;
|
|
424
|
+
const action = msg.action as
|
|
425
|
+
| "join"
|
|
426
|
+
| "leave"
|
|
427
|
+
| "presence"
|
|
428
|
+
| "broadcast"
|
|
429
|
+
| undefined;
|
|
430
|
+
if (typeof room === "string" && action) {
|
|
431
|
+
this.hooks.onRoomFanoutUpdate?.(room, action, msg.member, msg.data);
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case "room-fanout-error": {
|
|
436
|
+
if (this._isLeader) return;
|
|
437
|
+
const room = msg.room as string | undefined;
|
|
438
|
+
const error = msg.error;
|
|
439
|
+
if (typeof room === "string") {
|
|
440
|
+
this.hooks.onRoomFanoutError?.(room, error);
|
|
441
|
+
}
|
|
362
442
|
break;
|
|
363
443
|
}
|
|
364
444
|
}
|
package/src/persistence.ts
CHANGED
|
@@ -162,6 +162,61 @@ export class IndexedDBPersistence {
|
|
|
162
162
|
});
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Atomic warm-load: returns entities + cursor in a single IDB
|
|
167
|
+
* read transaction. Used by `SyncEngine.start()` to hydrate the
|
|
168
|
+
* in-memory replica BEFORE the network pull resolves so React
|
|
169
|
+
* hooks see real data on first render (no empty-then-populated
|
|
170
|
+
* flash on returning visits).
|
|
171
|
+
*
|
|
172
|
+
* `hadCache` is true when at least one row OR a saved cursor
|
|
173
|
+
* was found. The engine uses it to distinguish "true cold start
|
|
174
|
+
* — pull-from-0 IS a full snapshot, skip the post-snapshot
|
|
175
|
+
* reconcile" from "returning user with cached state — pull-from-
|
|
176
|
+
* cursor may miss server-side deletes that happened offline, the
|
|
177
|
+
* onConnected reconcile MUST run". Without that distinction, a
|
|
178
|
+
* returning user whose cursor somehow rolled back to 0 (rare:
|
|
179
|
+
* IDB partial corruption, cleared-by-mistake) would end up with
|
|
180
|
+
* ghost rows that survive forever.
|
|
181
|
+
*
|
|
182
|
+
* Single readonly tx is intentional — two separate reads could
|
|
183
|
+
* race a mid-load saveCursor/saveRow from another tab's apply
|
|
184
|
+
* pipeline and read an inconsistent (cursor C', rows for cursor C)
|
|
185
|
+
* pair. One tx guarantees a consistent snapshot.
|
|
186
|
+
*/
|
|
187
|
+
async loadSnapshot(): Promise<{
|
|
188
|
+
entities: Record<string, Row[]>;
|
|
189
|
+
cursor: SyncCursor | null;
|
|
190
|
+
hadCache: boolean;
|
|
191
|
+
}> {
|
|
192
|
+
if (!this.db) return { entities: {}, cursor: null, hadCache: false };
|
|
193
|
+
const tx = this.db.transaction([STORE_NAME, CURSOR_STORE], "readonly");
|
|
194
|
+
const entitiesReq = tx.objectStore(STORE_NAME).getAll();
|
|
195
|
+
const cursorReq = tx.objectStore(CURSOR_STORE).get("cursor");
|
|
196
|
+
return new Promise((resolve) => {
|
|
197
|
+
tx.oncomplete = () => {
|
|
198
|
+
const entities: Record<string, Row[]> = {};
|
|
199
|
+
for (const item of (entitiesReq.result ?? []) as {
|
|
200
|
+
entity: string;
|
|
201
|
+
id: string;
|
|
202
|
+
data: Row;
|
|
203
|
+
}[]) {
|
|
204
|
+
if (!entities[item.entity]) entities[item.entity] = [];
|
|
205
|
+
entities[item.entity].push({ id: item.id, ...item.data });
|
|
206
|
+
}
|
|
207
|
+
const cursorRec = cursorReq.result as { last_seq?: number } | undefined;
|
|
208
|
+
const cursor: SyncCursor | null = cursorRec
|
|
209
|
+
? { last_seq: cursorRec.last_seq ?? 0 }
|
|
210
|
+
: null;
|
|
211
|
+
const hadCache =
|
|
212
|
+
Object.keys(entities).length > 0 || cursor !== null;
|
|
213
|
+
resolve({ entities, cursor, hadCache });
|
|
214
|
+
};
|
|
215
|
+
tx.onerror = () => resolve({ entities: {}, cursor: null, hadCache: false });
|
|
216
|
+
tx.onabort = () => resolve({ entities: {}, cursor: null, hadCache: false });
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
165
220
|
/** Save the sync cursor. */
|
|
166
221
|
async saveCursor(cursor: SyncCursor): Promise<void> {
|
|
167
222
|
if (!this.db) return;
|