@spacelr/sdk 0.2.2 → 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.d.mts +191 -2
- package/dist/index.d.ts +191 -2
- package/dist/index.js +459 -15
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +458 -15
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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 (
|
|
857
|
-
|
|
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
|
|
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
|
}
|
|
@@ -1805,7 +1949,13 @@ var QueryBuilder = class {
|
|
|
1805
1949
|
this._populate.push({ field, collection, foreignField });
|
|
1806
1950
|
return this;
|
|
1807
1951
|
}
|
|
1808
|
-
|
|
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) {
|
|
1809
1959
|
const query = {};
|
|
1810
1960
|
if (this._filter) query["filter"] = JSON.stringify(this._filter);
|
|
1811
1961
|
if (this._sort) query["sort"] = JSON.stringify(this._sort);
|
|
@@ -1823,7 +1973,8 @@ var QueryBuilder = class {
|
|
|
1823
1973
|
method: "GET",
|
|
1824
1974
|
path: this.basePath,
|
|
1825
1975
|
query,
|
|
1826
|
-
authenticated: true
|
|
1976
|
+
authenticated: true,
|
|
1977
|
+
signal
|
|
1827
1978
|
});
|
|
1828
1979
|
}
|
|
1829
1980
|
};
|
|
@@ -1876,6 +2027,13 @@ var CollectionRef = class {
|
|
|
1876
2027
|
* cannot use a standard B-tree index — on very large collections consider
|
|
1877
2028
|
* narrowing with `filter` to scope the scan.
|
|
1878
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
|
+
*
|
|
1879
2037
|
* Limits: `query` 1–200 chars, `fields` 1–10 entries (each matching
|
|
1880
2038
|
* `/^[a-zA-Z0-9_.]+$/`, max 64 chars), `limit` max 100.
|
|
1881
2039
|
*/
|
|
@@ -2060,6 +2218,291 @@ var CollectionRef = class {
|
|
|
2060
2218
|
}
|
|
2061
2219
|
};
|
|
2062
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
|
+
}
|
|
2063
2506
|
};
|
|
2064
2507
|
var DatabaseModule = class {
|
|
2065
2508
|
constructor(http, projectId, realtime) {
|
|
@@ -2323,6 +2766,7 @@ function createClient(config) {
|
|
|
2323
2766
|
SpacelrEmailVerificationRequiredError,
|
|
2324
2767
|
SpacelrError,
|
|
2325
2768
|
SpacelrNetworkError,
|
|
2769
|
+
SpacelrSearchFilterRequiredError,
|
|
2326
2770
|
SpacelrTimeoutError,
|
|
2327
2771
|
SpacelrTwoFactorRequiredError,
|
|
2328
2772
|
createClient,
|