@spacelr/sdk 0.10.2 → 0.10.3

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
@@ -693,6 +693,32 @@ declare class RealtimeClient {
693
693
  onConnectionStateChanged(listener: (state: ConnectionState) => void): () => void;
694
694
  private setConnectionState;
695
695
  private buildRoomKey;
696
+ /**
697
+ * Stream subscription identity (#643): `streamKey` + canonical(`where`), so
698
+ * two `subscribeEvents` on the same collection with different filters get
699
+ * distinct handshakes/readers instead of one colliding on the bare streamKey.
700
+ * Empty/absent `where` keeps the bare streamKey. Same type-preserving sorted
701
+ * encoding as `buildRoomKey` / the gateway's `streamSubscriptionKey`; it is a
702
+ * client-local Map key only (never sent — the gateway derives its own).
703
+ */
704
+ private streamSubscriptionKey;
705
+ /**
706
+ * All per-filter subscription sets registered for one collection's stream
707
+ * (#643). The socket delivers events keyed only by `streamKey` (no `where`),
708
+ * so a dispatch / gap for `streamKey` must reach every filter variant:
709
+ * the bare `streamKey` entry plus any `${streamKey}?…` filtered entries.
710
+ */
711
+ private streamSetsForStreamKey;
712
+ /**
713
+ * Whether a stream event should reach a subscriber with this `where`. The
714
+ * server already filters doc-bearing events per per-filter reader, but events
715
+ * arrive on one socket without a `where`, so we re-filter to route each to the
716
+ * right subscriber (and as defense against desync/over-delivery). An event
717
+ * with no document (tombstone delete) can't be filtered — it is delivered to
718
+ * every subscriber on the collection, as the server forwards it to every
719
+ * reader. Empty `where` matches everything.
720
+ */
721
+ private eventMatchesStreamWhere;
696
722
  private ensureConnected;
697
723
  private connect;
698
724
  private eventMatchesRoom;
package/dist/index.d.ts CHANGED
@@ -693,6 +693,32 @@ declare class RealtimeClient {
693
693
  onConnectionStateChanged(listener: (state: ConnectionState) => void): () => void;
694
694
  private setConnectionState;
695
695
  private buildRoomKey;
696
+ /**
697
+ * Stream subscription identity (#643): `streamKey` + canonical(`where`), so
698
+ * two `subscribeEvents` on the same collection with different filters get
699
+ * distinct handshakes/readers instead of one colliding on the bare streamKey.
700
+ * Empty/absent `where` keeps the bare streamKey. Same type-preserving sorted
701
+ * encoding as `buildRoomKey` / the gateway's `streamSubscriptionKey`; it is a
702
+ * client-local Map key only (never sent — the gateway derives its own).
703
+ */
704
+ private streamSubscriptionKey;
705
+ /**
706
+ * All per-filter subscription sets registered for one collection's stream
707
+ * (#643). The socket delivers events keyed only by `streamKey` (no `where`),
708
+ * so a dispatch / gap for `streamKey` must reach every filter variant:
709
+ * the bare `streamKey` entry plus any `${streamKey}?…` filtered entries.
710
+ */
711
+ private streamSetsForStreamKey;
712
+ /**
713
+ * Whether a stream event should reach a subscriber with this `where`. The
714
+ * server already filters doc-bearing events per per-filter reader, but events
715
+ * arrive on one socket without a `where`, so we re-filter to route each to the
716
+ * right subscriber (and as defense against desync/over-delivery). An event
717
+ * with no document (tombstone delete) can't be filtered — it is delivered to
718
+ * every subscriber on the collection, as the server forwards it to every
719
+ * reader. Empty `where` matches everything.
720
+ */
721
+ private eventMatchesStreamWhere;
696
722
  private ensureConnected;
697
723
  private connect;
698
724
  private eventMatchesRoom;
package/dist/index.js CHANGED
@@ -883,13 +883,15 @@ var RealtimeClient = class {
883
883
  }
884
884
  await this.ensureConnected();
885
885
  const streamKey = `stream:${options.projectId}:${options.collectionName}`;
886
+ const subscriptionKey = this.streamSubscriptionKey(streamKey, options.where);
886
887
  const state = {
887
888
  options,
888
889
  lastDeliveredId: options.sinceId ?? null,
889
890
  streamKey,
891
+ subscriptionKey,
890
892
  dispatchQueue: Promise.resolve()
891
893
  };
892
- const existing = this.streamSubscriptions.get(streamKey);
894
+ const existing = this.streamSubscriptions.get(subscriptionKey);
893
895
  if (existing && existing.size > 0) {
894
896
  existing.add(state);
895
897
  const syntheticAck = {
@@ -904,13 +906,13 @@ var RealtimeClient = class {
904
906
  }
905
907
  const set = /* @__PURE__ */ new Set();
906
908
  set.add(state);
907
- this.streamSubscriptions.set(streamKey, set);
909
+ this.streamSubscriptions.set(subscriptionKey, set);
908
910
  try {
909
911
  const ack = await this.emitSubscribeEvents(state);
910
912
  if (ack.error) {
911
913
  const message = ack.message ?? ack.error;
912
914
  const err = new SpacelrError(message, ack.error);
913
- this.evictStreamKey(streamKey, err, state);
915
+ this.evictStreamKey(subscriptionKey, err, state);
914
916
  options.onError?.(err);
915
917
  throw err;
916
918
  }
@@ -920,7 +922,7 @@ var RealtimeClient = class {
920
922
  };
921
923
  } catch (err) {
922
924
  const error = err instanceof Error ? err : new Error(String(err));
923
- this.evictStreamKey(streamKey, error, state);
925
+ this.evictStreamKey(subscriptionKey, error, state);
924
926
  throw error;
925
927
  }
926
928
  }
@@ -979,6 +981,52 @@ var RealtimeClient = class {
979
981
  const filterStr = Object.keys(where).sort().map((k) => `${k}=${JSON.stringify(where[k])}`).join("&");
980
982
  return `${base}?${filterStr}`;
981
983
  }
984
+ /**
985
+ * Stream subscription identity (#643): `streamKey` + canonical(`where`), so
986
+ * two `subscribeEvents` on the same collection with different filters get
987
+ * distinct handshakes/readers instead of one colliding on the bare streamKey.
988
+ * Empty/absent `where` keeps the bare streamKey. Same type-preserving sorted
989
+ * encoding as `buildRoomKey` / the gateway's `streamSubscriptionKey`; it is a
990
+ * client-local Map key only (never sent — the gateway derives its own).
991
+ */
992
+ streamSubscriptionKey(streamKey, where) {
993
+ if (!where || Object.keys(where).length === 0) return streamKey;
994
+ const filterStr = Object.keys(where).sort().map((k) => `${k}=${JSON.stringify(where[k])}`).join("&");
995
+ return `${streamKey}?${filterStr}`;
996
+ }
997
+ /**
998
+ * All per-filter subscription sets registered for one collection's stream
999
+ * (#643). The socket delivers events keyed only by `streamKey` (no `where`),
1000
+ * so a dispatch / gap for `streamKey` must reach every filter variant:
1001
+ * the bare `streamKey` entry plus any `${streamKey}?…` filtered entries.
1002
+ */
1003
+ streamSetsForStreamKey(streamKey) {
1004
+ const sets = [];
1005
+ const prefix = `${streamKey}?`;
1006
+ for (const [key, set] of this.streamSubscriptions) {
1007
+ if (key === streamKey || key.startsWith(prefix)) sets.push(set);
1008
+ }
1009
+ return sets;
1010
+ }
1011
+ /**
1012
+ * Whether a stream event should reach a subscriber with this `where`. The
1013
+ * server already filters doc-bearing events per per-filter reader, but events
1014
+ * arrive on one socket without a `where`, so we re-filter to route each to the
1015
+ * right subscriber (and as defense against desync/over-delivery). An event
1016
+ * with no document (tombstone delete) can't be filtered — it is delivered to
1017
+ * every subscriber on the collection, as the server forwards it to every
1018
+ * reader. Empty `where` matches everything.
1019
+ */
1020
+ eventMatchesStreamWhere(event, where) {
1021
+ if (!where || Object.keys(where).length === 0) return true;
1022
+ if (!event.document) return true;
1023
+ for (const [key, value] of Object.entries(where)) {
1024
+ if (!whereValueMatches(resolveWherePath(event.document, key), value)) {
1025
+ return false;
1026
+ }
1027
+ }
1028
+ return true;
1029
+ }
982
1030
  async ensureConnected() {
983
1031
  if (this.socket?.connected) return;
984
1032
  if (this.connecting) {
@@ -1117,10 +1165,10 @@ var RealtimeClient = class {
1117
1165
  });
1118
1166
  this.socket.on("event-gap", (info) => {
1119
1167
  const streamKey = `stream:${info.projectId}:${info.collectionName}`;
1120
- const set = this.streamSubscriptions.get(streamKey);
1121
- if (!set) return;
1122
- for (const state of set) {
1123
- state.options.onGap?.(info);
1168
+ for (const set of this.streamSetsForStreamKey(streamKey)) {
1169
+ for (const state of set) {
1170
+ state.options.onGap?.(info);
1171
+ }
1124
1172
  }
1125
1173
  });
1126
1174
  this.socket.on("disconnect", () => {
@@ -1325,38 +1373,46 @@ var RealtimeClient = class {
1325
1373
  return payload;
1326
1374
  }
1327
1375
  unsubscribeStream(state) {
1328
- const set = this.streamSubscriptions.get(state.streamKey);
1376
+ const set = this.streamSubscriptions.get(state.subscriptionKey);
1329
1377
  if (!set) return;
1330
1378
  set.delete(state);
1331
1379
  if (set.size === 0) {
1332
- this.streamSubscriptions.delete(state.streamKey);
1333
- this.socket?.emit("unsubscribe-events", {
1380
+ this.streamSubscriptions.delete(state.subscriptionKey);
1381
+ const payload = {
1334
1382
  projectId: state.options.projectId,
1335
1383
  collectionName: state.options.collectionName
1336
- });
1384
+ };
1385
+ if (state.options.where && Object.keys(state.options.where).length > 0) {
1386
+ payload["where"] = state.options.where;
1387
+ }
1388
+ this.socket?.emit("unsubscribe-events", payload);
1337
1389
  }
1338
1390
  }
1339
1391
  dispatchStreamEvent(payload) {
1340
1392
  if (!payload.eventId) return Promise.resolve();
1341
1393
  const streamKey = `stream:${payload.projectId}:${payload.collectionName}`;
1342
- const set = this.streamSubscriptions.get(streamKey);
1343
- if (!set) return Promise.resolve();
1394
+ const sets = this.streamSetsForStreamKey(streamKey);
1395
+ if (sets.length === 0) return Promise.resolve();
1344
1396
  const entryId = payload.eventId;
1345
- for (const state of set) {
1346
- state.dispatchQueue = state.dispatchQueue.then(async () => {
1347
- const liveSet = this.streamSubscriptions.get(state.streamKey);
1348
- if (!liveSet?.has(state)) return;
1349
- try {
1350
- await state.options.onEvent({ eventId: entryId, event: payload });
1351
- state.lastDeliveredId = entryId;
1352
- } catch (err) {
1353
- const error = err instanceof Error ? err : new Error(String(err));
1397
+ for (const set of sets) {
1398
+ for (const state of set) {
1399
+ if (!this.eventMatchesStreamWhere(payload, state.options.where)) continue;
1400
+ state.dispatchQueue = state.dispatchQueue.then(async () => {
1401
+ const liveSet = this.streamSubscriptions.get(state.subscriptionKey);
1402
+ if (!liveSet?.has(state)) return;
1403
+ if (state.lastDeliveredId === entryId) return;
1354
1404
  try {
1355
- state.options.onError?.(error);
1356
- } catch {
1405
+ await state.options.onEvent({ eventId: entryId, event: payload });
1406
+ state.lastDeliveredId = entryId;
1407
+ } catch (err) {
1408
+ const error = err instanceof Error ? err : new Error(String(err));
1409
+ try {
1410
+ state.options.onError?.(error);
1411
+ } catch {
1412
+ }
1357
1413
  }
1358
- }
1359
- });
1414
+ });
1415
+ }
1360
1416
  }
1361
1417
  return Promise.resolve();
1362
1418
  }
@@ -1382,15 +1438,19 @@ var RealtimeClient = class {
1382
1438
  state.options.onError?.(err);
1383
1439
  }
1384
1440
  if (PERMANENT_STREAM_ACK_ERRORS.has(ack.error)) {
1385
- this.streamSubscriptions.delete(primary.streamKey);
1441
+ this.streamSubscriptions.delete(primary.subscriptionKey);
1386
1442
  }
1387
1443
  return;
1388
1444
  }
1389
- if (!this.streamSubscriptions.has(primary.streamKey)) {
1390
- this.socket?.emit("unsubscribe-events", {
1445
+ if (!this.streamSubscriptions.has(primary.subscriptionKey)) {
1446
+ const payload = {
1391
1447
  projectId: primary.options.projectId,
1392
1448
  collectionName: primary.options.collectionName
1393
- });
1449
+ };
1450
+ if (primary.options.where && Object.keys(primary.options.where).length > 0) {
1451
+ payload["where"] = primary.options.where;
1452
+ }
1453
+ this.socket?.emit("unsubscribe-events", payload);
1394
1454
  }
1395
1455
  } catch (err) {
1396
1456
  const error = err instanceof Error ? err : new Error(String(err));
@@ -1422,8 +1482,8 @@ var RealtimeClient = class {
1422
1482
  * streamKey via the dedup path. Fires `onError` on all siblings, then
1423
1483
  * removes the entire streamKey entry from `streamSubscriptions`.
1424
1484
  */
1425
- evictStreamKey(streamKey, err, primary) {
1426
- const set = this.streamSubscriptions.get(streamKey);
1485
+ evictStreamKey(subscriptionKey, err, primary) {
1486
+ const set = this.streamSubscriptions.get(subscriptionKey);
1427
1487
  if (!set) return;
1428
1488
  for (const sibling of [...set]) {
1429
1489
  if (sibling !== primary) {
@@ -1431,7 +1491,7 @@ var RealtimeClient = class {
1431
1491
  }
1432
1492
  set.delete(sibling);
1433
1493
  }
1434
- this.streamSubscriptions.delete(streamKey);
1494
+ this.streamSubscriptions.delete(subscriptionKey);
1435
1495
  }
1436
1496
  };
1437
1497