@pylonsync/sync 0.3.311 → 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 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
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.311",
6
+ "version": "0.3.312",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -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. Nothing else to do here — the broker is
755
- // wired to forward inbound messages into the engine. The
756
- // sessionPromise we kicked off above resolves into the void;
757
- // the leader's broadcast will deliver the authoritative view.
758
- // Swallow any pending error so it doesn't surface as an
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 ? { ...m, ...member } : m,
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
  }