@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.d.mts +268 -24
- package/dist/index.d.ts +268 -24
- package/dist/index.js +535 -15
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +534 -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
|
}
|
|
@@ -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
|
-
|
|
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,
|