@spacelr/sdk 0.10.0 → 0.10.2

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.mts CHANGED
@@ -62,6 +62,12 @@ interface RegisterResponse {
62
62
  access_token?: string;
63
63
  /** Omitted by the server when `emailVerificationRequired` is true. */
64
64
  refresh_token?: string;
65
+ /**
66
+ * Access-token lifetime in seconds. Present whenever tokens are issued
67
+ * (i.e. not when email verification is required). Lets the SDK store an
68
+ * expiry and refresh proactively instead of on the first request.
69
+ */
70
+ expires_in?: number;
65
71
  emailVerificationRequired?: boolean;
66
72
  }
67
73
  interface TokenResponse {
@@ -597,6 +603,10 @@ declare class RealtimeClient {
597
603
  private subscriptions;
598
604
  private connecting;
599
605
  private roomWhereMap;
606
+ private roomErrorMap;
607
+ private roomGeneration;
608
+ private subEpoch;
609
+ private resubscribingRooms;
600
610
  private unsubscribeFromTokenRefreshed;
601
611
  private onVisibilityChange;
602
612
  private onOnline;
@@ -606,7 +616,9 @@ declare class RealtimeClient {
606
616
  private connectionStateListeners;
607
617
  private streamSubscriptions;
608
618
  constructor(config: RealtimeConfig);
609
- subscribe(projectId: string, collectionName: string, callback: (event: DatabaseChangeEvent) => void, onError?: (error: Error) => void, where?: WhereFilter): Promise<() => void>;
619
+ subscribe(projectId: string, collectionName: string, callback: (event: DatabaseChangeEvent) => void, onError?: (error: Error & {
620
+ code?: string;
621
+ }) => void, where?: WhereFilter): Promise<() => void>;
610
622
  /**
611
623
  * Subscribe to a stream-mode collection using Redis Streams replay +
612
624
  * cursor-based delivery. Parallel to `subscribe()` (which targets
@@ -698,6 +710,31 @@ declare class RealtimeClient {
698
710
  private ensureWakeListeners;
699
711
  private detachWakeListeners;
700
712
  private resubscribeAll;
713
+ /**
714
+ * Parse a pubsub room key back into its projectId + collectionName.
715
+ * Room format: `db:{projectId}:{collectionName}` or that base plus `?filter`.
716
+ * Returns null for anything that isn't a well-formed `db:` room.
717
+ */
718
+ private parseRoom;
719
+ /** Build an Error carrying the gateway's typed `errorCode` (if any) as `.code`. */
720
+ private toSubscribeError;
721
+ /** Call every subscriber's error handler registered for `room` (#661). */
722
+ private notifyRoomError;
723
+ /**
724
+ * Re-run the subscribe handshake for one already-tracked room. Shared by
725
+ * reconnect resubscription (`resubscribeAll`) and the #661
726
+ * `subscription-invalidated` handler.
727
+ *
728
+ * - Deduped per room via `resubscribingRooms` so the duplicate invalidation
729
+ * events the gateway emits (one per evicted room) collapse to one handshake.
730
+ * - Snapshots the room's generation; the ack acts only if it is still current,
731
+ * so a late ack cannot clobber an unsubscribe/re-subscribe that happened in
732
+ * the meantime — and a success that arrives after the room was removed emits
733
+ * a compensating `unsubscribe` so no ghost server room is left behind.
734
+ * - On a genuine denial the subscription is evicted and its stored `onError`
735
+ * is called (with the typed `.code`), instead of silently going quiet.
736
+ */
737
+ private resubscribeRoom;
701
738
  private emitSubscribeEvents;
702
739
  private buildStreamPayload;
703
740
  private unsubscribeStream;
package/dist/index.d.ts CHANGED
@@ -62,6 +62,12 @@ interface RegisterResponse {
62
62
  access_token?: string;
63
63
  /** Omitted by the server when `emailVerificationRequired` is true. */
64
64
  refresh_token?: string;
65
+ /**
66
+ * Access-token lifetime in seconds. Present whenever tokens are issued
67
+ * (i.e. not when email verification is required). Lets the SDK store an
68
+ * expiry and refresh proactively instead of on the first request.
69
+ */
70
+ expires_in?: number;
65
71
  emailVerificationRequired?: boolean;
66
72
  }
67
73
  interface TokenResponse {
@@ -597,6 +603,10 @@ declare class RealtimeClient {
597
603
  private subscriptions;
598
604
  private connecting;
599
605
  private roomWhereMap;
606
+ private roomErrorMap;
607
+ private roomGeneration;
608
+ private subEpoch;
609
+ private resubscribingRooms;
600
610
  private unsubscribeFromTokenRefreshed;
601
611
  private onVisibilityChange;
602
612
  private onOnline;
@@ -606,7 +616,9 @@ declare class RealtimeClient {
606
616
  private connectionStateListeners;
607
617
  private streamSubscriptions;
608
618
  constructor(config: RealtimeConfig);
609
- subscribe(projectId: string, collectionName: string, callback: (event: DatabaseChangeEvent) => void, onError?: (error: Error) => void, where?: WhereFilter): Promise<() => void>;
619
+ subscribe(projectId: string, collectionName: string, callback: (event: DatabaseChangeEvent) => void, onError?: (error: Error & {
620
+ code?: string;
621
+ }) => void, where?: WhereFilter): Promise<() => void>;
610
622
  /**
611
623
  * Subscribe to a stream-mode collection using Redis Streams replay +
612
624
  * cursor-based delivery. Parallel to `subscribe()` (which targets
@@ -698,6 +710,31 @@ declare class RealtimeClient {
698
710
  private ensureWakeListeners;
699
711
  private detachWakeListeners;
700
712
  private resubscribeAll;
713
+ /**
714
+ * Parse a pubsub room key back into its projectId + collectionName.
715
+ * Room format: `db:{projectId}:{collectionName}` or that base plus `?filter`.
716
+ * Returns null for anything that isn't a well-formed `db:` room.
717
+ */
718
+ private parseRoom;
719
+ /** Build an Error carrying the gateway's typed `errorCode` (if any) as `.code`. */
720
+ private toSubscribeError;
721
+ /** Call every subscriber's error handler registered for `room` (#661). */
722
+ private notifyRoomError;
723
+ /**
724
+ * Re-run the subscribe handshake for one already-tracked room. Shared by
725
+ * reconnect resubscription (`resubscribeAll`) and the #661
726
+ * `subscription-invalidated` handler.
727
+ *
728
+ * - Deduped per room via `resubscribingRooms` so the duplicate invalidation
729
+ * events the gateway emits (one per evicted room) collapse to one handshake.
730
+ * - Snapshots the room's generation; the ack acts only if it is still current,
731
+ * so a late ack cannot clobber an unsubscribe/re-subscribe that happened in
732
+ * the meantime — and a success that arrives after the room was removed emits
733
+ * a compensating `unsubscribe` so no ghost server room is left behind.
734
+ * - On a genuine denial the subscription is evicted and its stored `onError`
735
+ * is called (with the typed `.code`), instead of silently going quiet.
736
+ */
737
+ private resubscribeRoom;
701
738
  private emitSubscribeEvents;
702
739
  private buildStreamPayload;
703
740
  private unsubscribeStream;
package/dist/index.js CHANGED
@@ -715,6 +715,21 @@ var RealtimeClient = class {
715
715
  this.connecting = null;
716
716
  // Store original where objects per room for reconnect resubscription
717
717
  this.roomWhereMap = /* @__PURE__ */ new Map();
718
+ // Per-room, per-callback error handlers so EVERY subscriber to a room (not
719
+ // just the first) is notified on a denial, and a departed subscriber's handler
720
+ // is never left dangling for its still-active siblings (#661).
721
+ this.roomErrorMap = /* @__PURE__ */ new Map();
722
+ // Per-room generation stamp, taken from a global monotonic counter each time a
723
+ // room becomes active. A re-subscribe snapshots the room's stamp and acts on
724
+ // the ack only if it is still current — so a late ack cannot notify or clean
725
+ // up after the app has unsubscribed/re-subscribed (#661). Entries are deleted
726
+ // on last-unsubscribe (bounded); the global counter guarantees a re-subscribe
727
+ // always gets a strictly higher stamp, so there is no ABA hole.
728
+ this.roomGeneration = /* @__PURE__ */ new Map();
729
+ this.subEpoch = 0;
730
+ // Rooms with a re-subscribe handshake in flight — dedupes the duplicate
731
+ // `subscription-invalidated` events the gateway emits per evicted room (#661).
732
+ this.resubscribingRooms = /* @__PURE__ */ new Set();
718
733
  this.unsubscribeFromTokenRefreshed = null;
719
734
  // Wake-up listeners (browser only) — recover from long OS suspends where
720
735
  // socket.io's internal reconnect loop may have already given up.
@@ -743,7 +758,17 @@ var RealtimeClient = class {
743
758
  }
744
759
  const callbacks = this.subscriptions.get(room);
745
760
  callbacks?.add(callback);
761
+ if (onError) {
762
+ let handlers = this.roomErrorMap.get(room);
763
+ if (!handlers) {
764
+ handlers = /* @__PURE__ */ new Map();
765
+ this.roomErrorMap.set(room, handlers);
766
+ }
767
+ handlers.set(callback, onError);
768
+ }
746
769
  if (callbacks?.size === 1) {
770
+ const generation = ++this.subEpoch;
771
+ this.roomGeneration.set(room, generation);
747
772
  if (where && Object.keys(where).length > 0) {
748
773
  this.roomWhereMap.set(room, where);
749
774
  }
@@ -755,8 +780,8 @@ var RealtimeClient = class {
755
780
  "subscribe",
756
781
  payload,
757
782
  (response) => {
758
- if (response.error && onError) {
759
- onError(new Error(response.error));
783
+ if (response.error && this.roomGeneration.get(room) === generation) {
784
+ this.notifyRoomError(room, this.toSubscribeError(response));
760
785
  }
761
786
  }
762
787
  );
@@ -765,9 +790,12 @@ var RealtimeClient = class {
765
790
  const callbacks2 = this.subscriptions.get(room);
766
791
  if (callbacks2) {
767
792
  callbacks2.delete(callback);
793
+ this.roomErrorMap.get(room)?.delete(callback);
768
794
  if (callbacks2.size === 0) {
769
795
  this.subscriptions.delete(room);
770
796
  this.roomWhereMap.delete(room);
797
+ this.roomErrorMap.delete(room);
798
+ this.roomGeneration.delete(room);
771
799
  const payload = { projectId, collectionName };
772
800
  if (where && Object.keys(where).length > 0) {
773
801
  payload["where"] = where;
@@ -920,6 +948,9 @@ var RealtimeClient = class {
920
948
  }
921
949
  this.subscriptions.clear();
922
950
  this.roomWhereMap.clear();
951
+ this.roomErrorMap.clear();
952
+ this.roomGeneration.clear();
953
+ this.resubscribingRooms.clear();
923
954
  this.streamSubscriptions.clear();
924
955
  this.connecting = null;
925
956
  }
@@ -1069,6 +1100,18 @@ var RealtimeClient = class {
1069
1100
  }
1070
1101
  }
1071
1102
  });
1103
+ this.socket.on(
1104
+ "subscription-invalidated",
1105
+ (payload) => {
1106
+ if (!payload?.projectId || !payload?.collectionName) return;
1107
+ const base = `db:${payload.projectId}:${payload.collectionName}`;
1108
+ for (const room of this.subscriptions.keys()) {
1109
+ if (room === base || room.startsWith(`${base}?`)) {
1110
+ this.resubscribeRoom(room);
1111
+ }
1112
+ }
1113
+ }
1114
+ );
1072
1115
  this.socket.on("event", (payload) => {
1073
1116
  this.dispatchStreamEvent(payload).catch(() => void 0);
1074
1117
  });
@@ -1170,31 +1213,88 @@ var RealtimeClient = class {
1170
1213
  this.onOnline = null;
1171
1214
  }
1172
1215
  resubscribeAll() {
1216
+ this.resubscribingRooms.clear();
1173
1217
  for (const [room] of this.subscriptions) {
1174
- const queryIdx = room.indexOf("?");
1175
- const basePart = queryIdx >= 0 ? room.substring(0, queryIdx) : room;
1176
- const parts = basePart.split(":");
1177
- if (parts.length >= 3) {
1178
- const projectId = parts[1];
1179
- const collectionName = parts.slice(2).join(":");
1180
- const payload = { projectId, collectionName };
1181
- const where = this.roomWhereMap.get(room);
1182
- if (where) {
1183
- payload["where"] = where;
1184
- }
1185
- this.socket?.emit(
1186
- "subscribe",
1187
- payload,
1188
- (response) => {
1189
- if (response?.error) {
1190
- this.subscriptions.delete(room);
1191
- this.roomWhereMap.delete(room);
1192
- }
1193
- }
1194
- );
1218
+ this.resubscribeRoom(room);
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Parse a pubsub room key back into its projectId + collectionName.
1223
+ * Room format: `db:{projectId}:{collectionName}` or that base plus `?filter`.
1224
+ * Returns null for anything that isn't a well-formed `db:` room.
1225
+ */
1226
+ parseRoom(room) {
1227
+ const queryIdx = room.indexOf("?");
1228
+ const basePart = queryIdx >= 0 ? room.substring(0, queryIdx) : room;
1229
+ const parts = basePart.split(":");
1230
+ if (parts.length < 3 || parts[0] !== "db") return null;
1231
+ return { projectId: parts[1], collectionName: parts.slice(2).join(":") };
1232
+ }
1233
+ /** Build an Error carrying the gateway's typed `errorCode` (if any) as `.code`. */
1234
+ toSubscribeError(response) {
1235
+ const err = new Error(response.error ?? "Subscribe denied");
1236
+ if (response.errorCode) err.code = response.errorCode;
1237
+ return err;
1238
+ }
1239
+ /** Call every subscriber's error handler registered for `room` (#661). */
1240
+ notifyRoomError(room, error) {
1241
+ const handlers = this.roomErrorMap.get(room);
1242
+ if (!handlers) return;
1243
+ for (const handler of handlers.values()) {
1244
+ try {
1245
+ handler(error);
1246
+ } catch {
1195
1247
  }
1196
1248
  }
1197
1249
  }
1250
+ /**
1251
+ * Re-run the subscribe handshake for one already-tracked room. Shared by
1252
+ * reconnect resubscription (`resubscribeAll`) and the #661
1253
+ * `subscription-invalidated` handler.
1254
+ *
1255
+ * - Deduped per room via `resubscribingRooms` so the duplicate invalidation
1256
+ * events the gateway emits (one per evicted room) collapse to one handshake.
1257
+ * - Snapshots the room's generation; the ack acts only if it is still current,
1258
+ * so a late ack cannot clobber an unsubscribe/re-subscribe that happened in
1259
+ * the meantime — and a success that arrives after the room was removed emits
1260
+ * a compensating `unsubscribe` so no ghost server room is left behind.
1261
+ * - On a genuine denial the subscription is evicted and its stored `onError`
1262
+ * is called (with the typed `.code`), instead of silently going quiet.
1263
+ */
1264
+ resubscribeRoom(room) {
1265
+ if (!this.socket) return;
1266
+ if (this.resubscribingRooms.has(room)) return;
1267
+ if (!this.subscriptions.has(room)) return;
1268
+ const parsed = this.parseRoom(room);
1269
+ if (!parsed) return;
1270
+ const { projectId, collectionName } = parsed;
1271
+ const where = this.roomWhereMap.get(room);
1272
+ const generation = this.roomGeneration.get(room) ?? 0;
1273
+ const payload = { projectId, collectionName };
1274
+ if (where) payload["where"] = where;
1275
+ this.resubscribingRooms.add(room);
1276
+ this.socket.emit(
1277
+ "subscribe",
1278
+ payload,
1279
+ (response) => {
1280
+ this.resubscribingRooms.delete(room);
1281
+ if ((this.roomGeneration.get(room) ?? 0) !== generation) {
1282
+ if (!this.subscriptions.has(room) && !response?.error) {
1283
+ this.socket?.emit("unsubscribe", payload);
1284
+ }
1285
+ return;
1286
+ }
1287
+ if (response?.error) {
1288
+ const error = this.toSubscribeError(response);
1289
+ this.notifyRoomError(room, error);
1290
+ this.subscriptions.delete(room);
1291
+ this.roomWhereMap.delete(room);
1292
+ this.roomErrorMap.delete(room);
1293
+ this.roomGeneration.delete(room);
1294
+ }
1295
+ }
1296
+ );
1297
+ }
1198
1298
  emitSubscribeEvents(state) {
1199
1299
  return new Promise((resolve) => {
1200
1300
  if (!this.socket) {
@@ -1770,9 +1870,11 @@ var AuthModule = class {
1770
1870
  }
1771
1871
  async storeTokensFromRegister(response) {
1772
1872
  if (!response.access_token) return;
1873
+ const expiresAt = response.expires_in ? Math.floor(Date.now() / 1e3) + response.expires_in : void 0;
1773
1874
  await this.tokenManager.setTokens({
1774
1875
  accessToken: response.access_token,
1775
- refreshToken: response.refresh_token
1876
+ refreshToken: response.refresh_token,
1877
+ expiresAt
1776
1878
  });
1777
1879
  this.emitState("authenticated");
1778
1880
  }