@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.mjs CHANGED
@@ -840,13 +840,15 @@ var RealtimeClient = class {
840
840
  }
841
841
  await this.ensureConnected();
842
842
  const streamKey = `stream:${options.projectId}:${options.collectionName}`;
843
+ const subscriptionKey = this.streamSubscriptionKey(streamKey, options.where);
843
844
  const state = {
844
845
  options,
845
846
  lastDeliveredId: options.sinceId ?? null,
846
847
  streamKey,
848
+ subscriptionKey,
847
849
  dispatchQueue: Promise.resolve()
848
850
  };
849
- const existing = this.streamSubscriptions.get(streamKey);
851
+ const existing = this.streamSubscriptions.get(subscriptionKey);
850
852
  if (existing && existing.size > 0) {
851
853
  existing.add(state);
852
854
  const syntheticAck = {
@@ -861,13 +863,13 @@ var RealtimeClient = class {
861
863
  }
862
864
  const set = /* @__PURE__ */ new Set();
863
865
  set.add(state);
864
- this.streamSubscriptions.set(streamKey, set);
866
+ this.streamSubscriptions.set(subscriptionKey, set);
865
867
  try {
866
868
  const ack = await this.emitSubscribeEvents(state);
867
869
  if (ack.error) {
868
870
  const message = ack.message ?? ack.error;
869
871
  const err = new SpacelrError(message, ack.error);
870
- this.evictStreamKey(streamKey, err, state);
872
+ this.evictStreamKey(subscriptionKey, err, state);
871
873
  options.onError?.(err);
872
874
  throw err;
873
875
  }
@@ -877,7 +879,7 @@ var RealtimeClient = class {
877
879
  };
878
880
  } catch (err) {
879
881
  const error = err instanceof Error ? err : new Error(String(err));
880
- this.evictStreamKey(streamKey, error, state);
882
+ this.evictStreamKey(subscriptionKey, error, state);
881
883
  throw error;
882
884
  }
883
885
  }
@@ -936,6 +938,52 @@ var RealtimeClient = class {
936
938
  const filterStr = Object.keys(where).sort().map((k) => `${k}=${JSON.stringify(where[k])}`).join("&");
937
939
  return `${base}?${filterStr}`;
938
940
  }
941
+ /**
942
+ * Stream subscription identity (#643): `streamKey` + canonical(`where`), so
943
+ * two `subscribeEvents` on the same collection with different filters get
944
+ * distinct handshakes/readers instead of one colliding on the bare streamKey.
945
+ * Empty/absent `where` keeps the bare streamKey. Same type-preserving sorted
946
+ * encoding as `buildRoomKey` / the gateway's `streamSubscriptionKey`; it is a
947
+ * client-local Map key only (never sent — the gateway derives its own).
948
+ */
949
+ streamSubscriptionKey(streamKey, where) {
950
+ if (!where || Object.keys(where).length === 0) return streamKey;
951
+ const filterStr = Object.keys(where).sort().map((k) => `${k}=${JSON.stringify(where[k])}`).join("&");
952
+ return `${streamKey}?${filterStr}`;
953
+ }
954
+ /**
955
+ * All per-filter subscription sets registered for one collection's stream
956
+ * (#643). The socket delivers events keyed only by `streamKey` (no `where`),
957
+ * so a dispatch / gap for `streamKey` must reach every filter variant:
958
+ * the bare `streamKey` entry plus any `${streamKey}?…` filtered entries.
959
+ */
960
+ streamSetsForStreamKey(streamKey) {
961
+ const sets = [];
962
+ const prefix = `${streamKey}?`;
963
+ for (const [key, set] of this.streamSubscriptions) {
964
+ if (key === streamKey || key.startsWith(prefix)) sets.push(set);
965
+ }
966
+ return sets;
967
+ }
968
+ /**
969
+ * Whether a stream event should reach a subscriber with this `where`. The
970
+ * server already filters doc-bearing events per per-filter reader, but events
971
+ * arrive on one socket without a `where`, so we re-filter to route each to the
972
+ * right subscriber (and as defense against desync/over-delivery). An event
973
+ * with no document (tombstone delete) can't be filtered — it is delivered to
974
+ * every subscriber on the collection, as the server forwards it to every
975
+ * reader. Empty `where` matches everything.
976
+ */
977
+ eventMatchesStreamWhere(event, where) {
978
+ if (!where || Object.keys(where).length === 0) return true;
979
+ if (!event.document) return true;
980
+ for (const [key, value] of Object.entries(where)) {
981
+ if (!whereValueMatches(resolveWherePath(event.document, key), value)) {
982
+ return false;
983
+ }
984
+ }
985
+ return true;
986
+ }
939
987
  async ensureConnected() {
940
988
  if (this.socket?.connected) return;
941
989
  if (this.connecting) {
@@ -1074,10 +1122,10 @@ var RealtimeClient = class {
1074
1122
  });
1075
1123
  this.socket.on("event-gap", (info) => {
1076
1124
  const streamKey = `stream:${info.projectId}:${info.collectionName}`;
1077
- const set = this.streamSubscriptions.get(streamKey);
1078
- if (!set) return;
1079
- for (const state of set) {
1080
- state.options.onGap?.(info);
1125
+ for (const set of this.streamSetsForStreamKey(streamKey)) {
1126
+ for (const state of set) {
1127
+ state.options.onGap?.(info);
1128
+ }
1081
1129
  }
1082
1130
  });
1083
1131
  this.socket.on("disconnect", () => {
@@ -1282,38 +1330,46 @@ var RealtimeClient = class {
1282
1330
  return payload;
1283
1331
  }
1284
1332
  unsubscribeStream(state) {
1285
- const set = this.streamSubscriptions.get(state.streamKey);
1333
+ const set = this.streamSubscriptions.get(state.subscriptionKey);
1286
1334
  if (!set) return;
1287
1335
  set.delete(state);
1288
1336
  if (set.size === 0) {
1289
- this.streamSubscriptions.delete(state.streamKey);
1290
- this.socket?.emit("unsubscribe-events", {
1337
+ this.streamSubscriptions.delete(state.subscriptionKey);
1338
+ const payload = {
1291
1339
  projectId: state.options.projectId,
1292
1340
  collectionName: state.options.collectionName
1293
- });
1341
+ };
1342
+ if (state.options.where && Object.keys(state.options.where).length > 0) {
1343
+ payload["where"] = state.options.where;
1344
+ }
1345
+ this.socket?.emit("unsubscribe-events", payload);
1294
1346
  }
1295
1347
  }
1296
1348
  dispatchStreamEvent(payload) {
1297
1349
  if (!payload.eventId) return Promise.resolve();
1298
1350
  const streamKey = `stream:${payload.projectId}:${payload.collectionName}`;
1299
- const set = this.streamSubscriptions.get(streamKey);
1300
- if (!set) return Promise.resolve();
1351
+ const sets = this.streamSetsForStreamKey(streamKey);
1352
+ if (sets.length === 0) return Promise.resolve();
1301
1353
  const entryId = payload.eventId;
1302
- for (const state of set) {
1303
- state.dispatchQueue = state.dispatchQueue.then(async () => {
1304
- const liveSet = this.streamSubscriptions.get(state.streamKey);
1305
- if (!liveSet?.has(state)) return;
1306
- try {
1307
- await state.options.onEvent({ eventId: entryId, event: payload });
1308
- state.lastDeliveredId = entryId;
1309
- } catch (err) {
1310
- const error = err instanceof Error ? err : new Error(String(err));
1354
+ for (const set of sets) {
1355
+ for (const state of set) {
1356
+ if (!this.eventMatchesStreamWhere(payload, state.options.where)) continue;
1357
+ state.dispatchQueue = state.dispatchQueue.then(async () => {
1358
+ const liveSet = this.streamSubscriptions.get(state.subscriptionKey);
1359
+ if (!liveSet?.has(state)) return;
1360
+ if (state.lastDeliveredId === entryId) return;
1311
1361
  try {
1312
- state.options.onError?.(error);
1313
- } catch {
1362
+ await state.options.onEvent({ eventId: entryId, event: payload });
1363
+ state.lastDeliveredId = entryId;
1364
+ } catch (err) {
1365
+ const error = err instanceof Error ? err : new Error(String(err));
1366
+ try {
1367
+ state.options.onError?.(error);
1368
+ } catch {
1369
+ }
1314
1370
  }
1315
- }
1316
- });
1371
+ });
1372
+ }
1317
1373
  }
1318
1374
  return Promise.resolve();
1319
1375
  }
@@ -1339,15 +1395,19 @@ var RealtimeClient = class {
1339
1395
  state.options.onError?.(err);
1340
1396
  }
1341
1397
  if (PERMANENT_STREAM_ACK_ERRORS.has(ack.error)) {
1342
- this.streamSubscriptions.delete(primary.streamKey);
1398
+ this.streamSubscriptions.delete(primary.subscriptionKey);
1343
1399
  }
1344
1400
  return;
1345
1401
  }
1346
- if (!this.streamSubscriptions.has(primary.streamKey)) {
1347
- this.socket?.emit("unsubscribe-events", {
1402
+ if (!this.streamSubscriptions.has(primary.subscriptionKey)) {
1403
+ const payload = {
1348
1404
  projectId: primary.options.projectId,
1349
1405
  collectionName: primary.options.collectionName
1350
- });
1406
+ };
1407
+ if (primary.options.where && Object.keys(primary.options.where).length > 0) {
1408
+ payload["where"] = primary.options.where;
1409
+ }
1410
+ this.socket?.emit("unsubscribe-events", payload);
1351
1411
  }
1352
1412
  } catch (err) {
1353
1413
  const error = err instanceof Error ? err : new Error(String(err));
@@ -1379,8 +1439,8 @@ var RealtimeClient = class {
1379
1439
  * streamKey via the dedup path. Fires `onError` on all siblings, then
1380
1440
  * removes the entire streamKey entry from `streamSubscriptions`.
1381
1441
  */
1382
- evictStreamKey(streamKey, err, primary) {
1383
- const set = this.streamSubscriptions.get(streamKey);
1442
+ evictStreamKey(subscriptionKey, err, primary) {
1443
+ const set = this.streamSubscriptions.get(subscriptionKey);
1384
1444
  if (!set) return;
1385
1445
  for (const sibling of [...set]) {
1386
1446
  if (sibling !== primary) {
@@ -1388,7 +1448,7 @@ var RealtimeClient = class {
1388
1448
  }
1389
1449
  set.delete(sibling);
1390
1450
  }
1391
- this.streamSubscriptions.delete(streamKey);
1451
+ this.streamSubscriptions.delete(subscriptionKey);
1392
1452
  }
1393
1453
  };
1394
1454