@spacelr/sdk 0.10.1 → 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
@@ -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 {
@@ -687,6 +693,32 @@ declare class RealtimeClient {
687
693
  onConnectionStateChanged(listener: (state: ConnectionState) => void): () => void;
688
694
  private setConnectionState;
689
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;
690
722
  private ensureConnected;
691
723
  private connect;
692
724
  private eventMatchesRoom;
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 {
@@ -687,6 +693,32 @@ declare class RealtimeClient {
687
693
  onConnectionStateChanged(listener: (state: ConnectionState) => void): () => void;
688
694
  private setConnectionState;
689
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;
690
722
  private ensureConnected;
691
723
  private connect;
692
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
 
@@ -1870,9 +1930,11 @@ var AuthModule = class {
1870
1930
  }
1871
1931
  async storeTokensFromRegister(response) {
1872
1932
  if (!response.access_token) return;
1933
+ const expiresAt = response.expires_in ? Math.floor(Date.now() / 1e3) + response.expires_in : void 0;
1873
1934
  await this.tokenManager.setTokens({
1874
1935
  accessToken: response.access_token,
1875
- refreshToken: response.refresh_token
1936
+ refreshToken: response.refresh_token,
1937
+ expiresAt
1876
1938
  });
1877
1939
  this.emitState("authenticated");
1878
1940
  }