@spacelr/sdk 0.1.9 → 0.2.0

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