@spacelr/sdk 0.1.10 → 0.2.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
@@ -378,7 +378,9 @@ var TokenManager = class {
378
378
  return tokens.accessToken;
379
379
  }
380
380
  async setTokens(tokens) {
381
- await this.storage.setTokens(tokens);
381
+ const needsDefault = tokens.expiresAt === void 0 && !!tokens.refreshToken;
382
+ const normalised = needsDefault ? { ...tokens, expiresAt: Math.floor(Date.now() / 1e3) } : tokens;
383
+ await this.storage.setTokens(normalised);
382
384
  this.authLostEmitted = false;
383
385
  }
384
386
  async clearTokens() {
@@ -543,6 +545,13 @@ async function generatePKCEChallenge() {
543
545
  // libs/sdk/src/core/realtime.ts
544
546
  import { io } from "socket.io-client";
545
547
  var REBUILD_RETRY_DELAY_MS = 5e3;
548
+ var PERMANENT_STREAM_ACK_ERRORS = /* @__PURE__ */ new Set([
549
+ "not-stream-collection",
550
+ "Collection not found",
551
+ "Invalid sinceId format",
552
+ "Not a member of this project",
553
+ "Subscribe denied"
554
+ ]);
546
555
  var RealtimeClient = class {
547
556
  constructor(config) {
548
557
  this.socket = null;
@@ -563,6 +572,7 @@ var RealtimeClient = class {
563
572
  this.rebuildRetryTimer = null;
564
573
  this.connectionState = "disconnected";
565
574
  this.connectionStateListeners = /* @__PURE__ */ new Set();
575
+ this.streamSubscriptions = /* @__PURE__ */ new Map();
566
576
  this.config = config;
567
577
  }
568
578
  async subscribe(projectId, collectionName, callback, onError, where) {
@@ -611,6 +621,53 @@ var RealtimeClient = class {
611
621
  }
612
622
  };
613
623
  }
624
+ /**
625
+ * Subscribe to a stream-mode collection using Redis Streams replay +
626
+ * cursor-based delivery. Parallel to `subscribe()` (which targets
627
+ * pubsub-mode collections). The server validates that the collection is
628
+ * configured in stream mode and rejects the handshake otherwise.
629
+ *
630
+ * The per-subscription cursor (`lastDeliveredId`) advances after each
631
+ * `onEvent` promise resolves, so reconnects resume from the last delivered
632
+ * event rather than the originally-subscribed `sinceId`.
633
+ */
634
+ async subscribeWithCursor(options) {
635
+ this.ensureWakeListeners();
636
+ if (this.connectionState === "disconnected") {
637
+ this.setConnectionState("reconnecting");
638
+ }
639
+ await this.ensureConnected();
640
+ const streamKey = `stream:${options.projectId}:${options.collectionName}`;
641
+ const state = {
642
+ options,
643
+ lastDeliveredId: options.sinceId ?? null,
644
+ streamKey,
645
+ dispatchQueue: Promise.resolve()
646
+ };
647
+ const existing = this.streamSubscriptions.get(streamKey);
648
+ if (existing && existing.size > 0) {
649
+ existing.add(state);
650
+ return () => this.unsubscribeStream(state);
651
+ }
652
+ const set = /* @__PURE__ */ new Set();
653
+ set.add(state);
654
+ this.streamSubscriptions.set(streamKey, set);
655
+ try {
656
+ const ack = await this.emitSubscribeEvents(state);
657
+ if (ack.error) {
658
+ const message = ack.message ?? ack.error;
659
+ const err = new Error(message);
660
+ this.evictStreamKey(streamKey, err, state);
661
+ options.onError?.(err);
662
+ throw err;
663
+ }
664
+ return () => this.unsubscribeStream(state);
665
+ } catch (err) {
666
+ const error = err instanceof Error ? err : new Error(String(err));
667
+ this.evictStreamKey(streamKey, error, state);
668
+ throw error;
669
+ }
670
+ }
614
671
  /**
615
672
  * Tear down the realtime client permanently. After this call the instance
616
673
  * is disposed — subsequent `subscribe()` calls will not re-establish a
@@ -635,6 +692,7 @@ var RealtimeClient = class {
635
692
  }
636
693
  this.subscriptions.clear();
637
694
  this.roomWhereMap.clear();
695
+ this.streamSubscriptions.clear();
638
696
  this.connecting = null;
639
697
  }
640
698
  getConnectionState() {
@@ -716,6 +774,7 @@ var RealtimeClient = class {
716
774
  resolve();
717
775
  } else {
718
776
  this.resubscribeAll();
777
+ void this.resubscribeAllStreams();
719
778
  }
720
779
  });
721
780
  this.socket.on("connect_error", (err) => {
@@ -748,6 +807,17 @@ var RealtimeClient = class {
748
807
  }
749
808
  }
750
809
  });
810
+ this.socket.on("event", (payload) => {
811
+ this.dispatchStreamEvent(payload).catch(() => void 0);
812
+ });
813
+ this.socket.on("event-gap", (info) => {
814
+ const streamKey = `stream:${info.projectId}:${info.collectionName}`;
815
+ const set = this.streamSubscriptions.get(streamKey);
816
+ if (!set) return;
817
+ for (const state of set) {
818
+ state.options.onGap?.(info);
819
+ }
820
+ });
751
821
  this.socket.on("disconnect", () => {
752
822
  if (!this.disposed) {
753
823
  this.setConnectionState("reconnecting");
@@ -805,6 +875,9 @@ var RealtimeClient = class {
805
875
  if (this.subscriptions.size > 0) {
806
876
  this.resubscribeAll();
807
877
  }
878
+ if (this.streamSubscriptions.size > 0) {
879
+ void this.resubscribeAllStreams();
880
+ }
808
881
  }
809
882
  scheduleRebuildRetry() {
810
883
  if (this.disposed || this.rebuildRetryTimer) return;
@@ -864,8 +937,186 @@ var RealtimeClient = class {
864
937
  }
865
938
  }
866
939
  }
940
+ emitSubscribeEvents(state) {
941
+ return new Promise((resolve) => {
942
+ if (!this.socket) {
943
+ resolve({ error: "disconnected" });
944
+ return;
945
+ }
946
+ const socket = this.socket;
947
+ const onDisconnect = () => {
948
+ socket.off("disconnect", onDisconnect);
949
+ resolve({ error: "disconnected" });
950
+ };
951
+ socket.once("disconnect", onDisconnect);
952
+ socket.emit(
953
+ "subscribe-events",
954
+ this.buildStreamPayload(state),
955
+ (ack) => {
956
+ socket.off("disconnect", onDisconnect);
957
+ resolve(ack);
958
+ }
959
+ );
960
+ });
961
+ }
962
+ buildStreamPayload(state) {
963
+ const { projectId, collectionName, where } = state.options;
964
+ const payload = { projectId, collectionName };
965
+ if (state.lastDeliveredId) payload["sinceId"] = state.lastDeliveredId;
966
+ if (where && Object.keys(where).length > 0) payload["where"] = where;
967
+ return payload;
968
+ }
969
+ unsubscribeStream(state) {
970
+ const set = this.streamSubscriptions.get(state.streamKey);
971
+ if (!set) return;
972
+ set.delete(state);
973
+ if (set.size === 0) {
974
+ this.streamSubscriptions.delete(state.streamKey);
975
+ this.socket?.emit("unsubscribe-events", {
976
+ projectId: state.options.projectId,
977
+ collectionName: state.options.collectionName
978
+ });
979
+ }
980
+ }
981
+ dispatchStreamEvent(payload) {
982
+ if (!payload.eventId) return Promise.resolve();
983
+ const streamKey = `stream:${payload.projectId}:${payload.collectionName}`;
984
+ const set = this.streamSubscriptions.get(streamKey);
985
+ if (!set) return Promise.resolve();
986
+ const entryId = payload.eventId;
987
+ for (const state of set) {
988
+ state.dispatchQueue = state.dispatchQueue.then(async () => {
989
+ const liveSet = this.streamSubscriptions.get(state.streamKey);
990
+ if (!liveSet?.has(state)) return;
991
+ try {
992
+ await state.options.onEvent({ eventId: entryId, event: payload });
993
+ state.lastDeliveredId = entryId;
994
+ } catch (err) {
995
+ const error = err instanceof Error ? err : new Error(String(err));
996
+ try {
997
+ state.options.onError?.(error);
998
+ } catch {
999
+ }
1000
+ }
1001
+ });
1002
+ }
1003
+ return Promise.resolve();
1004
+ }
1005
+ async resubscribeAllStreams() {
1006
+ const work = [];
1007
+ for (const set of this.streamSubscriptions.values()) {
1008
+ const states = Array.from(set);
1009
+ if (states.length === 0) continue;
1010
+ const primary = this.pickEarliestCursorState(states);
1011
+ work.push(this.resubscribeOne(primary, set));
1012
+ }
1013
+ try {
1014
+ await Promise.all(work);
1015
+ } catch {
1016
+ }
1017
+ }
1018
+ async resubscribeOne(primary, set) {
1019
+ try {
1020
+ const ack = await this.emitSubscribeEvents(primary);
1021
+ if (ack.error) {
1022
+ const err = new Error(ack.message ?? ack.error);
1023
+ for (const state of [...set]) {
1024
+ state.options.onError?.(err);
1025
+ }
1026
+ if (PERMANENT_STREAM_ACK_ERRORS.has(ack.error)) {
1027
+ this.streamSubscriptions.delete(primary.streamKey);
1028
+ }
1029
+ return;
1030
+ }
1031
+ if (!this.streamSubscriptions.has(primary.streamKey)) {
1032
+ this.socket?.emit("unsubscribe-events", {
1033
+ projectId: primary.options.projectId,
1034
+ collectionName: primary.options.collectionName
1035
+ });
1036
+ }
1037
+ } catch (err) {
1038
+ const error = err instanceof Error ? err : new Error(String(err));
1039
+ for (const state of [...set]) {
1040
+ state.options.onError?.(error);
1041
+ }
1042
+ }
1043
+ }
1044
+ pickEarliestCursorState(states) {
1045
+ const parse = (id) => {
1046
+ if (!id) return [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER];
1047
+ const [ms, seq = "0"] = id.split("-");
1048
+ return [Number(ms), Number(seq)];
1049
+ };
1050
+ let earliest = states[0];
1051
+ let [eMs, eSeq] = parse(earliest.lastDeliveredId);
1052
+ for (let i = 1; i < states.length; i++) {
1053
+ const [ms, seq] = parse(states[i].lastDeliveredId);
1054
+ if (ms < eMs || ms === eMs && seq < eSeq) {
1055
+ earliest = states[i];
1056
+ eMs = ms;
1057
+ eSeq = seq;
1058
+ }
1059
+ }
1060
+ return earliest;
1061
+ }
1062
+ /**
1063
+ * Evict the primary subscriber AND every sibling that joined the same
1064
+ * streamKey via the dedup path. Fires `onError` on all siblings, then
1065
+ * removes the entire streamKey entry from `streamSubscriptions`.
1066
+ */
1067
+ evictStreamKey(streamKey, err, primary) {
1068
+ const set = this.streamSubscriptions.get(streamKey);
1069
+ if (!set) return;
1070
+ for (const sibling of [...set]) {
1071
+ if (sibling !== primary) {
1072
+ sibling.options.onError?.(err);
1073
+ }
1074
+ set.delete(sibling);
1075
+ }
1076
+ this.streamSubscriptions.delete(streamKey);
1077
+ }
867
1078
  };
868
1079
 
1080
+ // libs/sdk/src/core/cursor-storage.ts
1081
+ function memoryCursorStorage() {
1082
+ const store = /* @__PURE__ */ new Map();
1083
+ return {
1084
+ load(key) {
1085
+ return store.get(key) ?? null;
1086
+ },
1087
+ save(key, cursor) {
1088
+ store.set(key, cursor);
1089
+ }
1090
+ };
1091
+ }
1092
+ function localStorageCursorStorage(prefix = "spacelr:cursor:") {
1093
+ const storage = (() => {
1094
+ try {
1095
+ const candidate = globalThis.localStorage;
1096
+ return typeof candidate === "undefined" ? null : candidate;
1097
+ } catch {
1098
+ return null;
1099
+ }
1100
+ })();
1101
+ return {
1102
+ load(key) {
1103
+ if (!storage) return null;
1104
+ try {
1105
+ return storage.getItem(prefix + key);
1106
+ } catch {
1107
+ return null;
1108
+ }
1109
+ },
1110
+ save(key, cursor) {
1111
+ if (!storage) return;
1112
+ try {
1113
+ storage.setItem(prefix + key, cursor);
1114
+ } catch {
1115
+ }
1116
+ }
1117
+ };
1118
+ }
1119
+
869
1120
  // libs/sdk/src/modules/auth.module.ts
870
1121
  var AuthModule = class {
871
1122
  constructor(http, tokenManager, config) {
@@ -1410,6 +1661,46 @@ var QueryBuilder = class {
1410
1661
  this._offset = offset;
1411
1662
  return this;
1412
1663
  }
1664
+ /**
1665
+ * **Constraint:** the cursor value must be a 24-hex ObjectId string.
1666
+ * Collections using custom non-ObjectId `_id` strings will not work
1667
+ * correctly with cursor pagination — the server's `$lt`/`$gt` comparison
1668
+ * uses BSON type ordering, mixing string `_id`s with ObjectId comparison
1669
+ * produces undefined behaviour. Documented limitation; future work may
1670
+ * add opaque cursor tokens that abstract over `_id` types.
1671
+ *
1672
+ * Switch to cursor-pagination mode: return documents with `_id < id`
1673
+ * (in the sort-defined order). The cursor refers to the cursor *value*,
1674
+ * not visual UI direction. Requires `.sort()` to be `{ _id: 1 }` or
1675
+ * `{ _id: -1 }` (or omitted — server defaults to `{ _id: 1 }`).
1676
+ *
1677
+ * Narrows the builder's mode parameter so subsequent `.execute()` returns
1678
+ * a `CursorResult<T>` instead of `OffsetResult<T>`.
1679
+ *
1680
+ * **For paginating further:** the `nextCursor` field returned by
1681
+ * `execute()` is the `_id` of the last document on the page. To load the
1682
+ * next older page, pass it again to `.before()`. (Do NOT pass it to
1683
+ * `.after()` — that would request docs newer than this page.)
1684
+ *
1685
+ * **Cannot be combined with `.offset()`.** Type system allows the chain
1686
+ * for ergonomics, but the server rejects it with HTTP 400.
1687
+ */
1688
+ before(id) {
1689
+ this._before = id;
1690
+ return this;
1691
+ }
1692
+ /**
1693
+ * Switch to cursor-pagination mode: return documents with `_id > id`.
1694
+ * See `.before()` for full semantics — including the ObjectId-only cursor
1695
+ * constraint and the `.offset()` incompatibility (server-enforced 400).
1696
+ *
1697
+ * **For paginating further:** pass the returned `nextCursor` to
1698
+ * `.after()` again to load the next newer page.
1699
+ */
1700
+ after(id) {
1701
+ this._after = id;
1702
+ return this;
1703
+ }
1413
1704
  select(fields) {
1414
1705
  this._fields = fields;
1415
1706
  return this;
@@ -1424,6 +1715,8 @@ var QueryBuilder = class {
1424
1715
  if (this._sort) query["sort"] = JSON.stringify(this._sort);
1425
1716
  if (this._limit !== void 0) query["limit"] = this._limit;
1426
1717
  if (this._offset !== void 0) query["offset"] = this._offset;
1718
+ if (this._before !== void 0) query["before"] = this._before;
1719
+ if (this._after !== void 0) query["after"] = this._after;
1427
1720
  if (this._fields) query["fields"] = this._fields.join(",");
1428
1721
  if (this._populate.length) {
1429
1722
  query["populate"] = this._populate.map(
@@ -1566,6 +1859,97 @@ var CollectionRef = class {
1566
1859
  }
1567
1860
  };
1568
1861
  }
1862
+ subscribeEvents(handlers) {
1863
+ if (!this.realtime) {
1864
+ throw new Error("Realtime not available: no RealtimeClient configured");
1865
+ }
1866
+ let lastCursor;
1867
+ let unsub = null;
1868
+ let unsubscribed = false;
1869
+ const cursorStorage = handlers.cursorStorage;
1870
+ const cursorKey = handlers.cursorKey ?? `${this.projectId}:${this.collectionName}`;
1871
+ if (cursorStorage && handlers.where && Object.keys(handlers.where).length > 0 && handlers.cursorKey === void 0) {
1872
+ console.warn(
1873
+ `[spacelr] subscribeEvents on ${this.projectId}:${this.collectionName} uses a 'where' filter with cursorStorage but no explicit cursorKey \u2014 filtered subscriptions sharing the default key will silently skip events across reconnects. Pass a unique cursorKey (e.g. include a hash of the filter).`
1874
+ );
1875
+ }
1876
+ const realtime = this.realtime;
1877
+ let lastSavePromise = Promise.resolve();
1878
+ const onEvent = async ({
1879
+ eventId,
1880
+ event
1881
+ }) => {
1882
+ try {
1883
+ if (event.type === "insert" && handlers.onInsert && event.document) {
1884
+ await handlers.onInsert({
1885
+ ...event.document,
1886
+ _eventId: eventId
1887
+ });
1888
+ } else if (event.type === "update" && handlers.onUpdate && event.document) {
1889
+ await handlers.onUpdate({
1890
+ ...event.document,
1891
+ _eventId: eventId
1892
+ });
1893
+ } else if (event.type === "delete" && handlers.onDelete) {
1894
+ await handlers.onDelete(event.documentId, eventId);
1895
+ }
1896
+ lastCursor = eventId;
1897
+ if (cursorStorage) {
1898
+ lastSavePromise = lastSavePromise.then(
1899
+ () => cursorStorage.save(cursorKey, eventId),
1900
+ () => cursorStorage.save(cursorKey, eventId)
1901
+ ).catch((err) => {
1902
+ handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
1903
+ });
1904
+ }
1905
+ } catch (err) {
1906
+ throw err instanceof Error ? err : new Error(String(err));
1907
+ }
1908
+ };
1909
+ const callSubscribe = (sinceId) => realtime.subscribeWithCursor({
1910
+ projectId: this.projectId,
1911
+ collectionName: this.collectionName,
1912
+ sinceId,
1913
+ where: handlers.where,
1914
+ onEvent,
1915
+ onGap: handlers.onGap,
1916
+ onError: handlers.onError
1917
+ });
1918
+ let promise;
1919
+ if (handlers.sinceId !== void 0 || !cursorStorage) {
1920
+ promise = callSubscribe(handlers.sinceId);
1921
+ } else {
1922
+ promise = (async () => {
1923
+ let resumeFrom;
1924
+ try {
1925
+ const loaded = await Promise.resolve(cursorStorage.load(cursorKey));
1926
+ if (loaded) resumeFrom = loaded;
1927
+ } catch (err) {
1928
+ handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
1929
+ }
1930
+ return callSubscribe(resumeFrom);
1931
+ })();
1932
+ }
1933
+ promise.then((u) => {
1934
+ if (unsubscribed) {
1935
+ u();
1936
+ } else {
1937
+ unsub = u;
1938
+ }
1939
+ }).catch(() => void 0);
1940
+ return {
1941
+ unsubscribe() {
1942
+ unsubscribed = true;
1943
+ if (unsub) {
1944
+ unsub();
1945
+ unsub = null;
1946
+ }
1947
+ },
1948
+ getCursor() {
1949
+ return lastCursor;
1950
+ }
1951
+ };
1952
+ }
1569
1953
  };
1570
1954
  var DatabaseModule = class {
1571
1955
  constructor(http, projectId, realtime) {
@@ -1831,6 +2215,8 @@ export {
1831
2215
  SpacelrTimeoutError,
1832
2216
  SpacelrTwoFactorRequiredError,
1833
2217
  createClient,
1834
- generatePKCEChallenge
2218
+ generatePKCEChallenge,
2219
+ localStorageCursorStorage,
2220
+ memoryCursorStorage
1835
2221
  };
1836
2222
  //# sourceMappingURL=index.mjs.map