@spacelr/sdk 0.9.2 → 0.10.1

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.mjs CHANGED
@@ -614,6 +614,57 @@ var PERMANENT_STREAM_ACK_ERRORS = /* @__PURE__ */ new Set([
614
614
  "Not a member of this project",
615
615
  "Subscribe denied"
616
616
  ]);
617
+ function isWhereScalar(value) {
618
+ const t = typeof value;
619
+ return t === "string" || t === "number" || t === "boolean";
620
+ }
621
+ function isWhereOperator(value) {
622
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
623
+ return false;
624
+ }
625
+ const keys = Object.keys(value);
626
+ if (keys.length !== 1) return false;
627
+ const op = keys[0];
628
+ if (op !== "in" && op !== "array-contains-any") return false;
629
+ const candidates = value[op];
630
+ return Array.isArray(candidates) && candidates.every(isWhereScalar);
631
+ }
632
+ var WHERE_PATH_ABSENT = /* @__PURE__ */ Symbol("where-path-absent");
633
+ var WHERE_PATH_UNRESOLVABLE = /* @__PURE__ */ Symbol("where-path-unresolvable");
634
+ var RESERVED_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
635
+ var MAX_WHERE_PATH_SEGMENTS = 16;
636
+ function resolveWherePath(document2, key) {
637
+ const segments = key.split(".");
638
+ if (segments.length > MAX_WHERE_PATH_SEGMENTS) return WHERE_PATH_ABSENT;
639
+ let cur = document2;
640
+ for (const segment of segments) {
641
+ if (RESERVED_PATH_SEGMENTS.has(segment)) return WHERE_PATH_ABSENT;
642
+ if (cur === null || cur === void 0) return WHERE_PATH_ABSENT;
643
+ if (Array.isArray(cur)) return WHERE_PATH_UNRESOLVABLE;
644
+ if (typeof cur !== "object") return WHERE_PATH_ABSENT;
645
+ if (!Object.prototype.hasOwnProperty.call(cur, segment)) {
646
+ return WHERE_PATH_ABSENT;
647
+ }
648
+ cur = cur[segment];
649
+ }
650
+ return cur === void 0 ? WHERE_PATH_ABSENT : cur;
651
+ }
652
+ function whereValueMatches(actual, expected) {
653
+ if (actual === WHERE_PATH_ABSENT || actual === WHERE_PATH_UNRESOLVABLE) {
654
+ return false;
655
+ }
656
+ if (isWhereOperator(expected)) {
657
+ if ("in" in expected) {
658
+ if (Array.isArray(actual)) return false;
659
+ return expected.in.includes(actual);
660
+ }
661
+ if (!Array.isArray(actual)) return false;
662
+ const candidates = expected["array-contains-any"];
663
+ return actual.some((el) => candidates.includes(el));
664
+ }
665
+ if (Array.isArray(actual)) return actual.includes(expected);
666
+ return actual === expected;
667
+ }
617
668
  var RealtimeClient = class {
618
669
  constructor(config) {
619
670
  this.socket = null;
@@ -621,6 +672,21 @@ var RealtimeClient = class {
621
672
  this.connecting = null;
622
673
  // Store original where objects per room for reconnect resubscription
623
674
  this.roomWhereMap = /* @__PURE__ */ new Map();
675
+ // Per-room, per-callback error handlers so EVERY subscriber to a room (not
676
+ // just the first) is notified on a denial, and a departed subscriber's handler
677
+ // is never left dangling for its still-active siblings (#661).
678
+ this.roomErrorMap = /* @__PURE__ */ new Map();
679
+ // Per-room generation stamp, taken from a global monotonic counter each time a
680
+ // room becomes active. A re-subscribe snapshots the room's stamp and acts on
681
+ // the ack only if it is still current — so a late ack cannot notify or clean
682
+ // up after the app has unsubscribed/re-subscribed (#661). Entries are deleted
683
+ // on last-unsubscribe (bounded); the global counter guarantees a re-subscribe
684
+ // always gets a strictly higher stamp, so there is no ABA hole.
685
+ this.roomGeneration = /* @__PURE__ */ new Map();
686
+ this.subEpoch = 0;
687
+ // Rooms with a re-subscribe handshake in flight — dedupes the duplicate
688
+ // `subscription-invalidated` events the gateway emits per evicted room (#661).
689
+ this.resubscribingRooms = /* @__PURE__ */ new Set();
624
690
  this.unsubscribeFromTokenRefreshed = null;
625
691
  // Wake-up listeners (browser only) — recover from long OS suspends where
626
692
  // socket.io's internal reconnect loop may have already given up.
@@ -649,7 +715,17 @@ var RealtimeClient = class {
649
715
  }
650
716
  const callbacks = this.subscriptions.get(room);
651
717
  callbacks?.add(callback);
718
+ if (onError) {
719
+ let handlers = this.roomErrorMap.get(room);
720
+ if (!handlers) {
721
+ handlers = /* @__PURE__ */ new Map();
722
+ this.roomErrorMap.set(room, handlers);
723
+ }
724
+ handlers.set(callback, onError);
725
+ }
652
726
  if (callbacks?.size === 1) {
727
+ const generation = ++this.subEpoch;
728
+ this.roomGeneration.set(room, generation);
653
729
  if (where && Object.keys(where).length > 0) {
654
730
  this.roomWhereMap.set(room, where);
655
731
  }
@@ -661,8 +737,8 @@ var RealtimeClient = class {
661
737
  "subscribe",
662
738
  payload,
663
739
  (response) => {
664
- if (response.error && onError) {
665
- onError(new Error(response.error));
740
+ if (response.error && this.roomGeneration.get(room) === generation) {
741
+ this.notifyRoomError(room, this.toSubscribeError(response));
666
742
  }
667
743
  }
668
744
  );
@@ -671,9 +747,12 @@ var RealtimeClient = class {
671
747
  const callbacks2 = this.subscriptions.get(room);
672
748
  if (callbacks2) {
673
749
  callbacks2.delete(callback);
750
+ this.roomErrorMap.get(room)?.delete(callback);
674
751
  if (callbacks2.size === 0) {
675
752
  this.subscriptions.delete(room);
676
753
  this.roomWhereMap.delete(room);
754
+ this.roomErrorMap.delete(room);
755
+ this.roomGeneration.delete(room);
677
756
  const payload = { projectId, collectionName };
678
757
  if (where && Object.keys(where).length > 0) {
679
758
  payload["where"] = where;
@@ -826,6 +905,9 @@ var RealtimeClient = class {
826
905
  }
827
906
  this.subscriptions.clear();
828
907
  this.roomWhereMap.clear();
908
+ this.roomErrorMap.clear();
909
+ this.roomGeneration.clear();
910
+ this.resubscribingRooms.clear();
829
911
  this.streamSubscriptions.clear();
830
912
  this.connecting = null;
831
913
  }
@@ -975,6 +1057,18 @@ var RealtimeClient = class {
975
1057
  }
976
1058
  }
977
1059
  });
1060
+ this.socket.on(
1061
+ "subscription-invalidated",
1062
+ (payload) => {
1063
+ if (!payload?.projectId || !payload?.collectionName) return;
1064
+ const base = `db:${payload.projectId}:${payload.collectionName}`;
1065
+ for (const room of this.subscriptions.keys()) {
1066
+ if (room === base || room.startsWith(`${base}?`)) {
1067
+ this.resubscribeRoom(room);
1068
+ }
1069
+ }
1070
+ }
1071
+ );
978
1072
  this.socket.on("event", (payload) => {
979
1073
  this.dispatchStreamEvent(payload).catch(() => void 0);
980
1074
  });
@@ -1002,12 +1096,7 @@ var RealtimeClient = class {
1002
1096
  if (!where) return false;
1003
1097
  if (!event.document) return false;
1004
1098
  for (const [key, value] of Object.entries(where)) {
1005
- const docValue = event.document[key];
1006
- if (Array.isArray(docValue)) {
1007
- if (!docValue.includes(value)) {
1008
- return false;
1009
- }
1010
- } else if (docValue !== value) {
1099
+ if (!whereValueMatches(resolveWherePath(event.document, key), value)) {
1011
1100
  return false;
1012
1101
  }
1013
1102
  }
@@ -1081,31 +1170,88 @@ var RealtimeClient = class {
1081
1170
  this.onOnline = null;
1082
1171
  }
1083
1172
  resubscribeAll() {
1173
+ this.resubscribingRooms.clear();
1084
1174
  for (const [room] of this.subscriptions) {
1085
- const queryIdx = room.indexOf("?");
1086
- const basePart = queryIdx >= 0 ? room.substring(0, queryIdx) : room;
1087
- const parts = basePart.split(":");
1088
- if (parts.length >= 3) {
1089
- const projectId = parts[1];
1090
- const collectionName = parts.slice(2).join(":");
1091
- const payload = { projectId, collectionName };
1092
- const where = this.roomWhereMap.get(room);
1093
- if (where) {
1094
- payload["where"] = where;
1095
- }
1096
- this.socket?.emit(
1097
- "subscribe",
1098
- payload,
1099
- (response) => {
1100
- if (response?.error) {
1101
- this.subscriptions.delete(room);
1102
- this.roomWhereMap.delete(room);
1103
- }
1104
- }
1105
- );
1175
+ this.resubscribeRoom(room);
1176
+ }
1177
+ }
1178
+ /**
1179
+ * Parse a pubsub room key back into its projectId + collectionName.
1180
+ * Room format: `db:{projectId}:{collectionName}` or that base plus `?filter`.
1181
+ * Returns null for anything that isn't a well-formed `db:` room.
1182
+ */
1183
+ parseRoom(room) {
1184
+ const queryIdx = room.indexOf("?");
1185
+ const basePart = queryIdx >= 0 ? room.substring(0, queryIdx) : room;
1186
+ const parts = basePart.split(":");
1187
+ if (parts.length < 3 || parts[0] !== "db") return null;
1188
+ return { projectId: parts[1], collectionName: parts.slice(2).join(":") };
1189
+ }
1190
+ /** Build an Error carrying the gateway's typed `errorCode` (if any) as `.code`. */
1191
+ toSubscribeError(response) {
1192
+ const err = new Error(response.error ?? "Subscribe denied");
1193
+ if (response.errorCode) err.code = response.errorCode;
1194
+ return err;
1195
+ }
1196
+ /** Call every subscriber's error handler registered for `room` (#661). */
1197
+ notifyRoomError(room, error) {
1198
+ const handlers = this.roomErrorMap.get(room);
1199
+ if (!handlers) return;
1200
+ for (const handler of handlers.values()) {
1201
+ try {
1202
+ handler(error);
1203
+ } catch {
1106
1204
  }
1107
1205
  }
1108
1206
  }
1207
+ /**
1208
+ * Re-run the subscribe handshake for one already-tracked room. Shared by
1209
+ * reconnect resubscription (`resubscribeAll`) and the #661
1210
+ * `subscription-invalidated` handler.
1211
+ *
1212
+ * - Deduped per room via `resubscribingRooms` so the duplicate invalidation
1213
+ * events the gateway emits (one per evicted room) collapse to one handshake.
1214
+ * - Snapshots the room's generation; the ack acts only if it is still current,
1215
+ * so a late ack cannot clobber an unsubscribe/re-subscribe that happened in
1216
+ * the meantime — and a success that arrives after the room was removed emits
1217
+ * a compensating `unsubscribe` so no ghost server room is left behind.
1218
+ * - On a genuine denial the subscription is evicted and its stored `onError`
1219
+ * is called (with the typed `.code`), instead of silently going quiet.
1220
+ */
1221
+ resubscribeRoom(room) {
1222
+ if (!this.socket) return;
1223
+ if (this.resubscribingRooms.has(room)) return;
1224
+ if (!this.subscriptions.has(room)) return;
1225
+ const parsed = this.parseRoom(room);
1226
+ if (!parsed) return;
1227
+ const { projectId, collectionName } = parsed;
1228
+ const where = this.roomWhereMap.get(room);
1229
+ const generation = this.roomGeneration.get(room) ?? 0;
1230
+ const payload = { projectId, collectionName };
1231
+ if (where) payload["where"] = where;
1232
+ this.resubscribingRooms.add(room);
1233
+ this.socket.emit(
1234
+ "subscribe",
1235
+ payload,
1236
+ (response) => {
1237
+ this.resubscribingRooms.delete(room);
1238
+ if ((this.roomGeneration.get(room) ?? 0) !== generation) {
1239
+ if (!this.subscriptions.has(room) && !response?.error) {
1240
+ this.socket?.emit("unsubscribe", payload);
1241
+ }
1242
+ return;
1243
+ }
1244
+ if (response?.error) {
1245
+ const error = this.toSubscribeError(response);
1246
+ this.notifyRoomError(room, error);
1247
+ this.subscriptions.delete(room);
1248
+ this.roomWhereMap.delete(room);
1249
+ this.roomErrorMap.delete(room);
1250
+ this.roomGeneration.delete(room);
1251
+ }
1252
+ }
1253
+ );
1254
+ }
1109
1255
  emitSubscribeEvents(state) {
1110
1256
  return new Promise((resolve) => {
1111
1257
  if (!this.socket) {
@@ -2968,10 +3114,14 @@ var FunctionsModule = class {
2968
3114
  * `config.apiUrl` (which already carries the `/api/v1` prefix).
2969
3115
  *
2970
3116
  * Auth defaults, based on `invokeMode` semantics:
2971
- * - webhook: pass `secret` → Authorization is NOT attached
2972
- * - authenticated: pass nothing → Authorization IS attached (from token manager)
2973
- * - public: pass nothing → Authorization is attached if logged in, else omitted
2974
- * - hybrid: pass both `secret` and `authenticated: true`
3117
+ * - webhook: pass `secret` → Authorization is NOT attached
3118
+ * - authenticated: pass nothing → Authorization IS attached (from token manager);
3119
+ * caller must be a member of the target project
3120
+ * - public: pass nothing → Authorization is attached if logged in, else omitted
3121
+ * - hybrid: pass both `secret` and `authenticated: true`; JWT path
3122
+ * requires project membership like `authenticated`
3123
+ * - platform-authenticated: pass nothing → Authorization IS attached (from token manager);
3124
+ * any signed-in user is accepted, still just attaches the bearer token
2975
3125
  *
2976
3126
  * To force a specific behaviour, set `authenticated` explicitly — it wins
2977
3127
  * over the `secret`-based default.