@pylonsync/sync 0.3.310 → 0.3.312
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/dist/index.d.ts +39 -0
- package/dist/subscription-coordinator.d.ts +7 -0
- package/package.json +1 -1
- package/src/crdt-follower-catchup.test.ts +107 -0
- package/src/index.ts +124 -6
- package/src/room-subscriptions.test.ts +20 -0
- package/src/room-subscriptions.ts +11 -1
- package/src/subscription-coordinator.ts +13 -0
package/dist/index.d.ts
CHANGED
|
@@ -287,6 +287,18 @@ export declare class SyncEngine {
|
|
|
287
287
|
* is the dedup key.
|
|
288
288
|
*/
|
|
289
289
|
private binaryHandlers;
|
|
290
|
+
/**
|
|
291
|
+
* Latest server CRDT SNAPSHOT frame per `entity|rowId`, kept so a
|
|
292
|
+
* follower tab that registers interest in an ALREADY-subscribed row
|
|
293
|
+
* gets state immediately. The server sends its catch-up snapshot only
|
|
294
|
+
* when a fresh `crdt-subscribe` goes over the WS — when the leader
|
|
295
|
+
* (or another follower) already holds the subscription, no wire
|
|
296
|
+
* traffic happens and, pre-cache, the new tab's LoroDoc stayed empty
|
|
297
|
+
* until the next live edit. Server broadcasts are always FULL merged
|
|
298
|
+
* snapshots (CRDT_FRAME_SNAPSHOT), so one frame per row is complete
|
|
299
|
+
* state. Bounded FIFO — see LAST_CRDT_FRAMES_MAX.
|
|
300
|
+
*/
|
|
301
|
+
private lastCrdtFrames;
|
|
290
302
|
/**
|
|
291
303
|
* Server-side ephemeral subscriptions (CRDT row subs, reactive query
|
|
292
304
|
* subs, future kinds). Owns the WS replay bookkeeping — each kind
|
|
@@ -951,6 +963,9 @@ export declare class SyncEngine {
|
|
|
951
963
|
* follower tab forwarded a CRDT sub, mirror over the multi-tab
|
|
952
964
|
* channel so followers see Loro updates too. */
|
|
953
965
|
private dispatchBinaryFrame;
|
|
966
|
+
/** Drop the cached snapshot for a revoked row so it can't be
|
|
967
|
+
* replayed to a late-joining tab after policy said no. */
|
|
968
|
+
private evictCrdtFrame;
|
|
954
969
|
private request;
|
|
955
970
|
}
|
|
956
971
|
/** Data shape for hydrating the client from server-rendered content. */
|
|
@@ -969,6 +984,30 @@ export interface HydrationData {
|
|
|
969
984
|
export declare function getServerData(baseUrl: string, entities: string[], options?: {
|
|
970
985
|
token?: string;
|
|
971
986
|
}): Promise<HydrationData>;
|
|
987
|
+
/**
|
|
988
|
+
* Stable equality check for reconciler diffs. Keys are sorted so
|
|
989
|
+
* `{a:1,b:2}` and `{b:2,a:1}` compare equal — without that, every
|
|
990
|
+
* reconcile pass would think every row had changed (insertion order
|
|
991
|
+
* varies by mutation path on the server). Recursive on objects only;
|
|
992
|
+
* arrays and primitives use their natural shape.
|
|
993
|
+
*/
|
|
994
|
+
/** Cap on cached per-row CRDT snapshots (see lastCrdtFrames). 64 rows of
|
|
995
|
+
* collaborative state is far beyond what one browser session edits at
|
|
996
|
+
* once; FIFO eviction keeps a long-lived leader tab bounded. */
|
|
997
|
+
export declare const LAST_CRDT_FRAMES_MAX = 64;
|
|
998
|
+
/**
|
|
999
|
+
* Header-only peek at a binary CRDT frame: returns `"entity|rowId"` for
|
|
1000
|
+
* a SNAPSHOT frame, null for anything else (updates, foreign binary,
|
|
1001
|
+
* truncated bytes). Mirrors the layout of
|
|
1002
|
+
* `crates/router::encode_crdt_frame` / `@pylonsync/loro`'s wire.ts:
|
|
1003
|
+
*
|
|
1004
|
+
* [type: u8] [entity_len: u16 BE] [entity utf8]
|
|
1005
|
+
* [row_id_len: u16 BE] [row_id utf8] [payload]
|
|
1006
|
+
*
|
|
1007
|
+
* The engine still treats the PAYLOAD as opaque — this reads only the
|
|
1008
|
+
* routing header so the follower-catch-up cache can be keyed per row.
|
|
1009
|
+
*/
|
|
1010
|
+
export declare function crdtFrameKey(bytes: Uint8Array): string | null;
|
|
972
1011
|
/**
|
|
973
1012
|
* Create a sync engine connected to the pylon backend.
|
|
974
1013
|
*
|
|
@@ -5,6 +5,13 @@ import type { ReactiveMessage } from "./types";
|
|
|
5
5
|
export interface SubscriptionCoordinatorContext {
|
|
6
6
|
isLeader(): boolean;
|
|
7
7
|
broadcastToTabs(payload: unknown): void;
|
|
8
|
+
/** Leader-side replay of the cached CRDT snapshot for a row (see
|
|
9
|
+
* SyncEngine.lastCrdtFrames). Called when a follower registers
|
|
10
|
+
* interest in a row whose WS subscription is ALREADY alive — the
|
|
11
|
+
* server only ships its catch-up snapshot on a fresh
|
|
12
|
+
* `crdt-subscribe`, so without the replay the new tab's doc stays
|
|
13
|
+
* empty until the next live edit. */
|
|
14
|
+
replayCrdtFrame?(entity: string, rowId: string): void;
|
|
8
15
|
}
|
|
9
16
|
export declare class SubscriptionCoordinator {
|
|
10
17
|
private readonly serverSubs;
|
package/package.json
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Follower catch-up for CRDT rows. The server ships its catch-up
|
|
2
|
+
// snapshot only on a FRESH `crdt-subscribe`; when a follower tab
|
|
3
|
+
// registers interest in a row the leader (or another follower) already
|
|
4
|
+
// subscribed, no wire traffic happens — pre-fix the new tab's LoroDoc
|
|
5
|
+
// stayed empty until the next live edit ("open the doc in a second tab
|
|
6
|
+
// and the content doesn't render"). The leader now caches the latest
|
|
7
|
+
// snapshot frame per row and replays it over the tab channel.
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { SubscriptionCoordinator, crdtKey } from "./subscription-coordinator";
|
|
10
|
+
import { ServerSubscriptions } from "./server-subscriptions";
|
|
11
|
+
import { crdtFrameKey } from "./index";
|
|
12
|
+
|
|
13
|
+
function encodeSnapshotFrame(
|
|
14
|
+
type: number,
|
|
15
|
+
entity: string,
|
|
16
|
+
rowId: string,
|
|
17
|
+
payload: Uint8Array,
|
|
18
|
+
): Uint8Array {
|
|
19
|
+
const enc = new TextEncoder();
|
|
20
|
+
const e = enc.encode(entity);
|
|
21
|
+
const r = enc.encode(rowId);
|
|
22
|
+
const out = new Uint8Array(1 + 2 + e.length + 2 + r.length + payload.length);
|
|
23
|
+
const view = new DataView(out.buffer);
|
|
24
|
+
out[0] = type;
|
|
25
|
+
view.setUint16(1, e.length, false);
|
|
26
|
+
out.set(e, 3);
|
|
27
|
+
view.setUint16(3 + e.length, r.length, false);
|
|
28
|
+
out.set(r, 5 + e.length);
|
|
29
|
+
out.set(payload, 5 + e.length + r.length);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("crdtFrameKey", () => {
|
|
34
|
+
test("keys a snapshot frame by entity|rowId", () => {
|
|
35
|
+
const frame = encodeSnapshotFrame(0x10, "Doc", "row1", new Uint8Array(3));
|
|
36
|
+
expect(crdtFrameKey(frame)).toBe("Doc|row1");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("ignores update frames and garbage", () => {
|
|
40
|
+
const update = encodeSnapshotFrame(0x11, "Doc", "row1", new Uint8Array(3));
|
|
41
|
+
expect(crdtFrameKey(update)).toBeNull();
|
|
42
|
+
expect(crdtFrameKey(new Uint8Array([0x10, 0, 9]))).toBeNull();
|
|
43
|
+
expect(crdtFrameKey(new Uint8Array(0))).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("forwarded register replay", () => {
|
|
48
|
+
function harness() {
|
|
49
|
+
const sent: unknown[] = [];
|
|
50
|
+
const replayed: Array<[string, string]> = [];
|
|
51
|
+
const serverSubs = new ServerSubscriptions((msg) => {
|
|
52
|
+
sent.push(msg);
|
|
53
|
+
return true;
|
|
54
|
+
});
|
|
55
|
+
const coordinator = new SubscriptionCoordinator(serverSubs, {
|
|
56
|
+
isLeader: () => true,
|
|
57
|
+
broadcastToTabs: () => {},
|
|
58
|
+
replayCrdtFrame: (entity, rowId) => {
|
|
59
|
+
replayed.push([entity, rowId]);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
return { coordinator, sent, replayed };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("already-alive subscription replays the cached snapshot to followers", () => {
|
|
66
|
+
const { coordinator, replayed } = harness();
|
|
67
|
+
// Leader's own component holds the row → WS sub is alive.
|
|
68
|
+
coordinator.subscribeCrdt("Doc", "row1");
|
|
69
|
+
// A second tab opens the same doc and forwards its interest. No new
|
|
70
|
+
// crdt-subscribe goes out (the sub exists), so the ONLY way this
|
|
71
|
+
// tab gets state is the replay.
|
|
72
|
+
coordinator.handleForwardedRegister(
|
|
73
|
+
{ kind: "crdt", key: crdtKey("Doc", "row1"), entity: "Doc", rowId: "row1" },
|
|
74
|
+
"tab-2",
|
|
75
|
+
);
|
|
76
|
+
expect(replayed).toEqual([["Doc", "row1"]]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("fresh subscription does NOT replay — the server catch-up covers it", () => {
|
|
80
|
+
const { coordinator, replayed, sent } = harness();
|
|
81
|
+
coordinator.handleForwardedRegister(
|
|
82
|
+
{ kind: "crdt", key: crdtKey("Doc", "row9"), entity: "Doc", rowId: "row9" },
|
|
83
|
+
"tab-2",
|
|
84
|
+
);
|
|
85
|
+
// First interest in the row → real crdt-subscribe goes out; the
|
|
86
|
+
// server's own catch-up snapshot will be fanned to tabs.
|
|
87
|
+
expect(replayed).toEqual([]);
|
|
88
|
+
expect(
|
|
89
|
+
sent.some(
|
|
90
|
+
(m) => (m as { type?: string }).type === "crdt-subscribe",
|
|
91
|
+
),
|
|
92
|
+
).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("second follower for the same row also gets a replay", () => {
|
|
96
|
+
const { coordinator, replayed } = harness();
|
|
97
|
+
coordinator.handleForwardedRegister(
|
|
98
|
+
{ kind: "crdt", key: crdtKey("Doc", "row1"), entity: "Doc", rowId: "row1" },
|
|
99
|
+
"tab-2",
|
|
100
|
+
);
|
|
101
|
+
coordinator.handleForwardedRegister(
|
|
102
|
+
{ kind: "crdt", key: crdtKey("Doc", "row1"), entity: "Doc", rowId: "row1" },
|
|
103
|
+
"tab-3",
|
|
104
|
+
);
|
|
105
|
+
expect(replayed).toEqual([["Doc", "row1"]]);
|
|
106
|
+
});
|
|
107
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -417,6 +417,19 @@ export class SyncEngine {
|
|
|
417
417
|
*/
|
|
418
418
|
private binaryHandlers: Set<(bytes: Uint8Array) => void> = new Set();
|
|
419
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Latest server CRDT SNAPSHOT frame per `entity|rowId`, kept so a
|
|
422
|
+
* follower tab that registers interest in an ALREADY-subscribed row
|
|
423
|
+
* gets state immediately. The server sends its catch-up snapshot only
|
|
424
|
+
* when a fresh `crdt-subscribe` goes over the WS — when the leader
|
|
425
|
+
* (or another follower) already holds the subscription, no wire
|
|
426
|
+
* traffic happens and, pre-cache, the new tab's LoroDoc stayed empty
|
|
427
|
+
* until the next live edit. Server broadcasts are always FULL merged
|
|
428
|
+
* snapshots (CRDT_FRAME_SNAPSHOT), so one frame per row is complete
|
|
429
|
+
* state. Bounded FIFO — see LAST_CRDT_FRAMES_MAX.
|
|
430
|
+
*/
|
|
431
|
+
private lastCrdtFrames: Map<string, Uint8Array> = new Map();
|
|
432
|
+
|
|
420
433
|
/**
|
|
421
434
|
* Server-side ephemeral subscriptions (CRDT row subs, reactive query
|
|
422
435
|
* subs, future kinds). Owns the WS replay bookkeeping — each kind
|
|
@@ -534,6 +547,14 @@ export class SyncEngine {
|
|
|
534
547
|
this.subscriptions = new SubscriptionCoordinator(this.serverSubs, {
|
|
535
548
|
isLeader: () => this.isMultiTabLeader,
|
|
536
549
|
broadcastToTabs: (payload) => this.broadcastToTabs(payload),
|
|
550
|
+
// Follower registered interest in a row whose WS subscription is
|
|
551
|
+
// already alive — no server catch-up will come, so replay the
|
|
552
|
+
// cached snapshot over the tab channel. Loro imports are
|
|
553
|
+
// idempotent, so tabs that already have the state are unaffected.
|
|
554
|
+
replayCrdtFrame: (entity, rowId) => {
|
|
555
|
+
const cached = this.lastCrdtFrames.get(`${entity}|${rowId}`);
|
|
556
|
+
if (cached) this.broadcastToTabs({ type: "binary", bytes: cached });
|
|
557
|
+
},
|
|
537
558
|
});
|
|
538
559
|
this.rooms = new RoomSubscriptions((msg) => {
|
|
539
560
|
// Leader: send over the WS. Followers don't open a transport;
|
|
@@ -751,16 +772,38 @@ export class SyncEngine {
|
|
|
751
772
|
|
|
752
773
|
if (!this.isMultiTabLeader) {
|
|
753
774
|
// Follower path: rely on the leader's broadcasts for session +
|
|
754
|
-
// applied changes.
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
// the
|
|
758
|
-
//
|
|
759
|
-
// unhandled rejection.
|
|
775
|
+
// applied changes. The broker is wired to forward inbound
|
|
776
|
+
// messages into the engine. The sessionPromise we kicked off
|
|
777
|
+
// above resolves into the void; the leader's broadcast will
|
|
778
|
+
// deliver the authoritative view. Swallow any pending error so
|
|
779
|
+
// it doesn't surface as an unhandled rejection.
|
|
760
780
|
void sessionPromise.then(
|
|
761
781
|
() => {},
|
|
762
782
|
() => {},
|
|
763
783
|
);
|
|
784
|
+
// Replay every subscription made BEFORE the election settled.
|
|
785
|
+
// Those calls took the follower branch and broadcast to a
|
|
786
|
+
// not-yet-constructed orchestrator — a silent no-op — so the
|
|
787
|
+
// leader never learned this tab wants anything. The leader-side
|
|
788
|
+
// mirror of this is seedServerSubsFromLocalInterest below; the
|
|
789
|
+
// follower side was missing, which left a second tab opening a
|
|
790
|
+
// CRDT doc the leader already held rendering EMPTY forever (no
|
|
791
|
+
// crdt-subscribe ever reached the leader, so neither the server
|
|
792
|
+
// catch-up nor the cached-snapshot replay fired). Rooms,
|
|
793
|
+
// observed entities, and hydrated offline mutations strand the
|
|
794
|
+
// same way — replay the full bundle, mirroring what
|
|
795
|
+
// request-sub-replay does after a leader flip.
|
|
796
|
+
this.subscriptions.replayForwardedSubs();
|
|
797
|
+
for (const roomId of this.rooms.roomIds()) {
|
|
798
|
+
this.broadcastToTabs({ type: "room-sub-register", room: roomId });
|
|
799
|
+
}
|
|
800
|
+
for (const entity of this.observedEntities) {
|
|
801
|
+
this.broadcastToTabs({ type: "entity-observe", entity });
|
|
802
|
+
}
|
|
803
|
+
const pendingOps = this.mutations.pending();
|
|
804
|
+
if (pendingOps.length > 0) {
|
|
805
|
+
this.broadcastToTabs({ type: "mutations", ops: pendingOps });
|
|
806
|
+
}
|
|
764
807
|
return;
|
|
765
808
|
}
|
|
766
809
|
|
|
@@ -1169,6 +1212,28 @@ export class SyncEngine {
|
|
|
1169
1212
|
this.transport.stop();
|
|
1170
1213
|
this.transport = null;
|
|
1171
1214
|
}
|
|
1215
|
+
// Re-forward everything this tab wanted while it still believed it
|
|
1216
|
+
// was the leader. Before the election settles every tab defaults to
|
|
1217
|
+
// leader, so components that mounted in that window registered
|
|
1218
|
+
// their subscriptions against OUR transport — which we just
|
|
1219
|
+
// stopped. Without the re-forward the real leader never learns
|
|
1220
|
+
// about them: a second tab opening a CRDT doc the leader already
|
|
1221
|
+
// holds rendered EMPTY forever (no crdt-subscribe reached the
|
|
1222
|
+
// leader, so neither the server catch-up nor the leader's cached-
|
|
1223
|
+
// snapshot replay ever fired). Same stranding applied to rooms
|
|
1224
|
+
// (presence), observed entities, and pending mutations — mirror
|
|
1225
|
+
// the request-sub-replay bundle.
|
|
1226
|
+
this.subscriptions.replayForwardedSubs();
|
|
1227
|
+
for (const roomId of this.rooms.roomIds()) {
|
|
1228
|
+
this.broadcastToTabs({ type: "room-sub-register", room: roomId });
|
|
1229
|
+
}
|
|
1230
|
+
for (const entity of this.observedEntities) {
|
|
1231
|
+
this.broadcastToTabs({ type: "entity-observe", entity });
|
|
1232
|
+
}
|
|
1233
|
+
const pending = this.mutations.pending();
|
|
1234
|
+
if (pending.length > 0) {
|
|
1235
|
+
this.broadcastToTabs({ type: "mutations", ops: pending });
|
|
1236
|
+
}
|
|
1172
1237
|
}
|
|
1173
1238
|
|
|
1174
1239
|
/** Broadcast a payload to other tabs in this origin. Delegates to
|
|
@@ -2316,6 +2381,7 @@ export class SyncEngine {
|
|
|
2316
2381
|
}
|
|
2317
2382
|
this.store.notify();
|
|
2318
2383
|
}
|
|
2384
|
+
this.evictCrdtFrame(entity, rowId);
|
|
2319
2385
|
for (const listener of this.rowEvictionListeners) {
|
|
2320
2386
|
listener(entity, rowId);
|
|
2321
2387
|
}
|
|
@@ -3317,6 +3383,19 @@ export class SyncEngine {
|
|
|
3317
3383
|
* follower tab forwarded a CRDT sub, mirror over the multi-tab
|
|
3318
3384
|
* channel so followers see Loro updates too. */
|
|
3319
3385
|
private dispatchBinaryFrame(bytes: Uint8Array): void {
|
|
3386
|
+
// Remember the latest snapshot per row for follower catch-up (see
|
|
3387
|
+
// lastCrdtFrames). Header-only peek; the payload stays opaque.
|
|
3388
|
+
const key = crdtFrameKey(bytes);
|
|
3389
|
+
if (key !== null) {
|
|
3390
|
+
if (!this.lastCrdtFrames.has(key) &&
|
|
3391
|
+
this.lastCrdtFrames.size >= LAST_CRDT_FRAMES_MAX) {
|
|
3392
|
+
const oldest = this.lastCrdtFrames.keys().next().value;
|
|
3393
|
+
if (oldest !== undefined) this.lastCrdtFrames.delete(oldest);
|
|
3394
|
+
}
|
|
3395
|
+
// Re-insert to refresh FIFO position.
|
|
3396
|
+
this.lastCrdtFrames.delete(key);
|
|
3397
|
+
this.lastCrdtFrames.set(key, bytes);
|
|
3398
|
+
}
|
|
3320
3399
|
for (const handler of this.binaryHandlers) {
|
|
3321
3400
|
try {
|
|
3322
3401
|
handler(bytes);
|
|
@@ -3340,6 +3419,12 @@ export class SyncEngine {
|
|
|
3340
3419
|
}
|
|
3341
3420
|
}
|
|
3342
3421
|
|
|
3422
|
+
/** Drop the cached snapshot for a revoked row so it can't be
|
|
3423
|
+
* replayed to a late-joining tab after policy said no. */
|
|
3424
|
+
private evictCrdtFrame(entity: string, rowId: string): void {
|
|
3425
|
+
this.lastCrdtFrames.delete(`${entity}|${rowId}`);
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3343
3428
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
3344
3429
|
const headers: Record<string, string> = {};
|
|
3345
3430
|
if (body) headers["Content-Type"] = "application/json";
|
|
@@ -3448,6 +3533,39 @@ export async function getServerData(
|
|
|
3448
3533
|
* varies by mutation path on the server). Recursive on objects only;
|
|
3449
3534
|
* arrays and primitives use their natural shape.
|
|
3450
3535
|
*/
|
|
3536
|
+
/** Cap on cached per-row CRDT snapshots (see lastCrdtFrames). 64 rows of
|
|
3537
|
+
* collaborative state is far beyond what one browser session edits at
|
|
3538
|
+
* once; FIFO eviction keeps a long-lived leader tab bounded. */
|
|
3539
|
+
export const LAST_CRDT_FRAMES_MAX = 64;
|
|
3540
|
+
|
|
3541
|
+
/**
|
|
3542
|
+
* Header-only peek at a binary CRDT frame: returns `"entity|rowId"` for
|
|
3543
|
+
* a SNAPSHOT frame, null for anything else (updates, foreign binary,
|
|
3544
|
+
* truncated bytes). Mirrors the layout of
|
|
3545
|
+
* `crates/router::encode_crdt_frame` / `@pylonsync/loro`'s wire.ts:
|
|
3546
|
+
*
|
|
3547
|
+
* [type: u8] [entity_len: u16 BE] [entity utf8]
|
|
3548
|
+
* [row_id_len: u16 BE] [row_id utf8] [payload]
|
|
3549
|
+
*
|
|
3550
|
+
* The engine still treats the PAYLOAD as opaque — this reads only the
|
|
3551
|
+
* routing header so the follower-catch-up cache can be keyed per row.
|
|
3552
|
+
*/
|
|
3553
|
+
export function crdtFrameKey(bytes: Uint8Array): string | null {
|
|
3554
|
+
const SNAPSHOT_TYPE = 0x10;
|
|
3555
|
+
if (bytes.length < 5 || bytes[0] !== SNAPSHOT_TYPE) return null;
|
|
3556
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
3557
|
+
const entityLen = view.getUint16(1, false);
|
|
3558
|
+
const entityEnd = 3 + entityLen;
|
|
3559
|
+
if (entityEnd + 2 > bytes.length) return null;
|
|
3560
|
+
const rowIdLen = view.getUint16(entityEnd, false);
|
|
3561
|
+
const rowIdEnd = entityEnd + 2 + rowIdLen;
|
|
3562
|
+
if (rowIdEnd > bytes.length) return null;
|
|
3563
|
+
const decoder = new TextDecoder();
|
|
3564
|
+
const entity = decoder.decode(bytes.subarray(3, entityEnd));
|
|
3565
|
+
const rowId = decoder.decode(bytes.subarray(entityEnd + 2, rowIdEnd));
|
|
3566
|
+
return `${entity}|${rowId}`;
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3451
3569
|
function rowsDiffer(a: Row, b: Row): boolean {
|
|
3452
3570
|
return stableStringify(a) !== stableStringify(b);
|
|
3453
3571
|
}
|
|
@@ -181,6 +181,26 @@ describe("RoomSubscriptions: snapshot / update / error", () => {
|
|
|
181
181
|
expect(h.rooms.members("channel:foo")).toHaveLength(1);
|
|
182
182
|
});
|
|
183
183
|
|
|
184
|
+
test("applyUpdate presence applies the envelope's data slot", () => {
|
|
185
|
+
// The server ships new presence in the DATA slot; `member` carries
|
|
186
|
+
// only user_id. Pre-fix the handler merged `member` alone — a no-op
|
|
187
|
+
// that froze remote presence (live cursors, typing indicators) at
|
|
188
|
+
// whatever the join carried.
|
|
189
|
+
const h = makeHarness();
|
|
190
|
+
h.rooms.register("channel:foo", () => {});
|
|
191
|
+
h.rooms.applySnapshot("channel:foo", [
|
|
192
|
+
{ user_id: "u1", joined_at: "t1", data: { caret: 1 } },
|
|
193
|
+
]);
|
|
194
|
+
h.rooms.applyUpdate(
|
|
195
|
+
"channel:foo",
|
|
196
|
+
"presence",
|
|
197
|
+
{ user_id: "u1", joined_at: "t1" },
|
|
198
|
+
{ caret: 42, name: "Ada" },
|
|
199
|
+
);
|
|
200
|
+
const members = h.rooms.members("channel:foo")!;
|
|
201
|
+
expect(members[0].data).toEqual({ caret: 42, name: "Ada" });
|
|
202
|
+
});
|
|
203
|
+
|
|
184
204
|
test("applyUpdate leave removes the matching user", () => {
|
|
185
205
|
const h = makeHarness();
|
|
186
206
|
h.rooms.register("channel:foo", () => {});
|
|
@@ -264,8 +264,18 @@ export class RoomSubscriptions {
|
|
|
264
264
|
case "presence": {
|
|
265
265
|
if (!member) break;
|
|
266
266
|
// Swap the matching member's data while preserving join order.
|
|
267
|
+
// The server ships the NEW presence in the envelope's data slot
|
|
268
|
+
// (`member` carries only the user_id) — merging `member` alone
|
|
269
|
+
// is a no-op that drops every live presence update: remote
|
|
270
|
+
// cursors/typing indicators freeze at whatever the join carried.
|
|
271
|
+
const presence =
|
|
272
|
+
_data && typeof _data === "object"
|
|
273
|
+
? (_data as Record<string, unknown>)
|
|
274
|
+
: undefined;
|
|
267
275
|
entry.members = entry.members.map((m) =>
|
|
268
|
-
m.user_id === member.user_id
|
|
276
|
+
m.user_id === member.user_id
|
|
277
|
+
? { ...m, ...member, ...(presence ? { data: presence } : {}) }
|
|
278
|
+
: m,
|
|
269
279
|
);
|
|
270
280
|
break;
|
|
271
281
|
}
|
|
@@ -53,6 +53,13 @@ const OWN_TAB = "__self__";
|
|
|
53
53
|
export interface SubscriptionCoordinatorContext {
|
|
54
54
|
isLeader(): boolean;
|
|
55
55
|
broadcastToTabs(payload: unknown): void;
|
|
56
|
+
/** Leader-side replay of the cached CRDT snapshot for a row (see
|
|
57
|
+
* SyncEngine.lastCrdtFrames). Called when a follower registers
|
|
58
|
+
* interest in a row whose WS subscription is ALREADY alive — the
|
|
59
|
+
* server only ships its catch-up snapshot on a fresh
|
|
60
|
+
* `crdt-subscribe`, so without the replay the new tab's doc stays
|
|
61
|
+
* empty until the next live edit. */
|
|
62
|
+
replayCrdtFrame?(entity: string, rowId: string): void;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
interface ReactiveSpec {
|
|
@@ -282,6 +289,12 @@ export class SubscriptionCoordinator {
|
|
|
282
289
|
entity,
|
|
283
290
|
rowId,
|
|
284
291
|
});
|
|
292
|
+
} else {
|
|
293
|
+
// Subscription already alive → the server won't send a fresh
|
|
294
|
+
// catch-up snapshot. Replay the leader's cached one so the
|
|
295
|
+
// follower converges immediately instead of staying empty
|
|
296
|
+
// until the next live edit.
|
|
297
|
+
this.ctx.replayCrdtFrame?.(entity, rowId);
|
|
285
298
|
}
|
|
286
299
|
return;
|
|
287
300
|
}
|