@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.js CHANGED
@@ -33,7 +33,9 @@ __export(index_exports, {
33
33
  SpacelrTimeoutError: () => SpacelrTimeoutError,
34
34
  SpacelrTwoFactorRequiredError: () => SpacelrTwoFactorRequiredError,
35
35
  createClient: () => createClient,
36
- generatePKCEChallenge: () => generatePKCEChallenge
36
+ generatePKCEChallenge: () => generatePKCEChallenge,
37
+ localStorageCursorStorage: () => localStorageCursorStorage,
38
+ memoryCursorStorage: () => memoryCursorStorage
37
39
  });
38
40
  module.exports = __toCommonJS(index_exports);
39
41
 
@@ -410,7 +412,9 @@ var TokenManager = class {
410
412
  return tokens.accessToken;
411
413
  }
412
414
  async setTokens(tokens) {
413
- await this.storage.setTokens(tokens);
415
+ const needsDefault = tokens.expiresAt === void 0 && !!tokens.refreshToken;
416
+ const normalised = needsDefault ? { ...tokens, expiresAt: Math.floor(Date.now() / 1e3) } : tokens;
417
+ await this.storage.setTokens(normalised);
414
418
  this.authLostEmitted = false;
415
419
  }
416
420
  async clearTokens() {
@@ -575,6 +579,13 @@ async function generatePKCEChallenge() {
575
579
  // libs/sdk/src/core/realtime.ts
576
580
  var import_socket = require("socket.io-client");
577
581
  var REBUILD_RETRY_DELAY_MS = 5e3;
582
+ var PERMANENT_STREAM_ACK_ERRORS = /* @__PURE__ */ new Set([
583
+ "not-stream-collection",
584
+ "Collection not found",
585
+ "Invalid sinceId format",
586
+ "Not a member of this project",
587
+ "Subscribe denied"
588
+ ]);
578
589
  var RealtimeClient = class {
579
590
  constructor(config) {
580
591
  this.socket = null;
@@ -595,6 +606,7 @@ var RealtimeClient = class {
595
606
  this.rebuildRetryTimer = null;
596
607
  this.connectionState = "disconnected";
597
608
  this.connectionStateListeners = /* @__PURE__ */ new Set();
609
+ this.streamSubscriptions = /* @__PURE__ */ new Map();
598
610
  this.config = config;
599
611
  }
600
612
  async subscribe(projectId, collectionName, callback, onError, where) {
@@ -643,6 +655,53 @@ var RealtimeClient = class {
643
655
  }
644
656
  };
645
657
  }
658
+ /**
659
+ * Subscribe to a stream-mode collection using Redis Streams replay +
660
+ * cursor-based delivery. Parallel to `subscribe()` (which targets
661
+ * pubsub-mode collections). The server validates that the collection is
662
+ * configured in stream mode and rejects the handshake otherwise.
663
+ *
664
+ * The per-subscription cursor (`lastDeliveredId`) advances after each
665
+ * `onEvent` promise resolves, so reconnects resume from the last delivered
666
+ * event rather than the originally-subscribed `sinceId`.
667
+ */
668
+ async subscribeWithCursor(options) {
669
+ this.ensureWakeListeners();
670
+ if (this.connectionState === "disconnected") {
671
+ this.setConnectionState("reconnecting");
672
+ }
673
+ await this.ensureConnected();
674
+ const streamKey = `stream:${options.projectId}:${options.collectionName}`;
675
+ const state = {
676
+ options,
677
+ lastDeliveredId: options.sinceId ?? null,
678
+ streamKey,
679
+ dispatchQueue: Promise.resolve()
680
+ };
681
+ const existing = this.streamSubscriptions.get(streamKey);
682
+ if (existing && existing.size > 0) {
683
+ existing.add(state);
684
+ return () => this.unsubscribeStream(state);
685
+ }
686
+ const set = /* @__PURE__ */ new Set();
687
+ set.add(state);
688
+ this.streamSubscriptions.set(streamKey, set);
689
+ try {
690
+ const ack = await this.emitSubscribeEvents(state);
691
+ if (ack.error) {
692
+ const message = ack.message ?? ack.error;
693
+ const err = new Error(message);
694
+ this.evictStreamKey(streamKey, err, state);
695
+ options.onError?.(err);
696
+ throw err;
697
+ }
698
+ return () => this.unsubscribeStream(state);
699
+ } catch (err) {
700
+ const error = err instanceof Error ? err : new Error(String(err));
701
+ this.evictStreamKey(streamKey, error, state);
702
+ throw error;
703
+ }
704
+ }
646
705
  /**
647
706
  * Tear down the realtime client permanently. After this call the instance
648
707
  * is disposed — subsequent `subscribe()` calls will not re-establish a
@@ -667,6 +726,7 @@ var RealtimeClient = class {
667
726
  }
668
727
  this.subscriptions.clear();
669
728
  this.roomWhereMap.clear();
729
+ this.streamSubscriptions.clear();
670
730
  this.connecting = null;
671
731
  }
672
732
  getConnectionState() {
@@ -748,6 +808,7 @@ var RealtimeClient = class {
748
808
  resolve();
749
809
  } else {
750
810
  this.resubscribeAll();
811
+ void this.resubscribeAllStreams();
751
812
  }
752
813
  });
753
814
  this.socket.on("connect_error", (err) => {
@@ -780,6 +841,17 @@ var RealtimeClient = class {
780
841
  }
781
842
  }
782
843
  });
844
+ this.socket.on("event", (payload) => {
845
+ this.dispatchStreamEvent(payload).catch(() => void 0);
846
+ });
847
+ this.socket.on("event-gap", (info) => {
848
+ const streamKey = `stream:${info.projectId}:${info.collectionName}`;
849
+ const set = this.streamSubscriptions.get(streamKey);
850
+ if (!set) return;
851
+ for (const state of set) {
852
+ state.options.onGap?.(info);
853
+ }
854
+ });
783
855
  this.socket.on("disconnect", () => {
784
856
  if (!this.disposed) {
785
857
  this.setConnectionState("reconnecting");
@@ -837,6 +909,9 @@ var RealtimeClient = class {
837
909
  if (this.subscriptions.size > 0) {
838
910
  this.resubscribeAll();
839
911
  }
912
+ if (this.streamSubscriptions.size > 0) {
913
+ void this.resubscribeAllStreams();
914
+ }
840
915
  }
841
916
  scheduleRebuildRetry() {
842
917
  if (this.disposed || this.rebuildRetryTimer) return;
@@ -896,8 +971,186 @@ var RealtimeClient = class {
896
971
  }
897
972
  }
898
973
  }
974
+ emitSubscribeEvents(state) {
975
+ return new Promise((resolve) => {
976
+ if (!this.socket) {
977
+ resolve({ error: "disconnected" });
978
+ return;
979
+ }
980
+ const socket = this.socket;
981
+ const onDisconnect = () => {
982
+ socket.off("disconnect", onDisconnect);
983
+ resolve({ error: "disconnected" });
984
+ };
985
+ socket.once("disconnect", onDisconnect);
986
+ socket.emit(
987
+ "subscribe-events",
988
+ this.buildStreamPayload(state),
989
+ (ack) => {
990
+ socket.off("disconnect", onDisconnect);
991
+ resolve(ack);
992
+ }
993
+ );
994
+ });
995
+ }
996
+ buildStreamPayload(state) {
997
+ const { projectId, collectionName, where } = state.options;
998
+ const payload = { projectId, collectionName };
999
+ if (state.lastDeliveredId) payload["sinceId"] = state.lastDeliveredId;
1000
+ if (where && Object.keys(where).length > 0) payload["where"] = where;
1001
+ return payload;
1002
+ }
1003
+ unsubscribeStream(state) {
1004
+ const set = this.streamSubscriptions.get(state.streamKey);
1005
+ if (!set) return;
1006
+ set.delete(state);
1007
+ if (set.size === 0) {
1008
+ this.streamSubscriptions.delete(state.streamKey);
1009
+ this.socket?.emit("unsubscribe-events", {
1010
+ projectId: state.options.projectId,
1011
+ collectionName: state.options.collectionName
1012
+ });
1013
+ }
1014
+ }
1015
+ dispatchStreamEvent(payload) {
1016
+ if (!payload.eventId) return Promise.resolve();
1017
+ const streamKey = `stream:${payload.projectId}:${payload.collectionName}`;
1018
+ const set = this.streamSubscriptions.get(streamKey);
1019
+ if (!set) return Promise.resolve();
1020
+ const entryId = payload.eventId;
1021
+ for (const state of set) {
1022
+ state.dispatchQueue = state.dispatchQueue.then(async () => {
1023
+ const liveSet = this.streamSubscriptions.get(state.streamKey);
1024
+ if (!liveSet?.has(state)) return;
1025
+ try {
1026
+ await state.options.onEvent({ eventId: entryId, event: payload });
1027
+ state.lastDeliveredId = entryId;
1028
+ } catch (err) {
1029
+ const error = err instanceof Error ? err : new Error(String(err));
1030
+ try {
1031
+ state.options.onError?.(error);
1032
+ } catch {
1033
+ }
1034
+ }
1035
+ });
1036
+ }
1037
+ return Promise.resolve();
1038
+ }
1039
+ async resubscribeAllStreams() {
1040
+ const work = [];
1041
+ for (const set of this.streamSubscriptions.values()) {
1042
+ const states = Array.from(set);
1043
+ if (states.length === 0) continue;
1044
+ const primary = this.pickEarliestCursorState(states);
1045
+ work.push(this.resubscribeOne(primary, set));
1046
+ }
1047
+ try {
1048
+ await Promise.all(work);
1049
+ } catch {
1050
+ }
1051
+ }
1052
+ async resubscribeOne(primary, set) {
1053
+ try {
1054
+ const ack = await this.emitSubscribeEvents(primary);
1055
+ if (ack.error) {
1056
+ const err = new Error(ack.message ?? ack.error);
1057
+ for (const state of [...set]) {
1058
+ state.options.onError?.(err);
1059
+ }
1060
+ if (PERMANENT_STREAM_ACK_ERRORS.has(ack.error)) {
1061
+ this.streamSubscriptions.delete(primary.streamKey);
1062
+ }
1063
+ return;
1064
+ }
1065
+ if (!this.streamSubscriptions.has(primary.streamKey)) {
1066
+ this.socket?.emit("unsubscribe-events", {
1067
+ projectId: primary.options.projectId,
1068
+ collectionName: primary.options.collectionName
1069
+ });
1070
+ }
1071
+ } catch (err) {
1072
+ const error = err instanceof Error ? err : new Error(String(err));
1073
+ for (const state of [...set]) {
1074
+ state.options.onError?.(error);
1075
+ }
1076
+ }
1077
+ }
1078
+ pickEarliestCursorState(states) {
1079
+ const parse = (id) => {
1080
+ if (!id) return [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER];
1081
+ const [ms, seq = "0"] = id.split("-");
1082
+ return [Number(ms), Number(seq)];
1083
+ };
1084
+ let earliest = states[0];
1085
+ let [eMs, eSeq] = parse(earliest.lastDeliveredId);
1086
+ for (let i = 1; i < states.length; i++) {
1087
+ const [ms, seq] = parse(states[i].lastDeliveredId);
1088
+ if (ms < eMs || ms === eMs && seq < eSeq) {
1089
+ earliest = states[i];
1090
+ eMs = ms;
1091
+ eSeq = seq;
1092
+ }
1093
+ }
1094
+ return earliest;
1095
+ }
1096
+ /**
1097
+ * Evict the primary subscriber AND every sibling that joined the same
1098
+ * streamKey via the dedup path. Fires `onError` on all siblings, then
1099
+ * removes the entire streamKey entry from `streamSubscriptions`.
1100
+ */
1101
+ evictStreamKey(streamKey, err, primary) {
1102
+ const set = this.streamSubscriptions.get(streamKey);
1103
+ if (!set) return;
1104
+ for (const sibling of [...set]) {
1105
+ if (sibling !== primary) {
1106
+ sibling.options.onError?.(err);
1107
+ }
1108
+ set.delete(sibling);
1109
+ }
1110
+ this.streamSubscriptions.delete(streamKey);
1111
+ }
899
1112
  };
900
1113
 
1114
+ // libs/sdk/src/core/cursor-storage.ts
1115
+ function memoryCursorStorage() {
1116
+ const store = /* @__PURE__ */ new Map();
1117
+ return {
1118
+ load(key) {
1119
+ return store.get(key) ?? null;
1120
+ },
1121
+ save(key, cursor) {
1122
+ store.set(key, cursor);
1123
+ }
1124
+ };
1125
+ }
1126
+ function localStorageCursorStorage(prefix = "spacelr:cursor:") {
1127
+ const storage = (() => {
1128
+ try {
1129
+ const candidate = globalThis.localStorage;
1130
+ return typeof candidate === "undefined" ? null : candidate;
1131
+ } catch {
1132
+ return null;
1133
+ }
1134
+ })();
1135
+ return {
1136
+ load(key) {
1137
+ if (!storage) return null;
1138
+ try {
1139
+ return storage.getItem(prefix + key);
1140
+ } catch {
1141
+ return null;
1142
+ }
1143
+ },
1144
+ save(key, cursor) {
1145
+ if (!storage) return;
1146
+ try {
1147
+ storage.setItem(prefix + key, cursor);
1148
+ } catch {
1149
+ }
1150
+ }
1151
+ };
1152
+ }
1153
+
901
1154
  // libs/sdk/src/modules/auth.module.ts
902
1155
  var AuthModule = class {
903
1156
  constructor(http, tokenManager, config) {
@@ -1442,6 +1695,46 @@ var QueryBuilder = class {
1442
1695
  this._offset = offset;
1443
1696
  return this;
1444
1697
  }
1698
+ /**
1699
+ * **Constraint:** the cursor value must be a 24-hex ObjectId string.
1700
+ * Collections using custom non-ObjectId `_id` strings will not work
1701
+ * correctly with cursor pagination — the server's `$lt`/`$gt` comparison
1702
+ * uses BSON type ordering, mixing string `_id`s with ObjectId comparison
1703
+ * produces undefined behaviour. Documented limitation; future work may
1704
+ * add opaque cursor tokens that abstract over `_id` types.
1705
+ *
1706
+ * Switch to cursor-pagination mode: return documents with `_id < id`
1707
+ * (in the sort-defined order). The cursor refers to the cursor *value*,
1708
+ * not visual UI direction. Requires `.sort()` to be `{ _id: 1 }` or
1709
+ * `{ _id: -1 }` (or omitted — server defaults to `{ _id: 1 }`).
1710
+ *
1711
+ * Narrows the builder's mode parameter so subsequent `.execute()` returns
1712
+ * a `CursorResult<T>` instead of `OffsetResult<T>`.
1713
+ *
1714
+ * **For paginating further:** the `nextCursor` field returned by
1715
+ * `execute()` is the `_id` of the last document on the page. To load the
1716
+ * next older page, pass it again to `.before()`. (Do NOT pass it to
1717
+ * `.after()` — that would request docs newer than this page.)
1718
+ *
1719
+ * **Cannot be combined with `.offset()`.** Type system allows the chain
1720
+ * for ergonomics, but the server rejects it with HTTP 400.
1721
+ */
1722
+ before(id) {
1723
+ this._before = id;
1724
+ return this;
1725
+ }
1726
+ /**
1727
+ * Switch to cursor-pagination mode: return documents with `_id > id`.
1728
+ * See `.before()` for full semantics — including the ObjectId-only cursor
1729
+ * constraint and the `.offset()` incompatibility (server-enforced 400).
1730
+ *
1731
+ * **For paginating further:** pass the returned `nextCursor` to
1732
+ * `.after()` again to load the next newer page.
1733
+ */
1734
+ after(id) {
1735
+ this._after = id;
1736
+ return this;
1737
+ }
1445
1738
  select(fields) {
1446
1739
  this._fields = fields;
1447
1740
  return this;
@@ -1456,6 +1749,8 @@ var QueryBuilder = class {
1456
1749
  if (this._sort) query["sort"] = JSON.stringify(this._sort);
1457
1750
  if (this._limit !== void 0) query["limit"] = this._limit;
1458
1751
  if (this._offset !== void 0) query["offset"] = this._offset;
1752
+ if (this._before !== void 0) query["before"] = this._before;
1753
+ if (this._after !== void 0) query["after"] = this._after;
1459
1754
  if (this._fields) query["fields"] = this._fields.join(",");
1460
1755
  if (this._populate.length) {
1461
1756
  query["populate"] = this._populate.map(
@@ -1598,6 +1893,97 @@ var CollectionRef = class {
1598
1893
  }
1599
1894
  };
1600
1895
  }
1896
+ subscribeEvents(handlers) {
1897
+ if (!this.realtime) {
1898
+ throw new Error("Realtime not available: no RealtimeClient configured");
1899
+ }
1900
+ let lastCursor;
1901
+ let unsub = null;
1902
+ let unsubscribed = false;
1903
+ const cursorStorage = handlers.cursorStorage;
1904
+ const cursorKey = handlers.cursorKey ?? `${this.projectId}:${this.collectionName}`;
1905
+ if (cursorStorage && handlers.where && Object.keys(handlers.where).length > 0 && handlers.cursorKey === void 0) {
1906
+ console.warn(
1907
+ `[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).`
1908
+ );
1909
+ }
1910
+ const realtime = this.realtime;
1911
+ let lastSavePromise = Promise.resolve();
1912
+ const onEvent = async ({
1913
+ eventId,
1914
+ event
1915
+ }) => {
1916
+ try {
1917
+ if (event.type === "insert" && handlers.onInsert && event.document) {
1918
+ await handlers.onInsert({
1919
+ ...event.document,
1920
+ _eventId: eventId
1921
+ });
1922
+ } else if (event.type === "update" && handlers.onUpdate && event.document) {
1923
+ await handlers.onUpdate({
1924
+ ...event.document,
1925
+ _eventId: eventId
1926
+ });
1927
+ } else if (event.type === "delete" && handlers.onDelete) {
1928
+ await handlers.onDelete(event.documentId, eventId);
1929
+ }
1930
+ lastCursor = eventId;
1931
+ if (cursorStorage) {
1932
+ lastSavePromise = lastSavePromise.then(
1933
+ () => cursorStorage.save(cursorKey, eventId),
1934
+ () => cursorStorage.save(cursorKey, eventId)
1935
+ ).catch((err) => {
1936
+ handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
1937
+ });
1938
+ }
1939
+ } catch (err) {
1940
+ throw err instanceof Error ? err : new Error(String(err));
1941
+ }
1942
+ };
1943
+ const callSubscribe = (sinceId) => realtime.subscribeWithCursor({
1944
+ projectId: this.projectId,
1945
+ collectionName: this.collectionName,
1946
+ sinceId,
1947
+ where: handlers.where,
1948
+ onEvent,
1949
+ onGap: handlers.onGap,
1950
+ onError: handlers.onError
1951
+ });
1952
+ let promise;
1953
+ if (handlers.sinceId !== void 0 || !cursorStorage) {
1954
+ promise = callSubscribe(handlers.sinceId);
1955
+ } else {
1956
+ promise = (async () => {
1957
+ let resumeFrom;
1958
+ try {
1959
+ const loaded = await Promise.resolve(cursorStorage.load(cursorKey));
1960
+ if (loaded) resumeFrom = loaded;
1961
+ } catch (err) {
1962
+ handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
1963
+ }
1964
+ return callSubscribe(resumeFrom);
1965
+ })();
1966
+ }
1967
+ promise.then((u) => {
1968
+ if (unsubscribed) {
1969
+ u();
1970
+ } else {
1971
+ unsub = u;
1972
+ }
1973
+ }).catch(() => void 0);
1974
+ return {
1975
+ unsubscribe() {
1976
+ unsubscribed = true;
1977
+ if (unsub) {
1978
+ unsub();
1979
+ unsub = null;
1980
+ }
1981
+ },
1982
+ getCursor() {
1983
+ return lastCursor;
1984
+ }
1985
+ };
1986
+ }
1601
1987
  };
1602
1988
  var DatabaseModule = class {
1603
1989
  constructor(http, projectId, realtime) {
@@ -1864,6 +2250,8 @@ function createClient(config) {
1864
2250
  SpacelrTimeoutError,
1865
2251
  SpacelrTwoFactorRequiredError,
1866
2252
  createClient,
1867
- generatePKCEChallenge
2253
+ generatePKCEChallenge,
2254
+ localStorageCursorStorage,
2255
+ memoryCursorStorage
1868
2256
  });
1869
2257
  //# sourceMappingURL=index.js.map