@spacelr/sdk 0.2.1 → 0.3.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
@@ -30,6 +30,7 @@ __export(index_exports, {
30
30
  SpacelrEmailVerificationRequiredError: () => SpacelrEmailVerificationRequiredError,
31
31
  SpacelrError: () => SpacelrError,
32
32
  SpacelrNetworkError: () => SpacelrNetworkError,
33
+ SpacelrSearchFilterRequiredError: () => SpacelrSearchFilterRequiredError,
33
34
  SpacelrTimeoutError: () => SpacelrTimeoutError,
34
35
  SpacelrTwoFactorRequiredError: () => SpacelrTwoFactorRequiredError,
35
36
  createClient: () => createClient,
@@ -84,6 +85,14 @@ var SpacelrTwoFactorRequiredError = class extends SpacelrError {
84
85
  this.twoFactorToken = twoFactorToken;
85
86
  }
86
87
  };
88
+ var SpacelrSearchFilterRequiredError = class extends SpacelrError {
89
+ constructor(message, details) {
90
+ super(message, "SEARCH_FILTER_REQUIRED", 400, details);
91
+ this.name = "SpacelrSearchFilterRequiredError";
92
+ const fields = details?.["missingFields"];
93
+ this.missingFields = Array.isArray(fields) ? fields.filter((f) => typeof f === "string") : [];
94
+ }
95
+ };
87
96
  var SpacelrEmailVerificationRequiredError = class extends SpacelrError {
88
97
  constructor(emailSent, details) {
89
98
  super(
@@ -112,7 +121,25 @@ var HttpClient = class {
112
121
  const timeout = this.config.timeout ?? 3e4;
113
122
  const includeCredentials = options.withCredentials ?? options.path.startsWith("/auth/");
114
123
  const controller = new AbortController();
115
- const timeoutId = setTimeout(() => controller.abort(), timeout);
124
+ let timeoutFired = false;
125
+ const timeoutId = setTimeout(() => {
126
+ timeoutFired = true;
127
+ controller.abort();
128
+ }, timeout);
129
+ let externalAbort = false;
130
+ let onExternalAbort;
131
+ if (options.signal) {
132
+ if (options.signal.aborted) {
133
+ externalAbort = true;
134
+ controller.abort();
135
+ } else {
136
+ onExternalAbort = () => {
137
+ externalAbort = true;
138
+ controller.abort();
139
+ };
140
+ options.signal.addEventListener("abort", onExternalAbort);
141
+ }
142
+ }
116
143
  try {
117
144
  const response = await fetch(url, {
118
145
  method: options.method,
@@ -134,6 +161,7 @@ var HttpClient = class {
134
161
  } catch (error) {
135
162
  if (error instanceof SpacelrError) throw error;
136
163
  if (error instanceof DOMException && error.name === "AbortError") {
164
+ if (externalAbort && !timeoutFired) throw error;
137
165
  throw new SpacelrTimeoutError(timeout);
138
166
  }
139
167
  throw new SpacelrNetworkError(
@@ -141,6 +169,9 @@ var HttpClient = class {
141
169
  );
142
170
  } finally {
143
171
  clearTimeout(timeoutId);
172
+ if (options.signal && onExternalAbort) {
173
+ options.signal.removeEventListener("abort", onExternalAbort);
174
+ }
144
175
  }
145
176
  }
146
177
  async uploadForm(path, formData, onProgress) {
@@ -311,6 +342,12 @@ var HttpClient = class {
311
342
  const message = apiBody.error?.message ?? `HTTP ${statusCode}`;
312
343
  const code = apiBody.error?.code ?? `HTTP_${statusCode}`;
313
344
  const details = apiBody.error?.details;
345
+ const flatBody = body;
346
+ if (statusCode === 400 && flatBody["errorCode"] === "SEARCH_FILTER_REQUIRED") {
347
+ const flatMessage = typeof flatBody["message"] === "string" ? flatBody["message"] : message;
348
+ const missingFields = Array.isArray(flatBody["missingFields"]) ? flatBody["missingFields"] : [];
349
+ throw new SpacelrSearchFilterRequiredError(flatMessage, { missingFields });
350
+ }
314
351
  if (statusCode === 401 || statusCode === 403) {
315
352
  throw new SpacelrAuthError(message, statusCode, details);
316
353
  }
@@ -666,6 +703,67 @@ var RealtimeClient = class {
666
703
  * event rather than the originally-subscribed `sinceId`.
667
704
  */
668
705
  async subscribeWithCursor(options) {
706
+ const { unsubscribe } = await this._subscribeInternal(options);
707
+ return unsubscribe;
708
+ }
709
+ /**
710
+ * Metadata-aware variant of `subscribeWithCursor()` — see #99.
711
+ *
712
+ * Identical to `subscribeWithCursor` except it also returns the
713
+ * `realtimeMode` and `latestStreamId` from the server's
714
+ * subscribe-events ack so callers (notably `subscribeWithSnapshot`)
715
+ * can fail-fast on pubsub-mode collections and anchor the snapshot
716
+ * baseline atomically with the reader-attach.
717
+ *
718
+ * Defaults: if the server didn't send `realtimeMode` (older
719
+ * gateway), the SDK treats the collection as pubsub-mode so
720
+ * downstream fail-fasts still fire correctly. `latestStreamId`
721
+ * defaults to `null` when absent.
722
+ *
723
+ * Dedup-path note: when a sibling local subscription already holds
724
+ * the server-side reader slot for this streamKey, no new handshake
725
+ * is emitted — the SDK piggy-backs on the existing slot. In that
726
+ * case we synthesise `{ realtimeMode: 'stream', latestStreamId: null }`
727
+ * because the existing slot's prior ack is no longer available.
728
+ * Callers that need a fresh `latestStreamId` (e.g. snapshot anchor)
729
+ * MUST handle the `null` case.
730
+ */
731
+ async subscribeWithCursorWithMeta(options) {
732
+ const { unsubscribe, ack } = await this._subscribeInternal(options);
733
+ return {
734
+ unsubscribe,
735
+ // Default to pubsub if the server didn't send the field — older
736
+ // gateways that haven't been updated yet are treated as pubsub-mode
737
+ // so the SDK helper's fail-fast still fires correctly.
738
+ realtimeMode: ack.realtimeMode ?? "pubsub",
739
+ latestStreamId: ack.latestStreamId ?? null
740
+ };
741
+ }
742
+ /**
743
+ * Shared body for `subscribeWithCursor` and
744
+ * `subscribeWithCursorWithMeta`. Owns the handshake, dedup path,
745
+ * pre-ack registration, and error eviction. Returns the unsubscribe
746
+ * function plus the subscribe-events ack so metadata-aware callers
747
+ * can read `realtimeMode` / `latestStreamId`.
748
+ *
749
+ * Behaviour-preserving contract — DO NOT change without re-running
750
+ * the singleflight tests in `realtime.spec.ts`:
751
+ *
752
+ * 1. Dedup branch BEFORE registration: if a sibling subscriber for
753
+ * this streamKey is already registered, the new caller piggy-backs
754
+ * on its server-side slot. Re-emitting the handshake here would
755
+ * tear the prior subscriber's reader down.
756
+ * 2. Registration BEFORE handshake: `streamSubscriptions.set` must
757
+ * fire before `emitSubscribeEvents` so replay/event-gap frames
758
+ * delivered DURING the handshake fanout find this subscriber.
759
+ * 3. evictStreamKey on ack-error MUST sweep dedup-path siblings:
760
+ * they have no independent server-side state and would silently
761
+ * miss every event until the next full reconnect.
762
+ * 4. The catch block is a safety net for the re-thrown ack-error
763
+ * only — `emitSubscribeEvents` itself always resolves, so this
764
+ * catch should never fire on the success path.
765
+ */
766
+ async _subscribeInternal(options) {
669
767
  this.ensureWakeListeners();
670
768
  if (this.connectionState === "disconnected") {
671
769
  this.setConnectionState("reconnecting");
@@ -681,7 +779,15 @@ var RealtimeClient = class {
681
779
  const existing = this.streamSubscriptions.get(streamKey);
682
780
  if (existing && existing.size > 0) {
683
781
  existing.add(state);
684
- return () => this.unsubscribeStream(state);
782
+ const syntheticAck = {
783
+ subscribed: streamKey,
784
+ realtimeMode: "stream",
785
+ latestStreamId: null
786
+ };
787
+ return {
788
+ unsubscribe: () => this.unsubscribeStream(state),
789
+ ack: syntheticAck
790
+ };
685
791
  }
686
792
  const set = /* @__PURE__ */ new Set();
687
793
  set.add(state);
@@ -690,12 +796,15 @@ var RealtimeClient = class {
690
796
  const ack = await this.emitSubscribeEvents(state);
691
797
  if (ack.error) {
692
798
  const message = ack.message ?? ack.error;
693
- const err = new Error(message);
799
+ const err = new SpacelrError(message, ack.error);
694
800
  this.evictStreamKey(streamKey, err, state);
695
801
  options.onError?.(err);
696
802
  throw err;
697
803
  }
698
- return () => this.unsubscribeStream(state);
804
+ return {
805
+ unsubscribe: () => this.unsubscribeStream(state),
806
+ ack
807
+ };
699
808
  } catch (err) {
700
809
  const error = err instanceof Error ? err : new Error(String(err));
701
810
  this.evictStreamKey(streamKey, error, state);
@@ -774,6 +883,7 @@ var RealtimeClient = class {
774
883
  const wsUrl = this.config.baseUrl.replace(/\/api\/v\d+\/?$/, "");
775
884
  return new Promise((resolve, reject) => {
776
885
  let initialConnect = true;
886
+ let onInitialDisconnect = null;
777
887
  this.socket = (0, import_socket.io)(`${wsUrl}/database`, {
778
888
  auth: async (cb) => {
779
889
  try {
@@ -784,8 +894,11 @@ var RealtimeClient = class {
784
894
  }
785
895
  },
786
896
  transports: ["websocket"],
897
+ // Disable auto-reconnect for the INITIAL connect — caller's promise
898
+ // rejects on connect_error and they decide whether to retry. Once the
899
+ // first authentication succeeds we re-enable it via the Manager's
900
+ // setters (see authenticated handler).
787
901
  reconnection: false
788
- // Disabled until first successful connect
789
902
  });
790
903
  this.socket.on("authenticated", () => {
791
904
  if (this.disposed) return;
@@ -793,10 +906,32 @@ var RealtimeClient = class {
793
906
  if (initialConnect) {
794
907
  initialConnect = false;
795
908
  if (this.socket) {
796
- this.socket.io.opts.reconnection = true;
797
- this.socket.io.opts.reconnectionDelay = 1e3;
798
- this.socket.io.opts.reconnectionDelayMax = 5e3;
799
- this.socket.io.opts.reconnectionAttempts = 50;
909
+ const manager = this.socket.io;
910
+ try {
911
+ manager.reconnection(true);
912
+ manager.skipReconnect = false;
913
+ manager.reconnectionDelay(1e3);
914
+ manager.reconnectionDelayMax(5e3);
915
+ manager.reconnectionAttempts(50);
916
+ } catch (err) {
917
+ if (onInitialDisconnect && this.socket) {
918
+ this.socket.off("disconnect", onInitialDisconnect);
919
+ onInitialDisconnect = null;
920
+ }
921
+ this.socket?.disconnect();
922
+ this.socket = null;
923
+ this.setConnectionState("disconnected");
924
+ reject(
925
+ new Error(
926
+ `socket.io-client Manager reconnection setters failed \u2014 internal API may have changed; auto-reconnect cannot be enabled. Check socket.io-client version compatibility. Underlying error: ${err instanceof Error ? err.message : String(err)}`
927
+ )
928
+ );
929
+ return;
930
+ }
931
+ }
932
+ if (onInitialDisconnect && this.socket) {
933
+ this.socket.off("disconnect", onInitialDisconnect);
934
+ onInitialDisconnect = null;
800
935
  }
801
936
  if (this.config.onTokenRefreshed && !this.unsubscribeFromTokenRefreshed) {
802
937
  this.unsubscribeFromTokenRefreshed = this.config.onTokenRefreshed(
@@ -816,6 +951,14 @@ var RealtimeClient = class {
816
951
  reject(new Error(`WebSocket connection failed: ${err.message}`));
817
952
  }
818
953
  });
954
+ onInitialDisconnect = (reason) => {
955
+ if (!initialConnect) return;
956
+ this.socket?.disconnect();
957
+ this.socket = null;
958
+ this.setConnectionState("disconnected");
959
+ reject(new Error(`WebSocket disconnected during handshake: ${reason}`));
960
+ };
961
+ this.socket.on("disconnect", onInitialDisconnect);
819
962
  this.socket.io.on("reconnect_failed", () => {
820
963
  void this.rebuildSocket();
821
964
  });
@@ -853,9 +996,10 @@ var RealtimeClient = class {
853
996
  }
854
997
  });
855
998
  this.socket.on("disconnect", () => {
856
- if (!this.disposed) {
857
- this.setConnectionState("reconnecting");
858
- }
999
+ if (this.disposed) return;
1000
+ if (initialConnect) return;
1001
+ if (!this.socket) return;
1002
+ this.setConnectionState("reconnecting");
859
1003
  });
860
1004
  });
861
1005
  }
@@ -1053,7 +1197,7 @@ var RealtimeClient = class {
1053
1197
  try {
1054
1198
  const ack = await this.emitSubscribeEvents(primary);
1055
1199
  if (ack.error) {
1056
- const err = new Error(ack.message ?? ack.error);
1200
+ const err = new SpacelrError(ack.message ?? ack.error, ack.error);
1057
1201
  for (const state of [...set]) {
1058
1202
  state.options.onError?.(err);
1059
1203
  }
@@ -1676,6 +1820,68 @@ var StorageModule = class {
1676
1820
  };
1677
1821
 
1678
1822
  // libs/sdk/src/modules/database.module.ts
1823
+ var Paginator = class {
1824
+ constructor(http, basePath, opts) {
1825
+ this.http = http;
1826
+ this.basePath = basePath;
1827
+ this._exhausted = false;
1828
+ /**
1829
+ * Tail of the serialized `next()` chain. Each call appends to this so
1830
+ * concurrent invocations from a UI scroll handler (e.g. user spam-tapping
1831
+ * "load more") don't issue parallel requests with the same cursor — which
1832
+ * would return duplicate pages and clobber the cursor based on whichever
1833
+ * response settles last. Calls execute strictly in invocation order.
1834
+ */
1835
+ this.chain = Promise.resolve({
1836
+ documents: [],
1837
+ hasMore: false
1838
+ });
1839
+ this.cursor = opts.cursor;
1840
+ this.direction = opts.sort?._id ?? -1;
1841
+ this.pageSize = opts.limit ?? 50;
1842
+ this.where = opts.where;
1843
+ }
1844
+ /**
1845
+ * True once a `next()` call has returned an empty page. Subsequent
1846
+ * `next()` calls return an empty page without hitting the server.
1847
+ *
1848
+ * Note: only the empty-page signal flips this flag; a server response
1849
+ * that returns documents but `hasMore: false` does NOT exhaust. This
1850
+ * lets ascending paginators (`sort: { _id: 1 }`) keep polling for
1851
+ * documents inserted after the caller caught up to the current tail —
1852
+ * the next `.after(lastSeen)` call will simply return zero documents
1853
+ * until something new lands.
1854
+ */
1855
+ get exhausted() {
1856
+ return this._exhausted;
1857
+ }
1858
+ next() {
1859
+ const run = this.chain.then(
1860
+ () => this.fetchNextPage(),
1861
+ () => this.fetchNextPage()
1862
+ );
1863
+ this.chain = run;
1864
+ return run;
1865
+ }
1866
+ async fetchNextPage() {
1867
+ if (this._exhausted) return { documents: [], hasMore: false };
1868
+ const builder = new QueryBuilder(this.http, this.basePath, this.where).sort({ _id: this.direction }).limit(this.pageSize);
1869
+ let result;
1870
+ if (this.cursor !== void 0) {
1871
+ const cursorBuilder = this.direction === -1 ? builder.before(this.cursor) : builder.after(this.cursor);
1872
+ result = await cursorBuilder.execute();
1873
+ } else {
1874
+ result = await builder.execute();
1875
+ }
1876
+ if (result.documents.length === 0) {
1877
+ this._exhausted = true;
1878
+ return { documents: [], hasMore: false };
1879
+ }
1880
+ this.cursor = result.documents[result.documents.length - 1]._id;
1881
+ const hasMore = result.mode === "cursor" ? result.hasMore : result.documents.length < result.total;
1882
+ return { documents: result.documents, hasMore };
1883
+ }
1884
+ };
1679
1885
  var QueryBuilder = class {
1680
1886
  constructor(http, basePath, filter) {
1681
1887
  this.http = http;
@@ -1743,7 +1949,13 @@ var QueryBuilder = class {
1743
1949
  this._populate.push({ field, collection, foreignField });
1744
1950
  return this;
1745
1951
  }
1746
- async execute() {
1952
+ /**
1953
+ * `signal` is an optional external AbortSignal forwarded into the underlying
1954
+ * HTTP request. Used by `subscribeWithSnapshot` so that an unsubscribe()
1955
+ * call cancels the in-flight snapshot find(); regular callers can leave it
1956
+ * undefined.
1957
+ */
1958
+ async execute(signal) {
1747
1959
  const query = {};
1748
1960
  if (this._filter) query["filter"] = JSON.stringify(this._filter);
1749
1961
  if (this._sort) query["sort"] = JSON.stringify(this._sort);
@@ -1761,7 +1973,8 @@ var QueryBuilder = class {
1761
1973
  method: "GET",
1762
1974
  path: this.basePath,
1763
1975
  query,
1764
- authenticated: true
1976
+ authenticated: true,
1977
+ signal
1765
1978
  });
1766
1979
  }
1767
1980
  };
@@ -1792,6 +2005,20 @@ var CollectionRef = class {
1792
2005
  find(filter) {
1793
2006
  return new QueryBuilder(this.http, this.basePath, filter);
1794
2007
  }
2008
+ /**
2009
+ * Cursor-based scroll-back helper. Returns a `Paginator` whose `.next()`
2010
+ * yields successive pages and tracks the last-seen `_id` internally.
2011
+ * Defaults to descending sort and 50 docs per page (chat scroll-back is
2012
+ * the canonical use case). See `PaginateOptions` for tuning.
2013
+ *
2014
+ * **Cursor constraint:** uses `_id` keyset pagination, which requires
2015
+ * 24-hex ObjectId `_id`s. Collections with custom string `_id` schemes
2016
+ * fall back to comparing strings as ObjectIds — the server's handler
2017
+ * documents this limitation on `before` / `after` directly.
2018
+ */
2019
+ paginate(opts = {}) {
2020
+ return new Paginator(this.http, this.basePath, opts);
2021
+ }
1795
2022
  /**
1796
2023
  * Server-side substring search across the specified fields.
1797
2024
  *
@@ -1800,6 +2027,13 @@ var CollectionRef = class {
1800
2027
  * cannot use a standard B-tree index — on very large collections consider
1801
2028
  * narrowing with `filter` to scope the scan.
1802
2029
  *
2030
+ * Required filters: collections may declare `searchConfig.requireFilter`
2031
+ * via the admin API. Calls without those keys at the top level of `filter`
2032
+ * (or inside a top-level `$and`) reject with
2033
+ * `SpacelrSearchFilterRequiredError` carrying `missingFields`. Allowed
2034
+ * shapes: `{ field: value }`, `{ field: { $eq: value } }`,
2035
+ * `{ field: { $in: [...] } }` (non-empty).
2036
+ *
1803
2037
  * Limits: `query` 1–200 chars, `fields` 1–10 entries (each matching
1804
2038
  * `/^[a-zA-Z0-9_.]+$/`, max 64 chars), `limit` max 100.
1805
2039
  */
@@ -1984,6 +2218,291 @@ var CollectionRef = class {
1984
2218
  }
1985
2219
  };
1986
2220
  }
2221
+ /**
2222
+ * Snapshot-aware subscribe — see #99 / SubscribeWithSnapshotOptions.
2223
+ *
2224
+ * Returns a Promise that resolves with the unsubscribe function only
2225
+ * after the first `onSnapshot` has run to completion (initial-load
2226
+ * signal). Calling unsubscribe() while the snapshot find() is in flight
2227
+ * aborts the request and silences the AbortError. Gap-recovery state
2228
+ * machine lands in Task 6.
2229
+ */
2230
+ async subscribeWithSnapshot(opts) {
2231
+ if (!this.realtime) {
2232
+ throw new Error("Realtime not available: no RealtimeClient configured");
2233
+ }
2234
+ let isActive = true;
2235
+ let currentUnsubscribe = null;
2236
+ let snapshotAbort = new AbortController();
2237
+ let recoveryState = "idle";
2238
+ const realtime = this.realtime;
2239
+ const streamWhere = (() => {
2240
+ if (!opts.where) return void 0;
2241
+ const out = {};
2242
+ for (const [k, v] of Object.entries(opts.where)) {
2243
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
2244
+ out[k] = v;
2245
+ }
2246
+ }
2247
+ return Object.keys(out).length > 0 ? out : void 0;
2248
+ })();
2249
+ if (opts.where && streamWhere === void 0 && Object.keys(opts.where).length > 0) {
2250
+ console.warn(
2251
+ `[spacelr] subscribeWithSnapshot on ${this.projectId}:${this.collectionName}: 'where' filter contains only operator-shaped values (e.g. $gt, $in) which are not supported by the live stream filter. onChange will receive ALL collection events; snapshot is still filtered.`
2252
+ );
2253
+ }
2254
+ const runRecovery = async () => {
2255
+ currentUnsubscribe?.();
2256
+ currentUnsubscribe = null;
2257
+ if (!isActive) return;
2258
+ let recoveryAckResolved = false;
2259
+ let recoverySnapshotDelivered = false;
2260
+ const pendingRecoveryEvents = [];
2261
+ let next;
2262
+ try {
2263
+ next = await realtime.subscribeWithCursorWithMeta({
2264
+ projectId: this.projectId,
2265
+ collectionName: this.collectionName,
2266
+ sinceId: void 0,
2267
+ where: streamWhere,
2268
+ onEvent: async ({ event }) => {
2269
+ if (!isActive) return;
2270
+ if (!recoverySnapshotDelivered) {
2271
+ pendingRecoveryEvents.push(event);
2272
+ return;
2273
+ }
2274
+ await opts.onChange(event);
2275
+ },
2276
+ onError: (err) => {
2277
+ if (!isActive) return;
2278
+ if (!recoveryAckResolved) return;
2279
+ isActive = false;
2280
+ currentUnsubscribe?.();
2281
+ currentUnsubscribe = null;
2282
+ snapshotAbort.abort();
2283
+ opts.onError?.(err);
2284
+ },
2285
+ onGap: handleGap
2286
+ });
2287
+ recoveryAckResolved = true;
2288
+ } catch (err) {
2289
+ if (!isActive) return;
2290
+ opts.onError?.(err instanceof Error ? err : new Error(String(err)));
2291
+ return;
2292
+ }
2293
+ if (!isActive) {
2294
+ next.unsubscribe();
2295
+ return;
2296
+ }
2297
+ currentUnsubscribe = next.unsubscribe;
2298
+ snapshotAbort = new AbortController();
2299
+ let recoveryDocs;
2300
+ try {
2301
+ const builder = this.find(opts.where);
2302
+ if (opts.sort) builder.sort(opts.sort);
2303
+ if (opts.limit !== void 0) builder.limit(opts.limit);
2304
+ if (opts.select) builder.select(opts.select);
2305
+ const result = await builder.execute(snapshotAbort.signal);
2306
+ recoveryDocs = result.documents;
2307
+ } catch (err) {
2308
+ if (!isActive) return;
2309
+ if (err instanceof DOMException && err.name === "AbortError") return;
2310
+ currentUnsubscribe?.();
2311
+ currentUnsubscribe = null;
2312
+ isActive = false;
2313
+ const error = err instanceof Error ? err : new Error(String(err));
2314
+ if (opts.onSnapshotError) opts.onSnapshotError(error);
2315
+ else opts.onError?.(error);
2316
+ return;
2317
+ }
2318
+ if (!isActive) return;
2319
+ try {
2320
+ await opts.onSnapshot(recoveryDocs, next.latestStreamId);
2321
+ } catch (err) {
2322
+ currentUnsubscribe?.();
2323
+ currentUnsubscribe = null;
2324
+ isActive = false;
2325
+ const error = err instanceof Error ? err : new Error(String(err));
2326
+ opts.onError?.(error);
2327
+ return;
2328
+ }
2329
+ while (true) {
2330
+ if (!isActive) return;
2331
+ if (pendingRecoveryEvents.length === 0) {
2332
+ recoverySnapshotDelivered = true;
2333
+ break;
2334
+ }
2335
+ const ev = pendingRecoveryEvents.shift();
2336
+ if (ev !== void 0) {
2337
+ try {
2338
+ await opts.onChange(ev);
2339
+ } catch (err) {
2340
+ currentUnsubscribe?.();
2341
+ currentUnsubscribe = null;
2342
+ isActive = false;
2343
+ const error = err instanceof Error ? err : new Error(String(err));
2344
+ opts.onError?.(error);
2345
+ return;
2346
+ }
2347
+ }
2348
+ }
2349
+ };
2350
+ const dispatchRecovery = () => {
2351
+ if (!isActive) return;
2352
+ if (recoveryState === "running") {
2353
+ recoveryState = "pending-rerun";
2354
+ return;
2355
+ }
2356
+ if (recoveryState === "pending-rerun") {
2357
+ return;
2358
+ }
2359
+ recoveryState = "running";
2360
+ void (async () => {
2361
+ await Promise.resolve();
2362
+ while (true) {
2363
+ try {
2364
+ await runRecovery();
2365
+ } catch (err) {
2366
+ recoveryState = "idle";
2367
+ if (!isActive) return;
2368
+ const error = err instanceof Error ? err : new Error(String(err));
2369
+ try {
2370
+ opts.onError?.(error);
2371
+ } catch {
2372
+ }
2373
+ return;
2374
+ }
2375
+ if (!isActive) {
2376
+ recoveryState = "idle";
2377
+ return;
2378
+ }
2379
+ if (recoveryState === "pending-rerun") {
2380
+ recoveryState = "running";
2381
+ continue;
2382
+ }
2383
+ recoveryState = "idle";
2384
+ return;
2385
+ }
2386
+ })();
2387
+ };
2388
+ const handleGap = (info) => {
2389
+ if (!isActive) return;
2390
+ if (info.reason !== "outside-retention-window") return;
2391
+ dispatchRecovery();
2392
+ };
2393
+ let subscribeAckResolved = false;
2394
+ let initialSnapshotDelivered = false;
2395
+ const pendingInitialEvents = [];
2396
+ let initial;
2397
+ try {
2398
+ initial = await realtime.subscribeWithCursorWithMeta({
2399
+ projectId: this.projectId,
2400
+ collectionName: this.collectionName,
2401
+ sinceId: void 0,
2402
+ where: streamWhere,
2403
+ onEvent: async ({ event }) => {
2404
+ if (!isActive) return;
2405
+ if (!initialSnapshotDelivered) {
2406
+ pendingInitialEvents.push(event);
2407
+ return;
2408
+ }
2409
+ await opts.onChange(event);
2410
+ },
2411
+ onError: (err) => {
2412
+ if (!isActive) return;
2413
+ if (!subscribeAckResolved) return;
2414
+ isActive = false;
2415
+ snapshotAbort.abort();
2416
+ currentUnsubscribe?.();
2417
+ currentUnsubscribe = null;
2418
+ opts.onError?.(err);
2419
+ },
2420
+ onGap: handleGap
2421
+ });
2422
+ subscribeAckResolved = true;
2423
+ } catch (err) {
2424
+ isActive = false;
2425
+ const code = err instanceof SpacelrError ? err.code : void 0;
2426
+ if (code === "not-stream-collection") {
2427
+ throw new SpacelrError(
2428
+ `subscribeWithSnapshot requires a stream-mode collection. Use subscribe() for pubsub collections.`,
2429
+ "SUBSCRIBE_WITH_SNAPSHOT_REQUIRES_STREAM",
2430
+ void 0,
2431
+ { collectionName: this.collectionName }
2432
+ );
2433
+ }
2434
+ throw err;
2435
+ }
2436
+ if (initial.realtimeMode !== "stream") {
2437
+ isActive = false;
2438
+ initial.unsubscribe();
2439
+ throw new SpacelrError(
2440
+ `subscribeWithSnapshot requires a stream-mode collection. Use subscribe() for pubsub collections.`,
2441
+ "SUBSCRIBE_WITH_SNAPSHOT_REQUIRES_STREAM",
2442
+ void 0,
2443
+ { collectionName: this.collectionName }
2444
+ );
2445
+ }
2446
+ currentUnsubscribe = initial.unsubscribe;
2447
+ let snapshotDocs;
2448
+ try {
2449
+ const builder = this.find(opts.where);
2450
+ if (opts.sort) builder.sort(opts.sort);
2451
+ if (opts.limit !== void 0) builder.limit(opts.limit);
2452
+ if (opts.select) builder.select(opts.select);
2453
+ const result = await builder.execute(snapshotAbort.signal);
2454
+ snapshotDocs = result.documents;
2455
+ } catch (err) {
2456
+ currentUnsubscribe?.();
2457
+ isActive = false;
2458
+ const isAbort = err instanceof DOMException && err.name === "AbortError";
2459
+ if (isAbort) throw err;
2460
+ const error = err instanceof Error ? err : new Error(String(err));
2461
+ if (opts.onSnapshotError) opts.onSnapshotError(error);
2462
+ else opts.onError?.(error);
2463
+ throw error;
2464
+ }
2465
+ if (!isActive) {
2466
+ currentUnsubscribe?.();
2467
+ return () => void 0;
2468
+ }
2469
+ try {
2470
+ await opts.onSnapshot(snapshotDocs, initial.latestStreamId);
2471
+ } catch (err) {
2472
+ currentUnsubscribe?.();
2473
+ currentUnsubscribe = null;
2474
+ isActive = false;
2475
+ const error = err instanceof Error ? err : new Error(String(err));
2476
+ opts.onError?.(error);
2477
+ throw error;
2478
+ }
2479
+ while (true) {
2480
+ if (!isActive) return () => void 0;
2481
+ if (pendingInitialEvents.length === 0) {
2482
+ initialSnapshotDelivered = true;
2483
+ break;
2484
+ }
2485
+ const ev = pendingInitialEvents.shift();
2486
+ if (ev !== void 0) {
2487
+ try {
2488
+ await opts.onChange(ev);
2489
+ } catch (err) {
2490
+ currentUnsubscribe?.();
2491
+ currentUnsubscribe = null;
2492
+ isActive = false;
2493
+ const error = err instanceof Error ? err : new Error(String(err));
2494
+ opts.onError?.(error);
2495
+ throw error;
2496
+ }
2497
+ }
2498
+ }
2499
+ return () => {
2500
+ if (!isActive) return;
2501
+ isActive = false;
2502
+ snapshotAbort.abort();
2503
+ currentUnsubscribe?.();
2504
+ };
2505
+ }
1987
2506
  };
1988
2507
  var DatabaseModule = class {
1989
2508
  constructor(http, projectId, realtime) {
@@ -2247,6 +2766,7 @@ function createClient(config) {
2247
2766
  SpacelrEmailVerificationRequiredError,
2248
2767
  SpacelrError,
2249
2768
  SpacelrNetworkError,
2769
+ SpacelrSearchFilterRequiredError,
2250
2770
  SpacelrTimeoutError,
2251
2771
  SpacelrTwoFactorRequiredError,
2252
2772
  createClient,