@pylonsync/sync 0.22.40 → 0.22.41

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";
@@ -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.41",
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,
@@ -4152,6 +4154,20 @@ export class SyncEngine {
4152
4154
  }
4153
4155
  return;
4154
4156
  }
4157
+ // The server removed us from a room tied to a row we may no longer
4158
+ // read. It stops sending the room's events; clear the members and tell
4159
+ // the hook, which stops rejoining.
4160
+ if (msg.type === "room-revoked" && typeof msg.room === "string") {
4161
+ const room = msg.room;
4162
+ const error: RoomError = { code: "ROOM_REVOKED" };
4163
+ this.rooms.applySnapshot(room, []);
4164
+ this.rooms.applyError(room, error);
4165
+ if ((this.roomForwarders.get(room)?.size ?? 0) > 0) {
4166
+ this.broadcastToTabs({ type: "room-fanout-snapshot", room, members: [] });
4167
+ this.broadcastToTabs({ type: "room-fanout-error", room, error });
4168
+ }
4169
+ return;
4170
+ }
4155
4171
  if (
4156
4172
  msg.type === "error" &&
4157
4173
  typeof msg.room === "string" &&
@@ -4163,7 +4179,8 @@ export class SyncEngine {
4163
4179
  // path right now, but the dispatch is keyed by `code` so future
4164
4180
  // error codes (rate-limited, room-full, etc.) route here too.
4165
4181
  const room = msg.room;
4166
- const code = msg.code === "NOT_IN_ROOM" ? "NOT_IN_ROOM" : "UNKNOWN";
4182
+ const code: RoomErrorCode =
4183
+ msg.code === "NOT_IN_ROOM" || msg.code === "ROOM_FORBIDDEN" ? msg.code : "UNKNOWN";
4167
4184
  const error: RoomError = {
4168
4185
  code,
4169
4186
  message: typeof msg.message === "string" ? msg.message : undefined,
@@ -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": {