@pylonsync/sync 0.22.40 → 0.22.43

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
@@ -2,7 +2,7 @@ import { LocalStore } from "./local-store";
2
2
  import { MutationQueue } from "./mutation-queue";
3
3
  import { type RoomError, type RoomMember, type RoomSubscriber } from "./room-subscriptions";
4
4
  import { SessionResolver } from "./session-resolver";
5
- export { RoomSubscriptions, type RoomError, type RoomErrorCode, type RoomMember, type RoomMessage, type RoomMessageSubscriber, type RoomSubscriber, } from "./room-subscriptions";
5
+ export { RoomSubscriptions, rowRoom, type RoomError, type RoomErrorCode, type RoomMember, type RoomMessage, type RoomMessageSubscriber, type RoomSubscriber, } from "./room-subscriptions";
6
6
  export { IndexedDBPersistence, persistChange } from "./persistence";
7
7
  export type { ReplicaPersistence } from "./persistence";
8
8
  export { BEARER_REJECTED_HEADER, bearerRejected, buildRequest, clearRejectedToken, pylonFetch, pylonFetchRaw, PylonHttpError, resolveBaseUrl, } from "./transport";
@@ -733,6 +733,35 @@ export declare class SyncEngine {
733
733
  */
734
734
  observeEntity(entity: string): void;
735
735
  reconcile(entities?: string[]): Promise<void>;
736
+ /** Entities a server hint (`sync-reconcile`) asked to reconcile that
737
+ * have not reconciled yet. */
738
+ private hintPending;
739
+ private hintTimer;
740
+ private hintRetries;
741
+ private lastHintReconcileAt;
742
+ /** Hints that arrive this close together share one reconcile. */
743
+ private static readonly HINT_DEBOUNCE_MS;
744
+ /** At most one hinted reconcile per this interval. */
745
+ private static readonly HINT_MIN_INTERVAL_MS;
746
+ /** Backoff for entities whose fetch raced a newer change: doubling from
747
+ * the base, capped. */
748
+ private static readonly HINT_RETRY_BASE_MS;
749
+ private static readonly HINT_RETRY_MAX_MS;
750
+ /**
751
+ * The server said a change elsewhere (a share, an unshare, a folder
752
+ * membership) changed which rows of `entities` this user may read
753
+ * (`sync-reconcile`). No other event will tell this replica, so the
754
+ * entities stay pending until a reconcile of them completes: hints are
755
+ * merged, run at most once a second, and an entity whose fetch raced a
756
+ * newer change is retried with backoff until it lands or the engine
757
+ * stops.
758
+ */
759
+ private queueHintReconcile;
760
+ private scheduleHintReconcile;
761
+ private flushHintReconcile;
762
+ /** `reconcile`, resolving with the entities skipped because the cursor
763
+ * moved during their fetch. */
764
+ private reconcileReportingSkips;
736
765
  /** Scoped reconcile batch that has not started running yet. */
737
766
  private scopedReconcileBatch;
738
767
  private scopedReconcileSeq;
@@ -4,7 +4,18 @@ export interface RoomMember {
4
4
  data?: Record<string, unknown>;
5
5
  }
6
6
  /** Reason codes the SDK exposes on a room subscription error. */
7
- export type RoomErrorCode = "NOT_IN_ROOM" | "UNKNOWN";
7
+ /**
8
+ * - `NOT_IN_ROOM`: subscribed before joining (rejoin and resubscribe).
9
+ * - `ROOM_FORBIDDEN`: a room tied to a row the caller may not read.
10
+ * - `ROOM_REVOKED`: the caller lost access to that row while in the room.
11
+ */
12
+ export type RoomErrorCode = "NOT_IN_ROOM" | "ROOM_FORBIDDEN" | "ROOM_REVOKED" | "UNKNOWN";
13
+ /**
14
+ * The room tied to a row: its members are the people who may read the row,
15
+ * and the server checks that on every room operation. `@` starts only these
16
+ * names.
17
+ */
18
+ export declare function rowRoom(entity: string, id: string): string;
8
19
  export interface RoomError {
9
20
  code: RoomErrorCode;
10
21
  message?: string;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.22.40",
6
+ "version": "0.22.43",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ import { OpQueue } from "./op-queue";
22
22
  import {
23
23
  RoomSubscriptions,
24
24
  type RoomError,
25
+ type RoomErrorCode,
25
26
  type RoomMember,
26
27
  type RoomSubscriber,
27
28
  } from "./room-subscriptions";
@@ -37,6 +38,7 @@ import {
37
38
  import { generateClientId, generateId } from "./ids";
38
39
  export {
39
40
  RoomSubscriptions,
41
+ rowRoom,
40
42
  type RoomError,
41
43
  type RoomErrorCode,
42
44
  type RoomMember,
@@ -1649,6 +1651,12 @@ export class SyncEngine {
1649
1651
  /** Stop the sync engine. */
1650
1652
  stop(): void {
1651
1653
  this.running = false;
1654
+ if (this.hintTimer !== null) {
1655
+ clearTimeout(this.hintTimer);
1656
+ this.hintTimer = null;
1657
+ }
1658
+ this.hintPending.clear();
1659
+ this.hintRetries = 0;
1652
1660
  if (this.identityRetryTimer !== null) {
1653
1661
  clearTimeout(this.identityRetryTimer);
1654
1662
  this.identityRetryTimer = null;
@@ -2301,10 +2309,75 @@ export class SyncEngine {
2301
2309
  }
2302
2310
 
2303
2311
  async reconcile(entities?: string[]): Promise<void> {
2312
+ await this.reconcileReportingSkips(entities);
2313
+ }
2314
+
2315
+ /** Entities a server hint (`sync-reconcile`) asked to reconcile that
2316
+ * have not reconciled yet. */
2317
+ private hintPending = new Set<string>();
2318
+ private hintTimer: ReturnType<typeof setTimeout> | null = null;
2319
+ private hintRetries = 0;
2320
+ private lastHintReconcileAt = 0;
2321
+ /** Hints that arrive this close together share one reconcile. */
2322
+ private static readonly HINT_DEBOUNCE_MS = 100;
2323
+ /** At most one hinted reconcile per this interval. */
2324
+ private static readonly HINT_MIN_INTERVAL_MS = 1_000;
2325
+ /** Backoff for entities whose fetch raced a newer change: doubling from
2326
+ * the base, capped. */
2327
+ private static readonly HINT_RETRY_BASE_MS = 250;
2328
+ private static readonly HINT_RETRY_MAX_MS = 10_000;
2329
+
2330
+ /**
2331
+ * The server said a change elsewhere (a share, an unshare, a folder
2332
+ * membership) changed which rows of `entities` this user may read
2333
+ * (`sync-reconcile`). No other event will tell this replica, so the
2334
+ * entities stay pending until a reconcile of them completes: hints are
2335
+ * merged, run at most once a second, and an entity whose fetch raced a
2336
+ * newer change is retried with backoff until it lands or the engine
2337
+ * stops.
2338
+ */
2339
+ private queueHintReconcile(entities: string[]): void {
2340
+ for (const e of entities) this.hintPending.add(e);
2341
+ this.scheduleHintReconcile(SyncEngine.HINT_DEBOUNCE_MS);
2342
+ }
2343
+
2344
+ private scheduleHintReconcile(delayMs: number): void {
2345
+ if (this.hintTimer !== null || !this.running) return;
2346
+ const wait = Math.max(delayMs, this.lastHintReconcileAt + SyncEngine.HINT_MIN_INTERVAL_MS - Date.now());
2347
+ this.hintTimer = setTimeout(() => {
2348
+ this.hintTimer = null;
2349
+ void this.flushHintReconcile();
2350
+ }, wait);
2351
+ }
2352
+
2353
+ private async flushHintReconcile(): Promise<void> {
2354
+ if (!this.running || this.hintPending.size === 0) return;
2355
+ const entities = [...this.hintPending];
2356
+ this.hintPending.clear();
2357
+ this.lastHintReconcileAt = Date.now();
2358
+ let skipped: string[];
2359
+ try {
2360
+ skipped = await this.reconcileReportingSkips(entities);
2361
+ } catch {
2362
+ skipped = entities;
2363
+ }
2364
+ for (const e of skipped) this.hintPending.add(e);
2365
+ this.hintRetries = skipped.length > 0 ? this.hintRetries + 1 : 0;
2366
+ if (this.hintPending.size === 0) return;
2367
+ const delay =
2368
+ skipped.length > 0
2369
+ ? Math.min(SyncEngine.HINT_RETRY_BASE_MS * 2 ** (this.hintRetries - 1), SyncEngine.HINT_RETRY_MAX_MS)
2370
+ : SyncEngine.HINT_DEBOUNCE_MS;
2371
+ this.scheduleHintReconcile(delay);
2372
+ }
2373
+
2374
+ /** `reconcile`, resolving with the entities skipped because the cursor
2375
+ * moved during their fetch. */
2376
+ private async reconcileReportingSkips(entities?: string[]): Promise<string[]> {
2304
2377
  const minIntervalMs = this.config.reconcileMinIntervalMs ?? 2_000;
2305
2378
  const now = Date.now();
2306
2379
  if (entities === undefined && now - this.lastReconcileAt < minIntervalMs) {
2307
- return;
2380
+ return [];
2308
2381
  }
2309
2382
  // Reconcile waits behind any in-flight pull / refresh / push so it
2310
2383
  // can't apply rows captured under a stale session.
@@ -2313,7 +2386,7 @@ export class SyncEngine {
2313
2386
  // the same window share one fetch per entity.
2314
2387
  return this.opQueue.enqueue("reconcile", async () => {
2315
2388
  try {
2316
- await this.reconcileInner();
2389
+ return await this.reconcileInner();
2317
2390
  } finally {
2318
2391
  this.lastReconcileAt = Date.now();
2319
2392
  }
@@ -2328,9 +2401,9 @@ export class SyncEngine {
2328
2401
  for (const e of entities) open.entities.add(e);
2329
2402
  return open.promise;
2330
2403
  }
2331
- const batch: { entities: Set<string>; promise: Promise<void> } = {
2404
+ const batch: { entities: Set<string>; promise: Promise<string[]> } = {
2332
2405
  entities: new Set(entities),
2333
- promise: Promise.resolve(),
2406
+ promise: Promise.resolve([]),
2334
2407
  };
2335
2408
  this.scopedReconcileBatch = batch;
2336
2409
  this.scopedReconcileSeq += 1;
@@ -2339,7 +2412,7 @@ export class SyncEngine {
2339
2412
  async () => {
2340
2413
  if (this.scopedReconcileBatch === batch) this.scopedReconcileBatch = null;
2341
2414
  try {
2342
- await this.reconcileInner([...batch.entities]);
2415
+ return await this.reconcileInner([...batch.entities]);
2343
2416
  } finally {
2344
2417
  this.lastReconcileAt = Date.now();
2345
2418
  }
@@ -2351,14 +2424,14 @@ export class SyncEngine {
2351
2424
  /** Scoped reconcile batch that has not started running yet. */
2352
2425
  private scopedReconcileBatch: {
2353
2426
  entities: Set<string>;
2354
- promise: Promise<void>;
2427
+ promise: Promise<string[]>;
2355
2428
  } | null = null;
2356
2429
  private scopedReconcileSeq = 0;
2357
2430
 
2358
- private async reconcileInner(entities?: string[]): Promise<void> {
2431
+ private async reconcileInner(entities?: string[]): Promise<string[]> {
2359
2432
  // Same reasoning as pullInner: the leader reconciles, broadcasts
2360
2433
  // results, and follower replicas converge via the channel.
2361
- if (!this.isMultiTabLeader) return;
2434
+ if (!this.isMultiTabLeader) return [];
2362
2435
  // Sweep entities with local rows PLUS entities the app has observed
2363
2436
  // via useQuery (even when empty locally). Without the observed set,
2364
2437
  // a server row in a never-cached entity is never reconciled and
@@ -2366,7 +2439,8 @@ export class SyncEngine {
2366
2439
  const names =
2367
2440
  entities ??
2368
2441
  [...new Set([...this.store.entityNames(), ...this.observedEntities])];
2369
- if (names.length === 0) return;
2442
+ if (names.length === 0) return [];
2443
+ const skipped: string[] = [];
2370
2444
  // Tombstone seq for any local row the server doesn't return. Using
2371
2445
  // the current cursor means future inserts (which have higher seqs)
2372
2446
  // bypass the tombstone — re-creation server-side still propagates.
@@ -2442,11 +2516,16 @@ export class SyncEngine {
2442
2516
  const streak = (this.reconcile403Streak.get(entity) ?? 0) + 1;
2443
2517
  if (streak < 2) {
2444
2518
  this.reconcile403Streak.set(entity, streak);
2519
+ skipped.push(entity);
2445
2520
  return;
2446
2521
  }
2447
2522
  this.reconcile403Streak.delete(entity);
2448
2523
  await this.dropEntity(entity, tombstoneSeq);
2524
+ return;
2449
2525
  }
2526
+ // Not reconciled (offline, a 5xx): reported, so a server hint
2527
+ // stays pending and retries.
2528
+ skipped.push(entity);
2450
2529
  return;
2451
2530
  }
2452
2531
  // Successful fetch — the entity is readable, so any prior 403 streak
@@ -2458,7 +2537,9 @@ export class SyncEngine {
2458
2537
  // for a row our snapshot just captured. Bail out for this
2459
2538
  // entity; reconcile() is triggered again on visibility-change
2460
2539
  // and reconnect, and the WS event already carried the latest
2461
- // state for the affected row.
2540
+ // state for the affected row. Reported, so a server hint can
2541
+ // retry (flushHintReconcile).
2542
+ skipped.push(entity);
2462
2543
  return;
2463
2544
  }
2464
2545
  if (this.session.signature() !== sessionBeforeFetch) {
@@ -2477,6 +2558,7 @@ export class SyncEngine {
2477
2558
  );
2478
2559
  }),
2479
2560
  );
2561
+ return skipped;
2480
2562
  }
2481
2563
 
2482
2564
  /** Fetch every row for an entity. Uses cursor pagination so big tables
@@ -4103,6 +4185,15 @@ export class SyncEngine {
4103
4185
  // fresh /api/auth/me read which updates the cached session AND,
4104
4186
  // on tenant flip, resets the replica so stale rows from the
4105
4187
  // previous tenant disappear.
4188
+ // A change elsewhere (a share, an unshare, a folder membership)
4189
+ // changed which rows of these entities this user may read, without a
4190
+ // change event for the rows themselves. Reconcile them.
4191
+ if (msg.type === "sync-reconcile" && Array.isArray(msg.entities)) {
4192
+ const entities = msg.entities.filter((e): e is string => typeof e === "string");
4193
+ if (entities.length > 0) this.queueHintReconcile(entities);
4194
+ return;
4195
+ }
4196
+
4106
4197
  if (msg.type === "session-changed") {
4107
4198
  void this.refreshResolvedSession();
4108
4199
  return;
@@ -4152,6 +4243,20 @@ export class SyncEngine {
4152
4243
  }
4153
4244
  return;
4154
4245
  }
4246
+ // The server removed us from a room tied to a row we may no longer
4247
+ // read. It stops sending the room's events; clear the members and tell
4248
+ // the hook, which stops rejoining.
4249
+ if (msg.type === "room-revoked" && typeof msg.room === "string") {
4250
+ const room = msg.room;
4251
+ const error: RoomError = { code: "ROOM_REVOKED" };
4252
+ this.rooms.applySnapshot(room, []);
4253
+ this.rooms.applyError(room, error);
4254
+ if ((this.roomForwarders.get(room)?.size ?? 0) > 0) {
4255
+ this.broadcastToTabs({ type: "room-fanout-snapshot", room, members: [] });
4256
+ this.broadcastToTabs({ type: "room-fanout-error", room, error });
4257
+ }
4258
+ return;
4259
+ }
4155
4260
  if (
4156
4261
  msg.type === "error" &&
4157
4262
  typeof msg.room === "string" &&
@@ -4163,7 +4268,8 @@ export class SyncEngine {
4163
4268
  // path right now, but the dispatch is keyed by `code` so future
4164
4269
  // error codes (rate-limited, room-full, etc.) route here too.
4165
4270
  const room = msg.room;
4166
- const code = msg.code === "NOT_IN_ROOM" ? "NOT_IN_ROOM" : "UNKNOWN";
4271
+ const code: RoomErrorCode =
4272
+ msg.code === "NOT_IN_ROOM" || msg.code === "ROOM_FORBIDDEN" ? msg.code : "UNKNOWN";
4167
4273
  const error: RoomError = {
4168
4274
  code,
4169
4275
  message: typeof msg.message === "string" ? msg.message : undefined,
@@ -668,3 +668,148 @@ describe("SyncEngine cookie auth", () => {
668
668
  expect(notifications).toBe(1);
669
669
  });
670
670
  });
671
+
672
+ // The server sends `sync-reconcile` when a row of another entity changed
673
+ // which rows of these entities the user may read (a share, an unshare):
674
+ // no change event of the rows themselves will come.
675
+ describe("sync-reconcile hint", () => {
676
+ let restore: (() => void) | null = null;
677
+
678
+ afterEach(() => {
679
+ restore?.();
680
+ restore = null;
681
+ });
682
+
683
+ type Internals = {
684
+ dispatchInboundMessage(m: Record<string, unknown>): void;
685
+ running: boolean;
686
+ cursor: { last_seq: number };
687
+ hintPending: Set<string>;
688
+ };
689
+ const inner = (engine: SyncEngine) => engine as unknown as Internals;
690
+ const deliver = (engine: SyncEngine, msg: Record<string, unknown>) => inner(engine).dispatchInboundMessage(msg);
691
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
692
+
693
+ function documentServer(onFetch: (n: number) => void = () => {}) {
694
+ let fetches = 0;
695
+ restore = installFetch(async (url) => {
696
+ if (url.includes("/api/entities/Document/cursor")) {
697
+ fetches += 1;
698
+ onFetch(fetches);
699
+ return {
700
+ status: 200,
701
+ body: { data: [{ id: "shared", title: "Now shared" }], next_cursor: null, has_more: false },
702
+ };
703
+ }
704
+ return { status: 404, body: {} };
705
+ });
706
+ return () => fetches;
707
+ }
708
+
709
+ test("reconciles the named entities: the shared row arrives, the unshared one leaves", async () => {
710
+ const fetches = documentServer();
711
+ const engine = makeEngine();
712
+ inner(engine).running = true;
713
+ seedStore(engine, "Document", [{ id: "unshared", title: "No longer shared" }]);
714
+
715
+ deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
716
+ await sleep(250);
717
+
718
+ expect(fetches()).toBe(1);
719
+ expect(engine.store.list("Document").map((r) => (r as { id: string }).id)).toEqual(["shared"]);
720
+ engine.stop();
721
+ });
722
+
723
+ test("hints that arrive together share one fetch", async () => {
724
+ const fetches = documentServer();
725
+ const engine = makeEngine();
726
+ inner(engine).running = true;
727
+ for (let i = 0; i < 5; i++) deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
728
+ await sleep(250);
729
+ expect(fetches()).toBe(1);
730
+ engine.stop();
731
+ });
732
+
733
+ test("keeps retrying while newer changes race the fetch, then lands", async () => {
734
+ const engine = makeEngine();
735
+ // The first two fetches race a change event: the cursor moves.
736
+ const fetches = documentServer((n) => {
737
+ if (n <= 2) inner(engine).cursor.last_seq += 1;
738
+ });
739
+ inner(engine).running = true;
740
+
741
+ deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
742
+ await sleep(250);
743
+ expect(fetches()).toBe(1);
744
+ expect(engine.store.list("Document")).toEqual([]);
745
+ expect([...inner(engine).hintPending]).toEqual(["Document"]);
746
+
747
+ // Retries run at most once a second.
748
+ await sleep(2300);
749
+ expect(fetches()).toBe(3);
750
+ expect(engine.store.list("Document").map((r) => (r as { id: string }).id)).toEqual(["shared"]);
751
+ expect(inner(engine).hintPending.size).toBe(0);
752
+ engine.stop();
753
+ });
754
+
755
+ test("stop drops pending hints", async () => {
756
+ const fetches = documentServer();
757
+ const engine = makeEngine();
758
+ inner(engine).running = true;
759
+ deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
760
+ engine.stop();
761
+ await sleep(250);
762
+ expect(fetches()).toBe(0);
763
+ expect(inner(engine).hintPending.size).toBe(0);
764
+ });
765
+
766
+ test("ignores a hint without entity names", async () => {
767
+ const fetches = documentServer();
768
+ const engine = makeEngine();
769
+ inner(engine).running = true;
770
+ deliver(engine, { type: "sync-reconcile", entities: [42, null] });
771
+ deliver(engine, { type: "sync-reconcile" });
772
+ await sleep(250);
773
+ expect(fetches()).toBe(0);
774
+ engine.stop();
775
+ });
776
+ });
777
+
778
+ describe("sync-reconcile hint after a failed fetch", () => {
779
+ let restore: (() => void) | null = null;
780
+ afterEach(() => {
781
+ restore?.();
782
+ restore = null;
783
+ });
784
+
785
+ test("a 5xx keeps the hint pending and the retry lands", async () => {
786
+ let fetches = 0;
787
+ restore = installFetch(async (url) => {
788
+ if (url.includes("/api/entities/Document/cursor")) {
789
+ fetches += 1;
790
+ if (fetches === 1) return { status: 503, body: {} };
791
+ return { status: 200, body: { data: [], next_cursor: null, has_more: false } };
792
+ }
793
+ return { status: 404, body: {} };
794
+ });
795
+ const engine = makeEngine();
796
+ const inner = engine as unknown as {
797
+ running: boolean;
798
+ hintPending: Set<string>;
799
+ dispatchInboundMessage(m: Record<string, unknown>): void;
800
+ };
801
+ inner.running = true;
802
+ seedStore(engine, "Document", [{ id: "unshared", title: "No longer shared" }]);
803
+
804
+ inner.dispatchInboundMessage({ type: "sync-reconcile", entities: ["Document"] });
805
+ await new Promise((r) => setTimeout(r, 250));
806
+ expect(fetches).toBe(1);
807
+ expect([...inner.hintPending]).toEqual(["Document"]);
808
+ expect(engine.store.list("Document").length).toBe(1);
809
+
810
+ await new Promise((r) => setTimeout(r, 1200));
811
+ expect(fetches).toBe(2);
812
+ expect(engine.store.list("Document")).toEqual([]);
813
+ engine.stop();
814
+ });
815
+ });
@@ -299,3 +299,15 @@ describe("RoomSubscriptions: resubscribe after a rejoin", () => {
299
299
  expect(sent).toEqual([{ type: "room-sub-resubscribe", room: "channel:a" }]);
300
300
  });
301
301
  });
302
+
303
+ test("presence from someone not in the list adds them back", () => {
304
+ const { rooms } = makeHarness();
305
+ rooms.register("doc", () => {});
306
+ rooms.applySnapshot("doc", [{ user_id: "alice", joined_at: "t", data: {} }]);
307
+ // A leave relayed from another machine, though Alice is still connected
308
+ // through this one.
309
+ rooms.applyUpdate("doc", "leave", { user_id: "alice", joined_at: "", data: {} }, undefined);
310
+ expect(rooms.members("doc")).toEqual([]);
311
+ rooms.applyUpdate("doc", "presence", { user_id: "alice", joined_at: "", data: {} }, { cursor: 4 });
312
+ expect(rooms.members("doc")).toEqual([{ user_id: "alice", joined_at: "", data: { cursor: 4 } }]);
313
+ });
@@ -51,7 +51,21 @@ export interface RoomMember {
51
51
  }
52
52
 
53
53
  /** Reason codes the SDK exposes on a room subscription error. */
54
- export type RoomErrorCode = "NOT_IN_ROOM" | "UNKNOWN";
54
+ /**
55
+ * - `NOT_IN_ROOM`: subscribed before joining (rejoin and resubscribe).
56
+ * - `ROOM_FORBIDDEN`: a room tied to a row the caller may not read.
57
+ * - `ROOM_REVOKED`: the caller lost access to that row while in the room.
58
+ */
59
+ export type RoomErrorCode = "NOT_IN_ROOM" | "ROOM_FORBIDDEN" | "ROOM_REVOKED" | "UNKNOWN";
60
+
61
+ /**
62
+ * The room tied to a row: its members are the people who may read the row,
63
+ * and the server checks that on every room operation. `@` starts only these
64
+ * names.
65
+ */
66
+ export function rowRoom(entity: string, id: string): string {
67
+ return `@${entity}/${id}`;
68
+ }
55
69
 
56
70
  export interface RoomError {
57
71
  code: RoomErrorCode;
@@ -272,11 +286,22 @@ export class RoomSubscriptions {
272
286
  _data && typeof _data === "object"
273
287
  ? (_data as Record<string, unknown>)
274
288
  : undefined;
275
- entry.members = entry.members.map((m) =>
276
- m.user_id === member.user_id
277
- ? { ...m, ...member, ...(presence ? { data: presence } : {}) }
278
- : m,
279
- );
289
+ if (entry.members.some((m) => m.user_id === member.user_id)) {
290
+ entry.members = entry.members.map((m) =>
291
+ m.user_id === member.user_id
292
+ ? { ...m, ...member, ...(presence ? { data: presence } : {}) }
293
+ : m,
294
+ );
295
+ } else {
296
+ // Presence from someone not in the list is in the room: on a
297
+ // cluster, a leave relayed from one machine can drop a person
298
+ // still connected through another. Their next presence puts them
299
+ // back.
300
+ entry.members = [
301
+ ...entry.members,
302
+ { ...member, joined_at: member.joined_at ?? "", ...(presence ? { data: presence } : {}) },
303
+ ];
304
+ }
280
305
  break;
281
306
  }
282
307
  case "broadcast": {