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