@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.mjs CHANGED
@@ -50,6 +50,14 @@ var SpacelrTwoFactorRequiredError = class extends SpacelrError {
50
50
  this.twoFactorToken = twoFactorToken;
51
51
  }
52
52
  };
53
+ var SpacelrSearchFilterRequiredError = class extends SpacelrError {
54
+ constructor(message, details) {
55
+ super(message, "SEARCH_FILTER_REQUIRED", 400, details);
56
+ this.name = "SpacelrSearchFilterRequiredError";
57
+ const fields = details?.["missingFields"];
58
+ this.missingFields = Array.isArray(fields) ? fields.filter((f) => typeof f === "string") : [];
59
+ }
60
+ };
53
61
  var SpacelrEmailVerificationRequiredError = class extends SpacelrError {
54
62
  constructor(emailSent, details) {
55
63
  super(
@@ -78,7 +86,25 @@ var HttpClient = class {
78
86
  const timeout = this.config.timeout ?? 3e4;
79
87
  const includeCredentials = options.withCredentials ?? options.path.startsWith("/auth/");
80
88
  const controller = new AbortController();
81
- const timeoutId = setTimeout(() => controller.abort(), timeout);
89
+ let timeoutFired = false;
90
+ const timeoutId = setTimeout(() => {
91
+ timeoutFired = true;
92
+ controller.abort();
93
+ }, timeout);
94
+ let externalAbort = false;
95
+ let onExternalAbort;
96
+ if (options.signal) {
97
+ if (options.signal.aborted) {
98
+ externalAbort = true;
99
+ controller.abort();
100
+ } else {
101
+ onExternalAbort = () => {
102
+ externalAbort = true;
103
+ controller.abort();
104
+ };
105
+ options.signal.addEventListener("abort", onExternalAbort);
106
+ }
107
+ }
82
108
  try {
83
109
  const response = await fetch(url, {
84
110
  method: options.method,
@@ -100,6 +126,7 @@ var HttpClient = class {
100
126
  } catch (error) {
101
127
  if (error instanceof SpacelrError) throw error;
102
128
  if (error instanceof DOMException && error.name === "AbortError") {
129
+ if (externalAbort && !timeoutFired) throw error;
103
130
  throw new SpacelrTimeoutError(timeout);
104
131
  }
105
132
  throw new SpacelrNetworkError(
@@ -107,6 +134,9 @@ var HttpClient = class {
107
134
  );
108
135
  } finally {
109
136
  clearTimeout(timeoutId);
137
+ if (options.signal && onExternalAbort) {
138
+ options.signal.removeEventListener("abort", onExternalAbort);
139
+ }
110
140
  }
111
141
  }
112
142
  async uploadForm(path, formData, onProgress) {
@@ -277,6 +307,12 @@ var HttpClient = class {
277
307
  const message = apiBody.error?.message ?? `HTTP ${statusCode}`;
278
308
  const code = apiBody.error?.code ?? `HTTP_${statusCode}`;
279
309
  const details = apiBody.error?.details;
310
+ const flatBody = body;
311
+ if (statusCode === 400 && flatBody["errorCode"] === "SEARCH_FILTER_REQUIRED") {
312
+ const flatMessage = typeof flatBody["message"] === "string" ? flatBody["message"] : message;
313
+ const missingFields = Array.isArray(flatBody["missingFields"]) ? flatBody["missingFields"] : [];
314
+ throw new SpacelrSearchFilterRequiredError(flatMessage, { missingFields });
315
+ }
280
316
  if (statusCode === 401 || statusCode === 403) {
281
317
  throw new SpacelrAuthError(message, statusCode, details);
282
318
  }
@@ -632,6 +668,67 @@ var RealtimeClient = class {
632
668
  * event rather than the originally-subscribed `sinceId`.
633
669
  */
634
670
  async subscribeWithCursor(options) {
671
+ const { unsubscribe } = await this._subscribeInternal(options);
672
+ return unsubscribe;
673
+ }
674
+ /**
675
+ * Metadata-aware variant of `subscribeWithCursor()` — see #99.
676
+ *
677
+ * Identical to `subscribeWithCursor` except it also returns the
678
+ * `realtimeMode` and `latestStreamId` from the server's
679
+ * subscribe-events ack so callers (notably `subscribeWithSnapshot`)
680
+ * can fail-fast on pubsub-mode collections and anchor the snapshot
681
+ * baseline atomically with the reader-attach.
682
+ *
683
+ * Defaults: if the server didn't send `realtimeMode` (older
684
+ * gateway), the SDK treats the collection as pubsub-mode so
685
+ * downstream fail-fasts still fire correctly. `latestStreamId`
686
+ * defaults to `null` when absent.
687
+ *
688
+ * Dedup-path note: when a sibling local subscription already holds
689
+ * the server-side reader slot for this streamKey, no new handshake
690
+ * is emitted — the SDK piggy-backs on the existing slot. In that
691
+ * case we synthesise `{ realtimeMode: 'stream', latestStreamId: null }`
692
+ * because the existing slot's prior ack is no longer available.
693
+ * Callers that need a fresh `latestStreamId` (e.g. snapshot anchor)
694
+ * MUST handle the `null` case.
695
+ */
696
+ async subscribeWithCursorWithMeta(options) {
697
+ const { unsubscribe, ack } = await this._subscribeInternal(options);
698
+ return {
699
+ unsubscribe,
700
+ // Default to pubsub if the server didn't send the field — older
701
+ // gateways that haven't been updated yet are treated as pubsub-mode
702
+ // so the SDK helper's fail-fast still fires correctly.
703
+ realtimeMode: ack.realtimeMode ?? "pubsub",
704
+ latestStreamId: ack.latestStreamId ?? null
705
+ };
706
+ }
707
+ /**
708
+ * Shared body for `subscribeWithCursor` and
709
+ * `subscribeWithCursorWithMeta`. Owns the handshake, dedup path,
710
+ * pre-ack registration, and error eviction. Returns the unsubscribe
711
+ * function plus the subscribe-events ack so metadata-aware callers
712
+ * can read `realtimeMode` / `latestStreamId`.
713
+ *
714
+ * Behaviour-preserving contract — DO NOT change without re-running
715
+ * the singleflight tests in `realtime.spec.ts`:
716
+ *
717
+ * 1. Dedup branch BEFORE registration: if a sibling subscriber for
718
+ * this streamKey is already registered, the new caller piggy-backs
719
+ * on its server-side slot. Re-emitting the handshake here would
720
+ * tear the prior subscriber's reader down.
721
+ * 2. Registration BEFORE handshake: `streamSubscriptions.set` must
722
+ * fire before `emitSubscribeEvents` so replay/event-gap frames
723
+ * delivered DURING the handshake fanout find this subscriber.
724
+ * 3. evictStreamKey on ack-error MUST sweep dedup-path siblings:
725
+ * they have no independent server-side state and would silently
726
+ * miss every event until the next full reconnect.
727
+ * 4. The catch block is a safety net for the re-thrown ack-error
728
+ * only — `emitSubscribeEvents` itself always resolves, so this
729
+ * catch should never fire on the success path.
730
+ */
731
+ async _subscribeInternal(options) {
635
732
  this.ensureWakeListeners();
636
733
  if (this.connectionState === "disconnected") {
637
734
  this.setConnectionState("reconnecting");
@@ -647,7 +744,15 @@ var RealtimeClient = class {
647
744
  const existing = this.streamSubscriptions.get(streamKey);
648
745
  if (existing && existing.size > 0) {
649
746
  existing.add(state);
650
- return () => this.unsubscribeStream(state);
747
+ const syntheticAck = {
748
+ subscribed: streamKey,
749
+ realtimeMode: "stream",
750
+ latestStreamId: null
751
+ };
752
+ return {
753
+ unsubscribe: () => this.unsubscribeStream(state),
754
+ ack: syntheticAck
755
+ };
651
756
  }
652
757
  const set = /* @__PURE__ */ new Set();
653
758
  set.add(state);
@@ -656,12 +761,15 @@ var RealtimeClient = class {
656
761
  const ack = await this.emitSubscribeEvents(state);
657
762
  if (ack.error) {
658
763
  const message = ack.message ?? ack.error;
659
- const err = new Error(message);
764
+ const err = new SpacelrError(message, ack.error);
660
765
  this.evictStreamKey(streamKey, err, state);
661
766
  options.onError?.(err);
662
767
  throw err;
663
768
  }
664
- return () => this.unsubscribeStream(state);
769
+ return {
770
+ unsubscribe: () => this.unsubscribeStream(state),
771
+ ack
772
+ };
665
773
  } catch (err) {
666
774
  const error = err instanceof Error ? err : new Error(String(err));
667
775
  this.evictStreamKey(streamKey, error, state);
@@ -740,6 +848,7 @@ var RealtimeClient = class {
740
848
  const wsUrl = this.config.baseUrl.replace(/\/api\/v\d+\/?$/, "");
741
849
  return new Promise((resolve, reject) => {
742
850
  let initialConnect = true;
851
+ let onInitialDisconnect = null;
743
852
  this.socket = io(`${wsUrl}/database`, {
744
853
  auth: async (cb) => {
745
854
  try {
@@ -750,8 +859,11 @@ var RealtimeClient = class {
750
859
  }
751
860
  },
752
861
  transports: ["websocket"],
862
+ // Disable auto-reconnect for the INITIAL connect — caller's promise
863
+ // rejects on connect_error and they decide whether to retry. Once the
864
+ // first authentication succeeds we re-enable it via the Manager's
865
+ // setters (see authenticated handler).
753
866
  reconnection: false
754
- // Disabled until first successful connect
755
867
  });
756
868
  this.socket.on("authenticated", () => {
757
869
  if (this.disposed) return;
@@ -759,10 +871,32 @@ var RealtimeClient = class {
759
871
  if (initialConnect) {
760
872
  initialConnect = false;
761
873
  if (this.socket) {
762
- this.socket.io.opts.reconnection = true;
763
- this.socket.io.opts.reconnectionDelay = 1e3;
764
- this.socket.io.opts.reconnectionDelayMax = 5e3;
765
- this.socket.io.opts.reconnectionAttempts = 50;
874
+ const manager = this.socket.io;
875
+ try {
876
+ manager.reconnection(true);
877
+ manager.skipReconnect = false;
878
+ manager.reconnectionDelay(1e3);
879
+ manager.reconnectionDelayMax(5e3);
880
+ manager.reconnectionAttempts(50);
881
+ } catch (err) {
882
+ if (onInitialDisconnect && this.socket) {
883
+ this.socket.off("disconnect", onInitialDisconnect);
884
+ onInitialDisconnect = null;
885
+ }
886
+ this.socket?.disconnect();
887
+ this.socket = null;
888
+ this.setConnectionState("disconnected");
889
+ reject(
890
+ new Error(
891
+ `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)}`
892
+ )
893
+ );
894
+ return;
895
+ }
896
+ }
897
+ if (onInitialDisconnect && this.socket) {
898
+ this.socket.off("disconnect", onInitialDisconnect);
899
+ onInitialDisconnect = null;
766
900
  }
767
901
  if (this.config.onTokenRefreshed && !this.unsubscribeFromTokenRefreshed) {
768
902
  this.unsubscribeFromTokenRefreshed = this.config.onTokenRefreshed(
@@ -782,6 +916,14 @@ var RealtimeClient = class {
782
916
  reject(new Error(`WebSocket connection failed: ${err.message}`));
783
917
  }
784
918
  });
919
+ onInitialDisconnect = (reason) => {
920
+ if (!initialConnect) return;
921
+ this.socket?.disconnect();
922
+ this.socket = null;
923
+ this.setConnectionState("disconnected");
924
+ reject(new Error(`WebSocket disconnected during handshake: ${reason}`));
925
+ };
926
+ this.socket.on("disconnect", onInitialDisconnect);
785
927
  this.socket.io.on("reconnect_failed", () => {
786
928
  void this.rebuildSocket();
787
929
  });
@@ -819,9 +961,10 @@ var RealtimeClient = class {
819
961
  }
820
962
  });
821
963
  this.socket.on("disconnect", () => {
822
- if (!this.disposed) {
823
- this.setConnectionState("reconnecting");
824
- }
964
+ if (this.disposed) return;
965
+ if (initialConnect) return;
966
+ if (!this.socket) return;
967
+ this.setConnectionState("reconnecting");
825
968
  });
826
969
  });
827
970
  }
@@ -1019,7 +1162,7 @@ var RealtimeClient = class {
1019
1162
  try {
1020
1163
  const ack = await this.emitSubscribeEvents(primary);
1021
1164
  if (ack.error) {
1022
- const err = new Error(ack.message ?? ack.error);
1165
+ const err = new SpacelrError(ack.message ?? ack.error, ack.error);
1023
1166
  for (const state of [...set]) {
1024
1167
  state.options.onError?.(err);
1025
1168
  }
@@ -1642,6 +1785,68 @@ var StorageModule = class {
1642
1785
  };
1643
1786
 
1644
1787
  // libs/sdk/src/modules/database.module.ts
1788
+ var Paginator = class {
1789
+ constructor(http, basePath, opts) {
1790
+ this.http = http;
1791
+ this.basePath = basePath;
1792
+ this._exhausted = false;
1793
+ /**
1794
+ * Tail of the serialized `next()` chain. Each call appends to this so
1795
+ * concurrent invocations from a UI scroll handler (e.g. user spam-tapping
1796
+ * "load more") don't issue parallel requests with the same cursor — which
1797
+ * would return duplicate pages and clobber the cursor based on whichever
1798
+ * response settles last. Calls execute strictly in invocation order.
1799
+ */
1800
+ this.chain = Promise.resolve({
1801
+ documents: [],
1802
+ hasMore: false
1803
+ });
1804
+ this.cursor = opts.cursor;
1805
+ this.direction = opts.sort?._id ?? -1;
1806
+ this.pageSize = opts.limit ?? 50;
1807
+ this.where = opts.where;
1808
+ }
1809
+ /**
1810
+ * True once a `next()` call has returned an empty page. Subsequent
1811
+ * `next()` calls return an empty page without hitting the server.
1812
+ *
1813
+ * Note: only the empty-page signal flips this flag; a server response
1814
+ * that returns documents but `hasMore: false` does NOT exhaust. This
1815
+ * lets ascending paginators (`sort: { _id: 1 }`) keep polling for
1816
+ * documents inserted after the caller caught up to the current tail —
1817
+ * the next `.after(lastSeen)` call will simply return zero documents
1818
+ * until something new lands.
1819
+ */
1820
+ get exhausted() {
1821
+ return this._exhausted;
1822
+ }
1823
+ next() {
1824
+ const run = this.chain.then(
1825
+ () => this.fetchNextPage(),
1826
+ () => this.fetchNextPage()
1827
+ );
1828
+ this.chain = run;
1829
+ return run;
1830
+ }
1831
+ async fetchNextPage() {
1832
+ if (this._exhausted) return { documents: [], hasMore: false };
1833
+ const builder = new QueryBuilder(this.http, this.basePath, this.where).sort({ _id: this.direction }).limit(this.pageSize);
1834
+ let result;
1835
+ if (this.cursor !== void 0) {
1836
+ const cursorBuilder = this.direction === -1 ? builder.before(this.cursor) : builder.after(this.cursor);
1837
+ result = await cursorBuilder.execute();
1838
+ } else {
1839
+ result = await builder.execute();
1840
+ }
1841
+ if (result.documents.length === 0) {
1842
+ this._exhausted = true;
1843
+ return { documents: [], hasMore: false };
1844
+ }
1845
+ this.cursor = result.documents[result.documents.length - 1]._id;
1846
+ const hasMore = result.mode === "cursor" ? result.hasMore : result.documents.length < result.total;
1847
+ return { documents: result.documents, hasMore };
1848
+ }
1849
+ };
1645
1850
  var QueryBuilder = class {
1646
1851
  constructor(http, basePath, filter) {
1647
1852
  this.http = http;
@@ -1709,7 +1914,13 @@ var QueryBuilder = class {
1709
1914
  this._populate.push({ field, collection, foreignField });
1710
1915
  return this;
1711
1916
  }
1712
- async execute() {
1917
+ /**
1918
+ * `signal` is an optional external AbortSignal forwarded into the underlying
1919
+ * HTTP request. Used by `subscribeWithSnapshot` so that an unsubscribe()
1920
+ * call cancels the in-flight snapshot find(); regular callers can leave it
1921
+ * undefined.
1922
+ */
1923
+ async execute(signal) {
1713
1924
  const query = {};
1714
1925
  if (this._filter) query["filter"] = JSON.stringify(this._filter);
1715
1926
  if (this._sort) query["sort"] = JSON.stringify(this._sort);
@@ -1727,7 +1938,8 @@ var QueryBuilder = class {
1727
1938
  method: "GET",
1728
1939
  path: this.basePath,
1729
1940
  query,
1730
- authenticated: true
1941
+ authenticated: true,
1942
+ signal
1731
1943
  });
1732
1944
  }
1733
1945
  };
@@ -1758,6 +1970,20 @@ var CollectionRef = class {
1758
1970
  find(filter) {
1759
1971
  return new QueryBuilder(this.http, this.basePath, filter);
1760
1972
  }
1973
+ /**
1974
+ * Cursor-based scroll-back helper. Returns a `Paginator` whose `.next()`
1975
+ * yields successive pages and tracks the last-seen `_id` internally.
1976
+ * Defaults to descending sort and 50 docs per page (chat scroll-back is
1977
+ * the canonical use case). See `PaginateOptions` for tuning.
1978
+ *
1979
+ * **Cursor constraint:** uses `_id` keyset pagination, which requires
1980
+ * 24-hex ObjectId `_id`s. Collections with custom string `_id` schemes
1981
+ * fall back to comparing strings as ObjectIds — the server's handler
1982
+ * documents this limitation on `before` / `after` directly.
1983
+ */
1984
+ paginate(opts = {}) {
1985
+ return new Paginator(this.http, this.basePath, opts);
1986
+ }
1761
1987
  /**
1762
1988
  * Server-side substring search across the specified fields.
1763
1989
  *
@@ -1766,6 +1992,13 @@ var CollectionRef = class {
1766
1992
  * cannot use a standard B-tree index — on very large collections consider
1767
1993
  * narrowing with `filter` to scope the scan.
1768
1994
  *
1995
+ * Required filters: collections may declare `searchConfig.requireFilter`
1996
+ * via the admin API. Calls without those keys at the top level of `filter`
1997
+ * (or inside a top-level `$and`) reject with
1998
+ * `SpacelrSearchFilterRequiredError` carrying `missingFields`. Allowed
1999
+ * shapes: `{ field: value }`, `{ field: { $eq: value } }`,
2000
+ * `{ field: { $in: [...] } }` (non-empty).
2001
+ *
1769
2002
  * Limits: `query` 1–200 chars, `fields` 1–10 entries (each matching
1770
2003
  * `/^[a-zA-Z0-9_.]+$/`, max 64 chars), `limit` max 100.
1771
2004
  */
@@ -1950,6 +2183,291 @@ var CollectionRef = class {
1950
2183
  }
1951
2184
  };
1952
2185
  }
2186
+ /**
2187
+ * Snapshot-aware subscribe — see #99 / SubscribeWithSnapshotOptions.
2188
+ *
2189
+ * Returns a Promise that resolves with the unsubscribe function only
2190
+ * after the first `onSnapshot` has run to completion (initial-load
2191
+ * signal). Calling unsubscribe() while the snapshot find() is in flight
2192
+ * aborts the request and silences the AbortError. Gap-recovery state
2193
+ * machine lands in Task 6.
2194
+ */
2195
+ async subscribeWithSnapshot(opts) {
2196
+ if (!this.realtime) {
2197
+ throw new Error("Realtime not available: no RealtimeClient configured");
2198
+ }
2199
+ let isActive = true;
2200
+ let currentUnsubscribe = null;
2201
+ let snapshotAbort = new AbortController();
2202
+ let recoveryState = "idle";
2203
+ const realtime = this.realtime;
2204
+ const streamWhere = (() => {
2205
+ if (!opts.where) return void 0;
2206
+ const out = {};
2207
+ for (const [k, v] of Object.entries(opts.where)) {
2208
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
2209
+ out[k] = v;
2210
+ }
2211
+ }
2212
+ return Object.keys(out).length > 0 ? out : void 0;
2213
+ })();
2214
+ if (opts.where && streamWhere === void 0 && Object.keys(opts.where).length > 0) {
2215
+ console.warn(
2216
+ `[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.`
2217
+ );
2218
+ }
2219
+ const runRecovery = async () => {
2220
+ currentUnsubscribe?.();
2221
+ currentUnsubscribe = null;
2222
+ if (!isActive) return;
2223
+ let recoveryAckResolved = false;
2224
+ let recoverySnapshotDelivered = false;
2225
+ const pendingRecoveryEvents = [];
2226
+ let next;
2227
+ try {
2228
+ next = await realtime.subscribeWithCursorWithMeta({
2229
+ projectId: this.projectId,
2230
+ collectionName: this.collectionName,
2231
+ sinceId: void 0,
2232
+ where: streamWhere,
2233
+ onEvent: async ({ event }) => {
2234
+ if (!isActive) return;
2235
+ if (!recoverySnapshotDelivered) {
2236
+ pendingRecoveryEvents.push(event);
2237
+ return;
2238
+ }
2239
+ await opts.onChange(event);
2240
+ },
2241
+ onError: (err) => {
2242
+ if (!isActive) return;
2243
+ if (!recoveryAckResolved) return;
2244
+ isActive = false;
2245
+ currentUnsubscribe?.();
2246
+ currentUnsubscribe = null;
2247
+ snapshotAbort.abort();
2248
+ opts.onError?.(err);
2249
+ },
2250
+ onGap: handleGap
2251
+ });
2252
+ recoveryAckResolved = true;
2253
+ } catch (err) {
2254
+ if (!isActive) return;
2255
+ opts.onError?.(err instanceof Error ? err : new Error(String(err)));
2256
+ return;
2257
+ }
2258
+ if (!isActive) {
2259
+ next.unsubscribe();
2260
+ return;
2261
+ }
2262
+ currentUnsubscribe = next.unsubscribe;
2263
+ snapshotAbort = new AbortController();
2264
+ let recoveryDocs;
2265
+ try {
2266
+ const builder = this.find(opts.where);
2267
+ if (opts.sort) builder.sort(opts.sort);
2268
+ if (opts.limit !== void 0) builder.limit(opts.limit);
2269
+ if (opts.select) builder.select(opts.select);
2270
+ const result = await builder.execute(snapshotAbort.signal);
2271
+ recoveryDocs = result.documents;
2272
+ } catch (err) {
2273
+ if (!isActive) return;
2274
+ if (err instanceof DOMException && err.name === "AbortError") return;
2275
+ currentUnsubscribe?.();
2276
+ currentUnsubscribe = null;
2277
+ isActive = false;
2278
+ const error = err instanceof Error ? err : new Error(String(err));
2279
+ if (opts.onSnapshotError) opts.onSnapshotError(error);
2280
+ else opts.onError?.(error);
2281
+ return;
2282
+ }
2283
+ if (!isActive) return;
2284
+ try {
2285
+ await opts.onSnapshot(recoveryDocs, next.latestStreamId);
2286
+ } catch (err) {
2287
+ currentUnsubscribe?.();
2288
+ currentUnsubscribe = null;
2289
+ isActive = false;
2290
+ const error = err instanceof Error ? err : new Error(String(err));
2291
+ opts.onError?.(error);
2292
+ return;
2293
+ }
2294
+ while (true) {
2295
+ if (!isActive) return;
2296
+ if (pendingRecoveryEvents.length === 0) {
2297
+ recoverySnapshotDelivered = true;
2298
+ break;
2299
+ }
2300
+ const ev = pendingRecoveryEvents.shift();
2301
+ if (ev !== void 0) {
2302
+ try {
2303
+ await opts.onChange(ev);
2304
+ } catch (err) {
2305
+ currentUnsubscribe?.();
2306
+ currentUnsubscribe = null;
2307
+ isActive = false;
2308
+ const error = err instanceof Error ? err : new Error(String(err));
2309
+ opts.onError?.(error);
2310
+ return;
2311
+ }
2312
+ }
2313
+ }
2314
+ };
2315
+ const dispatchRecovery = () => {
2316
+ if (!isActive) return;
2317
+ if (recoveryState === "running") {
2318
+ recoveryState = "pending-rerun";
2319
+ return;
2320
+ }
2321
+ if (recoveryState === "pending-rerun") {
2322
+ return;
2323
+ }
2324
+ recoveryState = "running";
2325
+ void (async () => {
2326
+ await Promise.resolve();
2327
+ while (true) {
2328
+ try {
2329
+ await runRecovery();
2330
+ } catch (err) {
2331
+ recoveryState = "idle";
2332
+ if (!isActive) return;
2333
+ const error = err instanceof Error ? err : new Error(String(err));
2334
+ try {
2335
+ opts.onError?.(error);
2336
+ } catch {
2337
+ }
2338
+ return;
2339
+ }
2340
+ if (!isActive) {
2341
+ recoveryState = "idle";
2342
+ return;
2343
+ }
2344
+ if (recoveryState === "pending-rerun") {
2345
+ recoveryState = "running";
2346
+ continue;
2347
+ }
2348
+ recoveryState = "idle";
2349
+ return;
2350
+ }
2351
+ })();
2352
+ };
2353
+ const handleGap = (info) => {
2354
+ if (!isActive) return;
2355
+ if (info.reason !== "outside-retention-window") return;
2356
+ dispatchRecovery();
2357
+ };
2358
+ let subscribeAckResolved = false;
2359
+ let initialSnapshotDelivered = false;
2360
+ const pendingInitialEvents = [];
2361
+ let initial;
2362
+ try {
2363
+ initial = await realtime.subscribeWithCursorWithMeta({
2364
+ projectId: this.projectId,
2365
+ collectionName: this.collectionName,
2366
+ sinceId: void 0,
2367
+ where: streamWhere,
2368
+ onEvent: async ({ event }) => {
2369
+ if (!isActive) return;
2370
+ if (!initialSnapshotDelivered) {
2371
+ pendingInitialEvents.push(event);
2372
+ return;
2373
+ }
2374
+ await opts.onChange(event);
2375
+ },
2376
+ onError: (err) => {
2377
+ if (!isActive) return;
2378
+ if (!subscribeAckResolved) return;
2379
+ isActive = false;
2380
+ snapshotAbort.abort();
2381
+ currentUnsubscribe?.();
2382
+ currentUnsubscribe = null;
2383
+ opts.onError?.(err);
2384
+ },
2385
+ onGap: handleGap
2386
+ });
2387
+ subscribeAckResolved = true;
2388
+ } catch (err) {
2389
+ isActive = false;
2390
+ const code = err instanceof SpacelrError ? err.code : void 0;
2391
+ if (code === "not-stream-collection") {
2392
+ throw new SpacelrError(
2393
+ `subscribeWithSnapshot requires a stream-mode collection. Use subscribe() for pubsub collections.`,
2394
+ "SUBSCRIBE_WITH_SNAPSHOT_REQUIRES_STREAM",
2395
+ void 0,
2396
+ { collectionName: this.collectionName }
2397
+ );
2398
+ }
2399
+ throw err;
2400
+ }
2401
+ if (initial.realtimeMode !== "stream") {
2402
+ isActive = false;
2403
+ initial.unsubscribe();
2404
+ throw new SpacelrError(
2405
+ `subscribeWithSnapshot requires a stream-mode collection. Use subscribe() for pubsub collections.`,
2406
+ "SUBSCRIBE_WITH_SNAPSHOT_REQUIRES_STREAM",
2407
+ void 0,
2408
+ { collectionName: this.collectionName }
2409
+ );
2410
+ }
2411
+ currentUnsubscribe = initial.unsubscribe;
2412
+ let snapshotDocs;
2413
+ try {
2414
+ const builder = this.find(opts.where);
2415
+ if (opts.sort) builder.sort(opts.sort);
2416
+ if (opts.limit !== void 0) builder.limit(opts.limit);
2417
+ if (opts.select) builder.select(opts.select);
2418
+ const result = await builder.execute(snapshotAbort.signal);
2419
+ snapshotDocs = result.documents;
2420
+ } catch (err) {
2421
+ currentUnsubscribe?.();
2422
+ isActive = false;
2423
+ const isAbort = err instanceof DOMException && err.name === "AbortError";
2424
+ if (isAbort) throw err;
2425
+ const error = err instanceof Error ? err : new Error(String(err));
2426
+ if (opts.onSnapshotError) opts.onSnapshotError(error);
2427
+ else opts.onError?.(error);
2428
+ throw error;
2429
+ }
2430
+ if (!isActive) {
2431
+ currentUnsubscribe?.();
2432
+ return () => void 0;
2433
+ }
2434
+ try {
2435
+ await opts.onSnapshot(snapshotDocs, initial.latestStreamId);
2436
+ } catch (err) {
2437
+ currentUnsubscribe?.();
2438
+ currentUnsubscribe = null;
2439
+ isActive = false;
2440
+ const error = err instanceof Error ? err : new Error(String(err));
2441
+ opts.onError?.(error);
2442
+ throw error;
2443
+ }
2444
+ while (true) {
2445
+ if (!isActive) return () => void 0;
2446
+ if (pendingInitialEvents.length === 0) {
2447
+ initialSnapshotDelivered = true;
2448
+ break;
2449
+ }
2450
+ const ev = pendingInitialEvents.shift();
2451
+ if (ev !== void 0) {
2452
+ try {
2453
+ await opts.onChange(ev);
2454
+ } catch (err) {
2455
+ currentUnsubscribe?.();
2456
+ currentUnsubscribe = null;
2457
+ isActive = false;
2458
+ const error = err instanceof Error ? err : new Error(String(err));
2459
+ opts.onError?.(error);
2460
+ throw error;
2461
+ }
2462
+ }
2463
+ }
2464
+ return () => {
2465
+ if (!isActive) return;
2466
+ isActive = false;
2467
+ snapshotAbort.abort();
2468
+ currentUnsubscribe?.();
2469
+ };
2470
+ }
1953
2471
  };
1954
2472
  var DatabaseModule = class {
1955
2473
  constructor(http, projectId, realtime) {
@@ -2212,6 +2730,7 @@ export {
2212
2730
  SpacelrEmailVerificationRequiredError,
2213
2731
  SpacelrError,
2214
2732
  SpacelrNetworkError,
2733
+ SpacelrSearchFilterRequiredError,
2215
2734
  SpacelrTimeoutError,
2216
2735
  SpacelrTwoFactorRequiredError,
2217
2736
  createClient,