@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.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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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 (
|
|
823
|
-
|
|
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
|
|
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
|
-
|
|
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,
|