@rebasepro/client 0.22.0 → 0.23.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/admin.d.ts +13 -8
- package/dist/auth.d.ts +24 -5
- package/dist/backups.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.es.js +718 -132
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +8 -0
- package/dist/offline-query.d.ts +9 -5
- package/dist/offline-store.d.ts +22 -0
- package/dist/offline.d.ts +30 -0
- package/dist/realtime-channel.d.ts +30 -0
- package/dist/transport.d.ts +17 -0
- package/dist/websocket.d.ts +34 -0
- package/package.json +4 -4
package/dist/index.es.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, RebaseClientError as RebaseClientError$1, SCHEMA_VERSION_HEADER, Vector, hasFieldOperation, isPublicStoragePath, isUnsupported, parseRelationAggregateSort, sortKeyToString, toCanonicalOp, unsupportedMethod } from "@rebasepro/types";
|
|
2
|
-
import { COMPOSITE_ID_SEPARATOR, QueryBuilder, RebasePaginationError, and, buildCompositeId, collectAllPages, cond, mergeIncludeSpecs, normalizeOrderBy, not, or, paginateFind, resolveFindWindow, serializeFilter, serializeInclude, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
|
|
2
|
+
import { COMPOSITE_ID_SEPARATOR, QueryBuilder, RESERVED_QUERY_KEYS, RebasePaginationError, and, buildCompositeId, collectAllPages, cond, mergeIncludeSpecs, normalizeOrderBy, not, or, paginateFind, resolveFindWindow, serializeFilter, serializeInclude, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
|
|
3
3
|
import { splitSearchTerms, toSnakeCase, unref } from "@rebasepro/utils";
|
|
4
4
|
//#region src/reviver.ts
|
|
5
5
|
function rebaseReviver(_key, value) {
|
|
@@ -118,13 +118,33 @@ function buildQueryString(params) {
|
|
|
118
118
|
const serialized = (root.conditions ?? []).map(serializeLogicalCondition).join(",");
|
|
119
119
|
parts.push(`${root.type}=${encodeURIComponent(`(${serialized})`)}`);
|
|
120
120
|
}
|
|
121
|
-
if (params.where)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
if (params.where) appendWhere(parts, params.where);
|
|
122
|
+
return parts.length > 0 ? "?" + parts.join("&") : "";
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Append a `where` to a query string, one `?<field>=<op>.<value>` parameter per
|
|
126
|
+
* condition.
|
|
127
|
+
*
|
|
128
|
+
* Except on a column whose name the server reads as a parameter of its own —
|
|
129
|
+
* `page`, `select`, `hard`, `where` ({@link RESERVED_QUERY_KEYS}). A filter on
|
|
130
|
+
* such a column sent as its own parameter is read as that parameter, and the
|
|
131
|
+
* filter is never applied. Those conditions go inside `?where=` instead, in the
|
|
132
|
+
* same dot-string spelling, which the server decodes through the same codec and
|
|
133
|
+
* merges in. A collection with no such column sends exactly what it did before.
|
|
134
|
+
*/
|
|
135
|
+
function appendWhere(parts, where) {
|
|
136
|
+
assertNoUndefinedFilterValues(where);
|
|
137
|
+
const serialized = serializeFilter(where);
|
|
138
|
+
const reserved = {};
|
|
139
|
+
for (const [field, value] of Object.entries(serialized)) {
|
|
140
|
+
if (RESERVED_QUERY_KEYS.has(field)) {
|
|
141
|
+
reserved[field] = value;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (Array.isArray(value)) for (const v of value) parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(v)}`);
|
|
125
145
|
else parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(value)}`);
|
|
126
146
|
}
|
|
127
|
-
|
|
147
|
+
if (Object.keys(reserved).length > 0) parts.push(`where=${encodeURIComponent(JSON.stringify(reserved))}`);
|
|
128
148
|
}
|
|
129
149
|
/**
|
|
130
150
|
* The query string for `GET /<collection>/aggregate`.
|
|
@@ -151,12 +171,7 @@ function buildAggregateQueryString(params) {
|
|
|
151
171
|
const serialized = (root.conditions ?? []).map(serializeLogicalCondition).join(",");
|
|
152
172
|
parts.push(`${root.type}=${encodeURIComponent(`(${serialized})`)}`);
|
|
153
173
|
}
|
|
154
|
-
if (params.where)
|
|
155
|
-
assertNoUndefinedFilterValues(params.where);
|
|
156
|
-
const serialized = serializeFilter(params.where);
|
|
157
|
-
for (const [field, value] of Object.entries(serialized)) if (Array.isArray(value)) for (const v of value) parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(v)}`);
|
|
158
|
-
else parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(value)}`);
|
|
159
|
-
}
|
|
174
|
+
if (params.where) appendWhere(parts, params.where);
|
|
160
175
|
return "?" + parts.join("&");
|
|
161
176
|
}
|
|
162
177
|
/**
|
|
@@ -251,12 +266,14 @@ function createTransport(config, environment) {
|
|
|
251
266
|
...config.schemaVersion ? { [SCHEMA_VERSION_HEADER]: config.schemaVersion } : {}
|
|
252
267
|
};
|
|
253
268
|
function getHeaders(activeToken, init) {
|
|
254
|
-
|
|
269
|
+
const headers = {
|
|
255
270
|
"Content-Type": "application/json",
|
|
256
271
|
...defaultHeaders,
|
|
257
272
|
...activeToken ? { Authorization: `Bearer ${activeToken}` } : {},
|
|
258
273
|
...init?.headers || {}
|
|
259
274
|
};
|
|
275
|
+
if (init?.body instanceof FormData) delete headers["Content-Type"];
|
|
276
|
+
return headers;
|
|
260
277
|
}
|
|
261
278
|
/**
|
|
262
279
|
* The refusal for a success status carrying a body this client cannot read.
|
|
@@ -330,7 +347,6 @@ function createTransport(config, environment) {
|
|
|
330
347
|
} catch (e) {}
|
|
331
348
|
warnIfAnonymousServerClient(activeToken);
|
|
332
349
|
const headers = getHeaders(activeToken, init);
|
|
333
|
-
if (init?.body instanceof FormData) delete headers["Content-Type"];
|
|
334
350
|
const res = await fetchFn(url, {
|
|
335
351
|
...init,
|
|
336
352
|
headers
|
|
@@ -505,9 +521,22 @@ function createAuth(transport, options) {
|
|
|
505
521
|
const REFRESH_RETRY_BASE_MS = 1e3;
|
|
506
522
|
const REFRESH_RETRY_MAX_MS = 3e4;
|
|
507
523
|
let currentSession = null;
|
|
524
|
+
/**
|
|
525
|
+
* Which sign-in `currentSession` belongs to. Bumped whenever the session
|
|
526
|
+
* is replaced by anything other than its own refresh — a sign-in, a
|
|
527
|
+
* sign-out, a revocation, a local abandon — so a refresh already in flight
|
|
528
|
+
* can tell, when its answer lands, that the session it was refreshing has
|
|
529
|
+
* ended. Its answer is then dropped: adopting it put a signed-out session
|
|
530
|
+
* back in memory, in storage and on the transport, announced
|
|
531
|
+
* TOKEN_REFRESHED after SIGNED_OUT, and overwrote whoever had signed in
|
|
532
|
+
* since.
|
|
533
|
+
*/
|
|
534
|
+
let sessionEpoch = 0;
|
|
508
535
|
const listeners = /* @__PURE__ */ new Set();
|
|
509
536
|
let refreshTimeout = null;
|
|
510
537
|
let inFlightRefresh = null;
|
|
538
|
+
/** The `sessionEpoch` the in-flight refresh was started for. */
|
|
539
|
+
let inFlightEpoch = -1;
|
|
511
540
|
let resolveInitialized;
|
|
512
541
|
const isInitialized = new Promise((resolve) => {
|
|
513
542
|
resolveInitialized = resolve;
|
|
@@ -569,10 +598,25 @@ function createAuth(transport, options) {
|
|
|
569
598
|
* may be stale precisely because a sibling tab holds a live one, and
|
|
570
599
|
* logging out on its behalf would turn one tab's bad luck into everybody
|
|
571
600
|
* being signed out — the exact failure this work exists to remove.
|
|
601
|
+
*
|
|
602
|
+
* Storage is left alone when it no longer holds the session being
|
|
603
|
+
* abandoned. It is shared with every other tab of the app, so a different
|
|
604
|
+
* session there is a sibling's live one — a rotation this tab missed, or
|
|
605
|
+
* another sign-in — and deleting it signed that tab out on its next load.
|
|
572
606
|
*/
|
|
573
607
|
function abandonSessionLocally() {
|
|
608
|
+
endSession(storageHoldsCurrentSession());
|
|
609
|
+
}
|
|
610
|
+
/** Whether storage holds this client's session — or no session at all. */
|
|
611
|
+
function storageHoldsCurrentSession() {
|
|
612
|
+
const persisted = loadStoredSession();
|
|
613
|
+
return !persisted || !currentSession || persisted.refreshToken === currentSession.refreshToken;
|
|
614
|
+
}
|
|
615
|
+
/** Drop the session from memory, storage and the transport, and say so. */
|
|
616
|
+
function endSession(clearStorage = true) {
|
|
617
|
+
sessionEpoch++;
|
|
574
618
|
currentSession = null;
|
|
575
|
-
clearStoredSession();
|
|
619
|
+
if (clearStorage) clearStoredSession();
|
|
576
620
|
if (refreshTimeout) {
|
|
577
621
|
clearTimeout(refreshTimeout);
|
|
578
622
|
refreshTimeout = null;
|
|
@@ -600,32 +644,39 @@ function createAuth(transport, options) {
|
|
|
600
644
|
abandonSessionLocally();
|
|
601
645
|
return false;
|
|
602
646
|
}
|
|
647
|
+
const epoch = sessionEpoch;
|
|
603
648
|
try {
|
|
604
649
|
await refreshSession();
|
|
605
|
-
return
|
|
650
|
+
return epoch === sessionEpoch;
|
|
606
651
|
} catch (err) {
|
|
607
|
-
if (isFatalRefreshError(err)) abandonSessionLocally();
|
|
652
|
+
if (epoch === sessionEpoch && isFatalRefreshError(err)) abandonSessionLocally();
|
|
608
653
|
return false;
|
|
609
654
|
}
|
|
610
655
|
}
|
|
611
656
|
async function attemptScheduledRefresh(attempt) {
|
|
657
|
+
const epoch = sessionEpoch;
|
|
612
658
|
try {
|
|
613
659
|
await refreshSession();
|
|
614
660
|
} catch (err) {
|
|
661
|
+
if (epoch !== sessionEpoch) return;
|
|
615
662
|
if (isFatalRefreshError(err)) {
|
|
616
663
|
abandonSessionLocally();
|
|
617
664
|
return;
|
|
618
665
|
}
|
|
619
666
|
if (attempt >= MAX_REFRESH_RETRIES) {
|
|
620
|
-
abandonSessionLocally();
|
|
667
|
+
if (err instanceof RebaseApiError && (err.status === 401 || err.status === 403)) abandonSessionLocally();
|
|
621
668
|
return;
|
|
622
669
|
}
|
|
623
|
-
|
|
624
|
-
refreshTimeout = setTimeout(() => {
|
|
625
|
-
attemptScheduledRefresh(attempt + 1);
|
|
626
|
-
}, backoff);
|
|
670
|
+
scheduleRetry(attempt);
|
|
627
671
|
}
|
|
628
672
|
}
|
|
673
|
+
/** Try the scheduled refresh again after a backoff: 1s, 2s, 4s, … capped. */
|
|
674
|
+
function scheduleRetry(attempt) {
|
|
675
|
+
const backoff = Math.min(REFRESH_RETRY_BASE_MS * 2 ** attempt, REFRESH_RETRY_MAX_MS);
|
|
676
|
+
refreshTimeout = setTimeout(() => {
|
|
677
|
+
attemptScheduledRefresh(attempt + 1);
|
|
678
|
+
}, backoff);
|
|
679
|
+
}
|
|
629
680
|
function scheduleRefresh(expiresAt) {
|
|
630
681
|
if (refreshTimeout) clearTimeout(refreshTimeout);
|
|
631
682
|
if (!autoRefresh) return;
|
|
@@ -666,6 +717,7 @@ function createAuth(transport, options) {
|
|
|
666
717
|
}
|
|
667
718
|
}
|
|
668
719
|
function handleAuthResponse(data, event) {
|
|
720
|
+
sessionEpoch++;
|
|
669
721
|
const user = mapRawUser(data.user);
|
|
670
722
|
const session = {
|
|
671
723
|
accessToken: data.tokens.accessToken,
|
|
@@ -847,6 +899,7 @@ function createAuth(transport, options) {
|
|
|
847
899
|
});
|
|
848
900
|
}
|
|
849
901
|
async function signOut() {
|
|
902
|
+
sessionEpoch++;
|
|
850
903
|
const fetchFn = getFetch();
|
|
851
904
|
try {
|
|
852
905
|
if (authFlowMode === "cookie" || currentSession?.refreshToken) await fetchFn(authUrl("/logout"), {
|
|
@@ -856,14 +909,7 @@ function createAuth(transport, options) {
|
|
|
856
909
|
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
857
910
|
});
|
|
858
911
|
} catch (e) {}
|
|
859
|
-
|
|
860
|
-
clearStoredSession();
|
|
861
|
-
if (refreshTimeout) {
|
|
862
|
-
clearTimeout(refreshTimeout);
|
|
863
|
-
refreshTimeout = null;
|
|
864
|
-
}
|
|
865
|
-
transport.setToken(null);
|
|
866
|
-
emit("SIGNED_OUT", null);
|
|
912
|
+
endSession();
|
|
867
913
|
}
|
|
868
914
|
/**
|
|
869
915
|
* Serialise refreshes across TABS, not just within one.
|
|
@@ -899,13 +945,85 @@ function createAuth(transport, options) {
|
|
|
899
945
|
}
|
|
900
946
|
}
|
|
901
947
|
function refreshSession() {
|
|
902
|
-
if (inFlightRefresh) return inFlightRefresh;
|
|
903
|
-
|
|
904
|
-
inFlightRefresh = null;
|
|
948
|
+
if (inFlightRefresh && inFlightEpoch === sessionEpoch) return inFlightRefresh;
|
|
949
|
+
const refresh = withRefreshLock(() => doRefreshSession()).finally(() => {
|
|
950
|
+
if (inFlightRefresh === refresh) inFlightRefresh = null;
|
|
905
951
|
});
|
|
906
|
-
|
|
952
|
+
inFlightRefresh = refresh;
|
|
953
|
+
inFlightEpoch = sessionEpoch;
|
|
954
|
+
return refresh;
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* What a refresh answers when its session ended while it was in flight.
|
|
958
|
+
*
|
|
959
|
+
* The tokens it received belong to a session this client no longer holds,
|
|
960
|
+
* so they are dropped, and so is any error it hit: a refusal of the old
|
|
961
|
+
* session's token says nothing about the new one, and acting on it signed
|
|
962
|
+
* out whoever had signed in since. The caller gets the session there is
|
|
963
|
+
* now — or, if there is none, is told so.
|
|
964
|
+
*/
|
|
965
|
+
function supersededRefresh() {
|
|
966
|
+
if (currentSession) return currentSession;
|
|
967
|
+
throw new RebaseClientError$1("The session was signed out while it was being refreshed.", { code: "NOT_SIGNED_IN" });
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* The session a sibling tab has persisted since this one last looked, when
|
|
971
|
+
* it continues this one.
|
|
972
|
+
*
|
|
973
|
+
* Tabs of one app share `localStorage` but each holds its session in
|
|
974
|
+
* memory, and a refresh rotates the refresh token. Once one tab has
|
|
975
|
+
* refreshed, every other tab is holding a token the server has already
|
|
976
|
+
* superseded: presented after the server's reuse window it is refused
|
|
977
|
+
* with TOKEN_ALREADY_USED, again on every retry, until the tab gives up
|
|
978
|
+
* and signs out. Its replacement was in storage all along.
|
|
979
|
+
*
|
|
980
|
+
* Only a session of the same user counts. A different user in storage
|
|
981
|
+
* means another tab signed someone else in, and that is not this tab's
|
|
982
|
+
* session to take over.
|
|
983
|
+
*/
|
|
984
|
+
function siblingSession() {
|
|
985
|
+
if (!persistSession || authFlowMode === "cookie" || !currentSession) return null;
|
|
986
|
+
const persisted = loadStoredSession();
|
|
987
|
+
if (!persisted?.accessToken || !persisted.refreshToken) return null;
|
|
988
|
+
if (persisted.refreshToken === currentSession.refreshToken) return null;
|
|
989
|
+
if (!persisted.user?.uid || persisted.user.uid !== currentSession.user?.uid) return null;
|
|
990
|
+
return persisted;
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Take over a sibling's session. Its access token is used as it is while
|
|
994
|
+
* it has time left — exactly as a session restored on load would be — and
|
|
995
|
+
* otherwise this answers `null` and the caller refreshes, now with the
|
|
996
|
+
* sibling's refresh token rather than the superseded one.
|
|
997
|
+
*/
|
|
998
|
+
function adoptSibling(sibling) {
|
|
999
|
+
currentSession = sibling;
|
|
1000
|
+
if (sibling.expiresAt - REFRESH_BUFFER_MS <= Date.now()) return null;
|
|
1001
|
+
transport.setToken(sibling.accessToken);
|
|
1002
|
+
scheduleRefresh(sibling.expiresAt);
|
|
1003
|
+
emit("TOKEN_REFRESHED", sibling);
|
|
1004
|
+
return sibling;
|
|
907
1005
|
}
|
|
908
1006
|
async function doRefreshSession() {
|
|
1007
|
+
const epoch = sessionEpoch;
|
|
1008
|
+
try {
|
|
1009
|
+
const sibling = siblingSession();
|
|
1010
|
+
if (sibling) {
|
|
1011
|
+
const adopted = adoptSibling(sibling);
|
|
1012
|
+
if (adopted) return adopted;
|
|
1013
|
+
}
|
|
1014
|
+
try {
|
|
1015
|
+
return await requestRefresh(epoch);
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
const late = epoch === sessionEpoch && err instanceof RebaseApiError && err.code === "TOKEN_ALREADY_USED" ? siblingSession() : null;
|
|
1018
|
+
if (!late) throw err;
|
|
1019
|
+
return adoptSibling(late) ?? await requestRefresh(epoch);
|
|
1020
|
+
}
|
|
1021
|
+
} catch (err) {
|
|
1022
|
+
if (epoch !== sessionEpoch) return supersededRefresh();
|
|
1023
|
+
throw err;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
async function requestRefresh(epoch) {
|
|
909
1027
|
if (authFlowMode !== "cookie" && !currentSession?.refreshToken) throw new RebaseClientError$1("No active session to refresh. Sign in first, or set `authFlowMode: \"cookie\"` if the refresh token is held in an HttpOnly cookie.", { code: "NOT_SIGNED_IN" });
|
|
910
1028
|
const res = await getFetch()(authUrl("/refresh"), {
|
|
911
1029
|
method: "POST",
|
|
@@ -914,14 +1032,18 @@ function createAuth(transport, options) {
|
|
|
914
1032
|
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
915
1033
|
});
|
|
916
1034
|
const body = await res.json().catch(() => ({}));
|
|
1035
|
+
if (epoch !== sessionEpoch) return supersededRefresh();
|
|
917
1036
|
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
918
1037
|
const accessToken = body.tokens.accessToken;
|
|
919
1038
|
transport.setToken(accessToken);
|
|
920
1039
|
let user = currentSession?.user;
|
|
921
1040
|
if (body.user && typeof body.user.uid === "string") user = mapRawUser(body.user);
|
|
922
|
-
else if (!user || !user.uid)
|
|
923
|
-
|
|
924
|
-
|
|
1041
|
+
else if (!user || !user.uid) {
|
|
1042
|
+
try {
|
|
1043
|
+
user = await getUser();
|
|
1044
|
+
} catch {}
|
|
1045
|
+
if (epoch !== sessionEpoch) return supersededRefresh();
|
|
1046
|
+
}
|
|
925
1047
|
const session = {
|
|
926
1048
|
accessToken,
|
|
927
1049
|
refreshToken: body.tokens.refreshToken || currentSession?.refreshToken || "",
|
|
@@ -1203,27 +1325,38 @@ function createAuth(transport, options) {
|
|
|
1203
1325
|
body: JSON.stringify({ factorId })
|
|
1204
1326
|
});
|
|
1205
1327
|
},
|
|
1206
|
-
/**
|
|
1207
|
-
|
|
1328
|
+
/**
|
|
1329
|
+
* Open a challenge against a verified factor. Expires in five minutes.
|
|
1330
|
+
*
|
|
1331
|
+
* Pass `mfaToken` to finish a sign-in that was refused with
|
|
1332
|
+
* `MFA_REQUIRED`: it is the `details.mfaToken` of that refusal, and the
|
|
1333
|
+
* challenge is opened with it as this one request's credential. Without
|
|
1334
|
+
* it, the challenge steps up the session this client already holds.
|
|
1335
|
+
*/
|
|
1336
|
+
async challenge(factorId, options) {
|
|
1337
|
+
const payload = JSON.stringify({ factorId });
|
|
1338
|
+
if (options?.mfaToken) return withPendingToken("/mfa/challenge", payload, options.mfaToken);
|
|
1208
1339
|
return transport.request(authPath + "/mfa/challenge", {
|
|
1209
1340
|
method: "POST",
|
|
1210
|
-
body:
|
|
1341
|
+
body: payload
|
|
1211
1342
|
});
|
|
1212
1343
|
},
|
|
1213
1344
|
/**
|
|
1214
1345
|
* Answer a challenge with a TOTP code or a recovery code.
|
|
1215
1346
|
*
|
|
1216
1347
|
* This is where an MFA-enrolled account's real session is minted, at
|
|
1217
|
-
* `aal2` — so
|
|
1218
|
-
*
|
|
1348
|
+
* `aal2` — so this client adopts the returned tokens and announces
|
|
1349
|
+
* `SIGNED_IN`, exactly as any other sign-in does. Pass the same
|
|
1350
|
+
* `mfaToken` the challenge was opened with when finishing a sign-in.
|
|
1219
1351
|
*/
|
|
1220
|
-
async verifyChallenge(challengeId, code) {
|
|
1221
|
-
const
|
|
1352
|
+
async verifyChallenge(challengeId, code, options) {
|
|
1353
|
+
const payload = JSON.stringify({
|
|
1354
|
+
challengeId,
|
|
1355
|
+
code
|
|
1356
|
+
});
|
|
1357
|
+
const session = handleAuthResponse(options?.mfaToken ? await withPendingToken("/mfa/challenge/verify", payload, options.mfaToken) : await transport.request(authPath + "/mfa/challenge/verify", {
|
|
1222
1358
|
method: "POST",
|
|
1223
|
-
body:
|
|
1224
|
-
challengeId,
|
|
1225
|
-
code
|
|
1226
|
-
})
|
|
1359
|
+
body: payload
|
|
1227
1360
|
}), "SIGNED_IN");
|
|
1228
1361
|
return {
|
|
1229
1362
|
user: session.user,
|
|
@@ -1232,6 +1365,31 @@ function createAuth(transport, options) {
|
|
|
1232
1365
|
};
|
|
1233
1366
|
}
|
|
1234
1367
|
};
|
|
1368
|
+
/**
|
|
1369
|
+
* POST to a challenge route with a sign-in's pending token as the credential.
|
|
1370
|
+
*
|
|
1371
|
+
* Straight to `fetch`, like the sign-in routes, and never through the
|
|
1372
|
+
* transport: the token is a credential for these two requests only. Set on
|
|
1373
|
+
* the transport it would go out on every request the app made while the
|
|
1374
|
+
* code step was open — and stay there if the step were abandoned. It would
|
|
1375
|
+
* also displace a session the client already holds, and a refused code
|
|
1376
|
+
* (401 `INVALID_CODE`) would be taken for an expired session and refreshed
|
|
1377
|
+
* and re-sent, spending a second of the challenge's attempts on it.
|
|
1378
|
+
*/
|
|
1379
|
+
async function withPendingToken(endpoint, payload, mfaToken) {
|
|
1380
|
+
const res = await getFetch()(authUrl(endpoint), {
|
|
1381
|
+
method: "POST",
|
|
1382
|
+
headers: {
|
|
1383
|
+
"Content-Type": "application/json",
|
|
1384
|
+
Authorization: `Bearer ${mfaToken}`
|
|
1385
|
+
},
|
|
1386
|
+
body: payload,
|
|
1387
|
+
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
1388
|
+
});
|
|
1389
|
+
const body = await res.json().catch(() => ({}));
|
|
1390
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
1391
|
+
return body;
|
|
1392
|
+
}
|
|
1235
1393
|
async function getSessions() {
|
|
1236
1394
|
return (await transport.request(authPath + "/sessions", { method: "GET" })).sessions;
|
|
1237
1395
|
}
|
|
@@ -1240,14 +1398,7 @@ function createAuth(transport, options) {
|
|
|
1240
1398
|
}
|
|
1241
1399
|
async function revokeAllSessions() {
|
|
1242
1400
|
const result = await transport.request(authPath + "/sessions", { method: "DELETE" });
|
|
1243
|
-
|
|
1244
|
-
clearStoredSession();
|
|
1245
|
-
if (refreshTimeout) {
|
|
1246
|
-
clearTimeout(refreshTimeout);
|
|
1247
|
-
refreshTimeout = null;
|
|
1248
|
-
}
|
|
1249
|
-
transport.setToken(null);
|
|
1250
|
-
emit("SIGNED_OUT", null);
|
|
1401
|
+
endSession();
|
|
1251
1402
|
return result;
|
|
1252
1403
|
}
|
|
1253
1404
|
async function getAuthConfig() {
|
|
@@ -1275,12 +1426,18 @@ function createAuth(transport, options) {
|
|
|
1275
1426
|
resolveInitialized();
|
|
1276
1427
|
} else if (authFlowMode === "cookie" || stored.refreshToken) {
|
|
1277
1428
|
currentSession = stored;
|
|
1429
|
+
const epoch = sessionEpoch;
|
|
1278
1430
|
refreshSession().then(() => {
|
|
1279
1431
|
resolveInitialized();
|
|
1280
|
-
}).catch(() => {
|
|
1281
|
-
currentSession
|
|
1282
|
-
|
|
1283
|
-
|
|
1432
|
+
}).catch((err) => {
|
|
1433
|
+
if (epoch === sessionEpoch && currentSession) if (isFatalRefreshError(err)) {
|
|
1434
|
+
if (storageHoldsCurrentSession()) clearStoredSession();
|
|
1435
|
+
currentSession = null;
|
|
1436
|
+
transport.setToken(null);
|
|
1437
|
+
} else {
|
|
1438
|
+
transport.setToken(currentSession.accessToken);
|
|
1439
|
+
if (autoRefresh) scheduleRetry(0);
|
|
1440
|
+
}
|
|
1284
1441
|
resolveInitialized();
|
|
1285
1442
|
});
|
|
1286
1443
|
} else resolveInitialized();
|
|
@@ -1378,8 +1535,14 @@ function createCookieStorage(options = {}) {
|
|
|
1378
1535
|
//#region src/admin.ts
|
|
1379
1536
|
function createAdmin(transport, options) {
|
|
1380
1537
|
const adminPath = (options || {}).adminPath || "/admin";
|
|
1381
|
-
|
|
1382
|
-
|
|
1538
|
+
/**
|
|
1539
|
+
* One page of users — the route's default is 25 — with the `total` that
|
|
1540
|
+
* says whether there are more. Takes the same window as
|
|
1541
|
+
* {@link listUsersPaginated}; without one, it used to hand back the first
|
|
1542
|
+
* page typed as the whole list.
|
|
1543
|
+
*/
|
|
1544
|
+
async function listUsers(options) {
|
|
1545
|
+
return listUsersPaginated(options);
|
|
1383
1546
|
}
|
|
1384
1547
|
async function listUsersPaginated(options) {
|
|
1385
1548
|
const params = new URLSearchParams();
|
|
@@ -1474,17 +1637,24 @@ function createBackups(transport, options) {
|
|
|
1474
1637
|
return transport.request(backupsPath, { method: "GET" });
|
|
1475
1638
|
}
|
|
1476
1639
|
/**
|
|
1477
|
-
* Download a backup's bytes.
|
|
1478
|
-
*
|
|
1640
|
+
* Download a backup's bytes. Not through the JSON `request()`, since the
|
|
1641
|
+
* answer is an octet-stream to hand back as a Blob — but through the
|
|
1642
|
+
* transport's own `fetch` and headers all the same. It used the global
|
|
1643
|
+
* `fetch` and a bare `Authorization` header, so a client configured with
|
|
1644
|
+
* its own `fetch` or with default headers (a gateway key, the schema
|
|
1645
|
+
* version) had every call but this one go where it was told.
|
|
1479
1646
|
*/
|
|
1480
1647
|
async function download(key) {
|
|
1481
|
-
const token = await transport.resolveToken();
|
|
1482
1648
|
const url = `${transport.baseUrl}${transport.apiPath}${backupsPath}/download?key=${encodeURIComponent(key)}`;
|
|
1483
|
-
const
|
|
1649
|
+
const token = await transport.resolveToken();
|
|
1650
|
+
const headers = transport.getHeaders();
|
|
1651
|
+
delete headers["Content-Type"];
|
|
1652
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
1653
|
+
const res = await transport.fetchFn(url, {
|
|
1484
1654
|
method: "GET",
|
|
1485
|
-
headers
|
|
1655
|
+
headers
|
|
1486
1656
|
});
|
|
1487
|
-
if (!res.ok) throw new
|
|
1657
|
+
if (!res.ok) throw new RebaseApiError$1(`Failed to download backup (${res.status})`, { status: res.status });
|
|
1488
1658
|
return res.blob();
|
|
1489
1659
|
}
|
|
1490
1660
|
return {
|
|
@@ -1816,6 +1986,28 @@ var SDKQueryBuilder = class {
|
|
|
1816
1986
|
*/
|
|
1817
1987
|
var NO_SOCKET = "Listen is only available when RebaseClient is configured with a websocketUrl, and not when it was created with realtime: false.";
|
|
1818
1988
|
/**
|
|
1989
|
+
* Why a live query cannot be opened for these parameters, or `undefined` when
|
|
1990
|
+
* it can.
|
|
1991
|
+
*
|
|
1992
|
+
* A `listen()` is the same query as the `find()` beside it, so what `find()`
|
|
1993
|
+
* refuses it refuses too. The socket would not: a frame is JSON, where an
|
|
1994
|
+
* `undefined` filter value becomes `null`, so `["!=", uid]` with `uid` unset
|
|
1995
|
+
* subscribed to every row whose column is NOT NULL — an ownership filter
|
|
1996
|
+
* widened instead of refused. And `after` names a place in one run of the
|
|
1997
|
+
* query, which a subscription re-runs on every write; the server keeps no
|
|
1998
|
+
* cursor for one, so it was dropped, and page one streamed to a caller who had
|
|
1999
|
+
* asked for page two.
|
|
2000
|
+
*/
|
|
2001
|
+
function listenRefusal(params) {
|
|
2002
|
+
if (params?.where) try {
|
|
2003
|
+
assertNoUndefinedFilterValues(params.where);
|
|
2004
|
+
} catch (error) {
|
|
2005
|
+
if (error instanceof RebaseClientError$1) return error;
|
|
2006
|
+
throw error;
|
|
2007
|
+
}
|
|
2008
|
+
if (params?.after) return new RebaseClientError$1("listen() cannot continue from a cursor: a subscription re-runs its query on every write, and `after` names a place in one run of it. Use `offset` or `page` for a live window, or `find({ after })` to read the page once.", { code: "CURSOR_NOT_LIVE" });
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
1819
2011
|
* Counts currently in flight, **per client**, keyed by the exact request they
|
|
1820
2012
|
* issue. Entries live only for the duration of the request — see `count()` for
|
|
1821
2013
|
* why they exist at all.
|
|
@@ -2067,10 +2259,20 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
2067
2259
|
partial: false
|
|
2068
2260
|
});
|
|
2069
2261
|
};
|
|
2262
|
+
const wantsLive = options?.realtime !== false && !isUnsupported(client.listen);
|
|
2263
|
+
const refusal = wantsLive ? listenRefusal(params) : void 0;
|
|
2264
|
+
if (refusal) {
|
|
2265
|
+
queueMicrotask(() => {
|
|
2266
|
+
if (!closed) onError?.(refusal);
|
|
2267
|
+
});
|
|
2268
|
+
return () => {
|
|
2269
|
+
closed = true;
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2070
2272
|
client.find(params).then((result) => deliver(result, false)).catch((error) => {
|
|
2071
2273
|
if (!closed) onError?.(error);
|
|
2072
2274
|
});
|
|
2073
|
-
const live =
|
|
2275
|
+
const live = wantsLive ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
|
|
2074
2276
|
return () => {
|
|
2075
2277
|
closed = true;
|
|
2076
2278
|
live?.();
|
|
@@ -2152,6 +2354,15 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
2152
2354
|
if (ws) {
|
|
2153
2355
|
client.listen = (params, onUpdate, onError) => {
|
|
2154
2356
|
let active = true;
|
|
2357
|
+
const refusal = listenRefusal(params);
|
|
2358
|
+
if (refusal) {
|
|
2359
|
+
queueMicrotask(() => {
|
|
2360
|
+
if (active) onError?.(refusal);
|
|
2361
|
+
});
|
|
2362
|
+
return () => {
|
|
2363
|
+
active = false;
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2155
2366
|
let lastUpdateId = 0;
|
|
2156
2367
|
let lastKnownTotal;
|
|
2157
2368
|
const window = resolveFindWindow(params);
|
|
@@ -2504,6 +2715,26 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
|
|
|
2504
2715
|
"channel_history"
|
|
2505
2716
|
]);
|
|
2506
2717
|
/**
|
|
2718
|
+
* Subscription frames. Like channel frames they get no response envelope, and
|
|
2719
|
+
* like them they go out without an account when there is none: whether an
|
|
2720
|
+
* anonymous visitor may subscribe is the server's `requireAuth` to decide, and
|
|
2721
|
+
* a server that refuses answers the frame, which reaches the listener's
|
|
2722
|
+
* `onError`.
|
|
2723
|
+
*/
|
|
2724
|
+
var SUBSCRIPTION_MESSAGE_TYPES = /* @__PURE__ */ new Set([
|
|
2725
|
+
"subscribe_collection",
|
|
2726
|
+
"subscribe_one",
|
|
2727
|
+
"unsubscribe"
|
|
2728
|
+
]);
|
|
2729
|
+
/**
|
|
2730
|
+
* The token getter's "there is no account" — an empty token, or its own
|
|
2731
|
+
* "not logged in". Distinct from a getter that failed, which says nothing about
|
|
2732
|
+
* whether there is one.
|
|
2733
|
+
*/
|
|
2734
|
+
function isNotSignedIn(error) {
|
|
2735
|
+
return (error instanceof Error ? error.message : String(error)).includes("not logged in");
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2507
2738
|
* The other direction: the frame types the *server* addresses by channel.
|
|
2508
2739
|
*
|
|
2509
2740
|
* Not the mirror of {@link CHANNEL_MESSAGE_TYPES} — a request and its answer
|
|
@@ -2614,6 +2845,17 @@ var RebaseWebSocketClient = class {
|
|
|
2614
2845
|
pendingRequests = /* @__PURE__ */ new Map();
|
|
2615
2846
|
reconnectAttempts = 0;
|
|
2616
2847
|
maxReconnectAttempts = 5;
|
|
2848
|
+
/**
|
|
2849
|
+
* Whether a socket of this client has ever opened — what makes the next
|
|
2850
|
+
* open a *reconnect*, whose server has forgotten every subscription and
|
|
2851
|
+
* channel membership and must be told them again.
|
|
2852
|
+
*
|
|
2853
|
+
* Not `reconnectAttempts > 0`: that counter is a backoff budget, and a
|
|
2854
|
+
* fresh one (after giving up, or after a sign-out dropped the socket)
|
|
2855
|
+
* starts at zero, so the socket that finally came back looked like a first
|
|
2856
|
+
* connect and nothing was re-sent.
|
|
2857
|
+
*/
|
|
2858
|
+
hadConnection = false;
|
|
2617
2859
|
isConnected = false;
|
|
2618
2860
|
messageQueue = [];
|
|
2619
2861
|
requestTimeoutMs = 3e4;
|
|
@@ -2737,6 +2979,7 @@ var RebaseWebSocketClient = class {
|
|
|
2737
2979
|
this.reconnectTimeout = null;
|
|
2738
2980
|
}
|
|
2739
2981
|
this.suspendSubscribeWatchdogs();
|
|
2982
|
+
this.forgetCachedData();
|
|
2740
2983
|
if (this.ws) {
|
|
2741
2984
|
this.ws.onclose = null;
|
|
2742
2985
|
this.ws.onerror = null;
|
|
@@ -2759,7 +3002,8 @@ var RebaseWebSocketClient = class {
|
|
|
2759
3002
|
this.ws = socket;
|
|
2760
3003
|
this.ws.onopen = async () => {
|
|
2761
3004
|
console.debug("Connected to PostgreSQL backend");
|
|
2762
|
-
const wasReconnect = this.
|
|
3005
|
+
const wasReconnect = this.hadConnection;
|
|
3006
|
+
this.hadConnection = true;
|
|
2763
3007
|
this.isConnected = true;
|
|
2764
3008
|
this.reconnectAttempts = 0;
|
|
2765
3009
|
if (this.getAuthToken && !this.isAuthenticated) try {
|
|
@@ -2772,7 +3016,7 @@ var RebaseWebSocketClient = class {
|
|
|
2772
3016
|
console.debug("WebSocket connected without auth:", error?.message || error);
|
|
2773
3017
|
}
|
|
2774
3018
|
this.emit(wasReconnect ? "reconnect" : "connect");
|
|
2775
|
-
this.processMessageQueue();
|
|
3019
|
+
this.processMessageQueue(wasReconnect);
|
|
2776
3020
|
if (wasReconnect) this.resubscribeAll();
|
|
2777
3021
|
this.armPendingSubscribeWatchdogs();
|
|
2778
3022
|
};
|
|
@@ -2813,11 +3057,45 @@ var RebaseWebSocketClient = class {
|
|
|
2813
3057
|
this.attemptReconnect();
|
|
2814
3058
|
}
|
|
2815
3059
|
}
|
|
2816
|
-
processMessageQueue() {
|
|
3060
|
+
processMessageQueue(isReconnect) {
|
|
2817
3061
|
while (this.messageQueue.length > 0 && this.isConnected) {
|
|
2818
3062
|
const message = this.messageQueue.shift();
|
|
2819
|
-
if (message)
|
|
3063
|
+
if (!message) continue;
|
|
3064
|
+
if (!this.shouldSendQueued(message, isReconnect)) {
|
|
3065
|
+
const resolveQueued = message._queuedResolve;
|
|
3066
|
+
if (typeof resolveQueued === "function") resolveQueued(void 0);
|
|
3067
|
+
continue;
|
|
3068
|
+
}
|
|
3069
|
+
this.sendMessage(message);
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
/**
|
|
3073
|
+
* Whether a frame that waited in the queue should go out on the socket
|
|
3074
|
+
* that just opened. Only a subscribe can be refused, and one is exactly
|
|
3075
|
+
* when sending it would give the server a subscription this side will not
|
|
3076
|
+
* read:
|
|
3077
|
+
*
|
|
3078
|
+
* - Its registration is gone, or has been given a new id, while it waited.
|
|
3079
|
+
* A listener that unmounted before the socket opened (a StrictMode double
|
|
3080
|
+
* mount does exactly this) sends no `unsubscribe`, because there was no
|
|
3081
|
+
* socket to send it on, so the server would keep that one forever.
|
|
3082
|
+
* - This open is a reconnect. `resubscribeAll` sends every registration
|
|
3083
|
+
* under a fresh id right after the queue drains, so the queued copy would
|
|
3084
|
+
* be the second of two. It is handed back to that pass instead.
|
|
3085
|
+
*/
|
|
3086
|
+
shouldSendQueued(message, isReconnect) {
|
|
3087
|
+
if (message.type !== "subscribe_collection" && message.type !== "subscribe_one") return true;
|
|
3088
|
+
const payload = message.payload;
|
|
3089
|
+
if (!payload || typeof payload !== "object" || !("subscriptionId" in payload)) return true;
|
|
3090
|
+
const subscriptionId = payload.subscriptionId;
|
|
3091
|
+
if (typeof subscriptionId !== "string") return true;
|
|
3092
|
+
const registration = message.type === "subscribe_collection" ? this.collectionSubscriptions.get(this.backendToCollectionKey.get(subscriptionId) ?? "") : this.singleSubscriptions.get(this.backendToEntityKey.get(subscriptionId) ?? "");
|
|
3093
|
+
if (!registration || registration.backendSubscriptionId !== subscriptionId) return false;
|
|
3094
|
+
if (isReconnect) {
|
|
3095
|
+
registration.subscribeInFlight = false;
|
|
3096
|
+
return false;
|
|
2820
3097
|
}
|
|
3098
|
+
return true;
|
|
2821
3099
|
}
|
|
2822
3100
|
attemptReconnect() {
|
|
2823
3101
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
@@ -2977,8 +3255,9 @@ var RebaseWebSocketClient = class {
|
|
|
2977
3255
|
}
|
|
2978
3256
|
}
|
|
2979
3257
|
}
|
|
2980
|
-
|
|
2981
|
-
|
|
3258
|
+
const erroredSubscriptionId = subscriptionId ?? requestId;
|
|
3259
|
+
if (erroredSubscriptionId && (type === "ERROR" || message.error)) {
|
|
3260
|
+
const collectionKey = this.backendToCollectionKey.get(erroredSubscriptionId);
|
|
2982
3261
|
if (collectionKey) {
|
|
2983
3262
|
const collectionSub = this.collectionSubscriptions.get(collectionKey);
|
|
2984
3263
|
if (collectionSub) {
|
|
@@ -2996,7 +3275,7 @@ var RebaseWebSocketClient = class {
|
|
|
2996
3275
|
return;
|
|
2997
3276
|
}
|
|
2998
3277
|
}
|
|
2999
|
-
const entityKey = this.backendToEntityKey.get(
|
|
3278
|
+
const entityKey = this.backendToEntityKey.get(erroredSubscriptionId);
|
|
3000
3279
|
if (entityKey) {
|
|
3001
3280
|
const entitySub = this.singleSubscriptions.get(entityKey);
|
|
3002
3281
|
if (entitySub) {
|
|
@@ -3095,15 +3374,18 @@ var RebaseWebSocketClient = class {
|
|
|
3095
3374
|
});
|
|
3096
3375
|
}
|
|
3097
3376
|
async doSendMessage(message, resolve, reject) {
|
|
3377
|
+
const isSubscriptionFrame = SUBSCRIPTION_MESSAGE_TYPES.has(message.type);
|
|
3098
3378
|
if (message.type !== "AUTHENTICATE" && !CHANNEL_MESSAGE_TYPES.has(message.type) && this.getAuthToken && !this.isAuthenticated) try {
|
|
3099
3379
|
await this.ensureAuthenticated();
|
|
3100
3380
|
} catch (error) {
|
|
3101
|
-
|
|
3102
|
-
|
|
3381
|
+
if (!(isSubscriptionFrame && isNotSignedIn(error))) {
|
|
3382
|
+
reject(new RebaseApiError$1(error instanceof Error ? error.message : "Authentication required"));
|
|
3383
|
+
return;
|
|
3384
|
+
}
|
|
3103
3385
|
}
|
|
3104
3386
|
const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
3105
3387
|
message.requestId = requestId;
|
|
3106
|
-
const expectsResponse = !(
|
|
3388
|
+
const expectsResponse = !(isSubscriptionFrame || CHANNEL_MESSAGE_TYPES.has(message.type));
|
|
3107
3389
|
if (expectsResponse && !this.pendingRequests.has(requestId)) {
|
|
3108
3390
|
const timeoutHandle = setTimeout(() => {
|
|
3109
3391
|
if (this.pendingRequests.has(requestId)) {
|
|
@@ -3476,6 +3758,7 @@ var RebaseWebSocketClient = class {
|
|
|
3476
3758
|
if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
|
|
3477
3759
|
this.sendMessage({
|
|
3478
3760
|
type: "subscribe_collection",
|
|
3761
|
+
requestId: backendSubscriptionId,
|
|
3479
3762
|
payload: {
|
|
3480
3763
|
...subscription.props,
|
|
3481
3764
|
subscriptionId: backendSubscriptionId
|
|
@@ -3497,6 +3780,7 @@ var RebaseWebSocketClient = class {
|
|
|
3497
3780
|
if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
|
|
3498
3781
|
this.sendMessage({
|
|
3499
3782
|
type: "subscribe_one",
|
|
3783
|
+
requestId: backendSubscriptionId,
|
|
3500
3784
|
payload: {
|
|
3501
3785
|
...subscription.props,
|
|
3502
3786
|
subscriptionId: backendSubscriptionId
|
|
@@ -3564,6 +3848,26 @@ var RebaseWebSocketClient = class {
|
|
|
3564
3848
|
}
|
|
3565
3849
|
}
|
|
3566
3850
|
/**
|
|
3851
|
+
* Drop every registration's cached rows, keeping the registrations.
|
|
3852
|
+
*
|
|
3853
|
+
* A registration without rows counts as not loaded, so the next listener
|
|
3854
|
+
* to attach asks the server (or waits for the resubscribe) instead of
|
|
3855
|
+
* being handed what is cached.
|
|
3856
|
+
*/
|
|
3857
|
+
forgetCachedData() {
|
|
3858
|
+
for (const sub of this.collectionSubscriptions.values()) {
|
|
3859
|
+
sub.latestData = void 0;
|
|
3860
|
+
sub.latestMeta = void 0;
|
|
3861
|
+
sub.lastUpdated = void 0;
|
|
3862
|
+
sub.isInitialDataReceived = false;
|
|
3863
|
+
}
|
|
3864
|
+
for (const sub of this.singleSubscriptions.values()) {
|
|
3865
|
+
sub.latestData = void 0;
|
|
3866
|
+
sub.lastUpdated = void 0;
|
|
3867
|
+
sub.isInitialDataReceived = false;
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
/**
|
|
3567
3871
|
* Arm watchdogs for subscribes that were requested while offline and have
|
|
3568
3872
|
* just been flushed to the socket. Their timers were deliberately not set at
|
|
3569
3873
|
* request time, so without this they would have no timeout at all.
|
|
@@ -3616,6 +3920,7 @@ var RebaseWebSocketClient = class {
|
|
|
3616
3920
|
resubscribeAll() {
|
|
3617
3921
|
console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.singleSubscriptions.size} row(ies)`);
|
|
3618
3922
|
for (const [key, sub] of this.collectionSubscriptions.entries()) {
|
|
3923
|
+
if (sub.subscribeInFlight) continue;
|
|
3619
3924
|
const oldBackendId = sub.backendSubscriptionId;
|
|
3620
3925
|
const newBackendId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
3621
3926
|
sub.backendSubscriptionId = newBackendId;
|
|
@@ -3624,6 +3929,7 @@ var RebaseWebSocketClient = class {
|
|
|
3624
3929
|
this.sendCollectionSubscribe(key);
|
|
3625
3930
|
}
|
|
3626
3931
|
for (const [key, sub] of this.singleSubscriptions.entries()) {
|
|
3932
|
+
if (sub.subscribeInFlight) continue;
|
|
3627
3933
|
const oldBackendId = sub.backendSubscriptionId;
|
|
3628
3934
|
const newBackendId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
3629
3935
|
sub.backendSubscriptionId = newBackendId;
|
|
@@ -3702,6 +4008,27 @@ var RebaseRealtimeChannel = class {
|
|
|
3702
4008
|
pendingLive = [];
|
|
3703
4009
|
catchUpInFlight = false;
|
|
3704
4010
|
/**
|
|
4011
|
+
* Whether {@link lastSeq} is a position this client reached, rather than
|
|
4012
|
+
* the zero of a handle that has not caught up yet.
|
|
4013
|
+
*
|
|
4014
|
+
* A client joining for the first time asks for whatever is retained, so
|
|
4015
|
+
* history pruned before it ever joined is not something it missed. Only a
|
|
4016
|
+
* client with a position can have a hole in what it saw.
|
|
4017
|
+
*/
|
|
4018
|
+
positioned = false;
|
|
4019
|
+
/** The page size the catch-up in flight asked for, reused for its later pages. */
|
|
4020
|
+
catchUpLimit;
|
|
4021
|
+
/**
|
|
4022
|
+
* `latestSeq` of an empty page that left this client behind, while a
|
|
4023
|
+
* second request confirms it.
|
|
4024
|
+
*
|
|
4025
|
+
* The server reads the retained messages and then the cursor, so a message
|
|
4026
|
+
* committed between the two shows up in `latestSeq` and not in the page.
|
|
4027
|
+
* The second request returns it if it exists; only a second empty page
|
|
4028
|
+
* means the messages up to this seq are gone.
|
|
4029
|
+
*/
|
|
4030
|
+
emptyTailSeq = null;
|
|
4031
|
+
/**
|
|
3705
4032
|
* Deadline for a catch-up response.
|
|
3706
4033
|
*
|
|
3707
4034
|
* Buffering live messages is only safe because the wait is bounded. A
|
|
@@ -3793,6 +4120,7 @@ var RebaseRealtimeChannel = class {
|
|
|
3793
4120
|
*/
|
|
3794
4121
|
async requestHistory(limit) {
|
|
3795
4122
|
this.catchUpInFlight = true;
|
|
4123
|
+
this.catchUpLimit = limit;
|
|
3796
4124
|
if (this.catchUpTimeout) clearTimeout(this.catchUpTimeout);
|
|
3797
4125
|
this.catchUpTimeout = setTimeout(() => this.abandonCatchUp(), CATCH_UP_TIMEOUT_MS);
|
|
3798
4126
|
unref(this.catchUpTimeout);
|
|
@@ -3820,6 +4148,7 @@ var RebaseRealtimeChannel = class {
|
|
|
3820
4148
|
}
|
|
3821
4149
|
if (!this.catchUpInFlight) return;
|
|
3822
4150
|
this.catchUpInFlight = false;
|
|
4151
|
+
this.emptyTailSeq = null;
|
|
3823
4152
|
for (const resolve of this.historyWaiters.splice(0)) resolve({
|
|
3824
4153
|
messages: [],
|
|
3825
4154
|
retained: false
|
|
@@ -3886,6 +4215,12 @@ var RebaseRealtimeChannel = class {
|
|
|
3886
4215
|
* retention cannot be served; `CHANNEL_BUS_PAYLOAD_TOO_LARGE` when a
|
|
3887
4216
|
* broadcast on an ephemeral channel is too big to cross the bus.
|
|
3888
4217
|
*
|
|
4218
|
+
* `CHANNEL_HISTORY_GAP` is raised by the client: a catch-up found that the
|
|
4219
|
+
* server no longer retains messages this client never received, so its
|
|
4220
|
+
* state has a hole a replay cannot fill. `details` is `{ from, to }`, the
|
|
4221
|
+
* inclusive range of sequence numbers missed. Resync the channel's state
|
|
4222
|
+
* from its source of truth.
|
|
4223
|
+
*
|
|
3889
4224
|
* These used to be dropped on the floor — there is no promise to reject on
|
|
3890
4225
|
* a fire-and-forget frame, so a forbidden broadcast looked exactly like a
|
|
3891
4226
|
* delivered one. With no handler attached they are logged as a warning,
|
|
@@ -3927,7 +4262,10 @@ var RebaseRealtimeChannel = class {
|
|
|
3927
4262
|
*/
|
|
3928
4263
|
async history(options = {}) {
|
|
3929
4264
|
await this.join();
|
|
3930
|
-
if (options.sinceSeq !== void 0)
|
|
4265
|
+
if (options.sinceSeq !== void 0) {
|
|
4266
|
+
this.lastSeq = options.sinceSeq;
|
|
4267
|
+
this.positioned = options.sinceSeq > 0;
|
|
4268
|
+
}
|
|
3931
4269
|
const result = new Promise((resolve) => {
|
|
3932
4270
|
this.historyWaiters.push(resolve);
|
|
3933
4271
|
});
|
|
@@ -3942,6 +4280,8 @@ var RebaseRealtimeChannel = class {
|
|
|
3942
4280
|
this.presenceHandlers.clear();
|
|
3943
4281
|
this.broadcastHandlers.clear();
|
|
3944
4282
|
this.lastSeq = 0;
|
|
4283
|
+
this.positioned = false;
|
|
4284
|
+
this.emptyTailSeq = null;
|
|
3945
4285
|
this.pendingLive = [];
|
|
3946
4286
|
this.catchUpInFlight = false;
|
|
3947
4287
|
if (this.catchUpTimeout) {
|
|
@@ -3999,11 +4339,11 @@ var RebaseRealtimeChannel = class {
|
|
|
3999
4339
|
}
|
|
4000
4340
|
if (seq <= this.lastSeq) break;
|
|
4001
4341
|
this.lastSeq = seq;
|
|
4342
|
+
this.positioned = true;
|
|
4002
4343
|
this.deliver(event);
|
|
4003
4344
|
break;
|
|
4004
4345
|
}
|
|
4005
4346
|
case "channel_history": {
|
|
4006
|
-
this.catchUpInFlight = false;
|
|
4007
4347
|
if (this.catchUpTimeout) {
|
|
4008
4348
|
clearTimeout(this.catchUpTimeout);
|
|
4009
4349
|
this.catchUpTimeout = null;
|
|
@@ -4014,6 +4354,9 @@ var RebaseRealtimeChannel = class {
|
|
|
4014
4354
|
retained,
|
|
4015
4355
|
latestSeq
|
|
4016
4356
|
});
|
|
4357
|
+
const wasPositioned = this.positioned;
|
|
4358
|
+
const firstNew = entries.find((entry) => entry.seq > this.lastSeq);
|
|
4359
|
+
if (firstNew && wasPositioned && firstNew.seq > this.lastSeq + 1) this.reportHistoryGap(this.lastSeq + 1, firstNew.seq - 1);
|
|
4017
4360
|
for (const entry of entries) {
|
|
4018
4361
|
if (entry.seq <= this.lastSeq) continue;
|
|
4019
4362
|
this.lastSeq = entry.seq;
|
|
@@ -4024,6 +4367,28 @@ var RebaseRealtimeChannel = class {
|
|
|
4024
4367
|
replayed: true
|
|
4025
4368
|
});
|
|
4026
4369
|
}
|
|
4370
|
+
if (retained) this.positioned = true;
|
|
4371
|
+
const behind = retained && latestSeq !== void 0 && latestSeq > this.lastSeq;
|
|
4372
|
+
if (behind && firstNew) {
|
|
4373
|
+
this.emptyTailSeq = null;
|
|
4374
|
+
this.requestHistory(this.catchUpLimit);
|
|
4375
|
+
break;
|
|
4376
|
+
}
|
|
4377
|
+
if (behind && wasPositioned) {
|
|
4378
|
+
if (this.emptyTailSeq === null) {
|
|
4379
|
+
this.emptyTailSeq = latestSeq;
|
|
4380
|
+
this.requestHistory(this.catchUpLimit);
|
|
4381
|
+
break;
|
|
4382
|
+
}
|
|
4383
|
+
const firstLive = this.pendingLive.reduce((min, event) => event.seq !== void 0 && (min === void 0 || event.seq < min) ? event.seq : min, void 0);
|
|
4384
|
+
const end = firstLive !== void 0 ? Math.min(this.emptyTailSeq, firstLive - 1) : this.emptyTailSeq;
|
|
4385
|
+
if (end > this.lastSeq) {
|
|
4386
|
+
this.reportHistoryGap(this.lastSeq + 1, end);
|
|
4387
|
+
this.lastSeq = end;
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
this.emptyTailSeq = null;
|
|
4391
|
+
this.catchUpInFlight = false;
|
|
4027
4392
|
this.flushPendingLive();
|
|
4028
4393
|
break;
|
|
4029
4394
|
}
|
|
@@ -4032,20 +4397,32 @@ var RebaseRealtimeChannel = class {
|
|
|
4032
4397
|
const raw = message.payload?.error;
|
|
4033
4398
|
const asObject = typeof raw === "object" ? raw : void 0;
|
|
4034
4399
|
const text = asObject?.message ?? (typeof raw === "string" ? raw : void 0) ?? message.error ?? "The server refused a channel operation.";
|
|
4035
|
-
|
|
4036
|
-
if (this.errorHandlers.size === 0) {
|
|
4037
|
-
console.warn(`[Rebase] Channel "${this.name}" error${asObject?.code ? ` (${asObject.code})` : ""}: ${text}. Attach \`channel.onError(...)\` to handle it.`);
|
|
4038
|
-
break;
|
|
4039
|
-
}
|
|
4040
|
-
for (const handler of [...this.errorHandlers]) try {
|
|
4041
|
-
handler(error);
|
|
4042
|
-
} catch (e) {
|
|
4043
|
-
console.error("Error in channel error handler:", e);
|
|
4044
|
-
}
|
|
4400
|
+
this.emitError(new RebaseApiError$1(text, { ...asObject?.code ? { code: asObject.code } : {} }));
|
|
4045
4401
|
break;
|
|
4046
4402
|
}
|
|
4047
4403
|
}
|
|
4048
4404
|
}
|
|
4405
|
+
/** Tell the app that retention dropped messages it never received. */
|
|
4406
|
+
reportHistoryGap(from, to) {
|
|
4407
|
+
this.emitError(new RebaseApiError$1(`Channel "${this.name}" missed messages ${from}–${to}: the server no longer retains them. Resync this channel's state.`, {
|
|
4408
|
+
code: "CHANNEL_HISTORY_GAP",
|
|
4409
|
+
details: {
|
|
4410
|
+
from,
|
|
4411
|
+
to
|
|
4412
|
+
}
|
|
4413
|
+
}));
|
|
4414
|
+
}
|
|
4415
|
+
emitError(error) {
|
|
4416
|
+
if (this.errorHandlers.size === 0) {
|
|
4417
|
+
console.warn(`[Rebase] Channel "${this.name}" error${error.code ? ` (${error.code})` : ""}: ${error.message}. Attach \`channel.onError(...)\` to handle it.`);
|
|
4418
|
+
return;
|
|
4419
|
+
}
|
|
4420
|
+
for (const handler of [...this.errorHandlers]) try {
|
|
4421
|
+
handler(error);
|
|
4422
|
+
} catch (e) {
|
|
4423
|
+
console.error("Error in channel error handler:", e);
|
|
4424
|
+
}
|
|
4425
|
+
}
|
|
4049
4426
|
/** Deliver everything held back during a catch-up, in sequence order. */
|
|
4050
4427
|
flushPendingLive() {
|
|
4051
4428
|
if (this.pendingLive.length === 0) return;
|
|
@@ -4056,6 +4433,7 @@ var RebaseRealtimeChannel = class {
|
|
|
4056
4433
|
if (seq !== void 0) {
|
|
4057
4434
|
if (seq <= this.lastSeq) continue;
|
|
4058
4435
|
this.lastSeq = seq;
|
|
4436
|
+
this.positioned = true;
|
|
4059
4437
|
}
|
|
4060
4438
|
this.deliver(event);
|
|
4061
4439
|
}
|
|
@@ -4200,6 +4578,16 @@ var IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
|
|
|
4200
4578
|
function isIdempotencyInProgressError(error) {
|
|
4201
4579
|
return error instanceof RebaseApiError && error.status === 409 && error.code === IDEMPOTENCY_IN_PROGRESS;
|
|
4202
4580
|
}
|
|
4581
|
+
/**
|
|
4582
|
+
* Is this key held for a different request than the one just sent with it?
|
|
4583
|
+
*
|
|
4584
|
+
* For a create the queue already sent once, that is an answer rather than a
|
|
4585
|
+
* failure: the key is live only because the first attempt got through and
|
|
4586
|
+
* committed, and only its response went missing.
|
|
4587
|
+
*/
|
|
4588
|
+
function isIdempotencyKeyReusedError(error) {
|
|
4589
|
+
return error instanceof RebaseApiError && error.status === 422 && error.code === "IDEMPOTENCY_KEY_REUSED";
|
|
4590
|
+
}
|
|
4203
4591
|
/** Is this failure worth another attempt later? */
|
|
4204
4592
|
function isRetryableError(error) {
|
|
4205
4593
|
if (isNetworkError(error)) return true;
|
|
@@ -4550,9 +4938,10 @@ var IndexedDBOfflineStore = class {
|
|
|
4550
4938
|
* would have included them.
|
|
4551
4939
|
* - `ORDER BY` puts nulls last ascending and first descending, which is the
|
|
4552
4940
|
* Postgres default.
|
|
4553
|
-
* -
|
|
4554
|
-
* compared numerically against
|
|
4555
|
-
*
|
|
4941
|
+
* - A filter value may arrive as a string (off a URL, say), so a string is
|
|
4942
|
+
* compared numerically against a number and as an instant against a date.
|
|
4943
|
+
* `["==", "3"]` matches the number `3`, as it does server-side. Two strings
|
|
4944
|
+
* compare as text: `"01234"` is not `"1234"`.
|
|
4556
4945
|
*
|
|
4557
4946
|
* Two things it deliberately approximates, both flagged by
|
|
4558
4947
|
* {@link isExactlyEvaluable}: `searchString` becomes a case-insensitive
|
|
@@ -4592,9 +4981,11 @@ function compareValues(a, b) {
|
|
|
4592
4981
|
const right = toComparable(b);
|
|
4593
4982
|
if (isNullish(left) || isNullish(right)) return void 0;
|
|
4594
4983
|
if (typeof left === "boolean" || typeof right === "boolean") return (left === true || left === "true" || left === 1 ? 1 : 0) - (right === true || right === "true" || right === 1 ? 1 : 0);
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4984
|
+
if (isNumeric(left) || isNumeric(right)) {
|
|
4985
|
+
const leftNum = numericOrNaN(left);
|
|
4986
|
+
const rightNum = numericOrNaN(right);
|
|
4987
|
+
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) return leftNum < rightNum ? -1 : leftNum > rightNum ? 1 : 0;
|
|
4988
|
+
}
|
|
4598
4989
|
if (typeof left === "number" || typeof right === "number") {
|
|
4599
4990
|
const leftTime = toTime(left);
|
|
4600
4991
|
const rightTime = toTime(right);
|
|
@@ -4605,6 +4996,9 @@ function compareValues(a, b) {
|
|
|
4605
4996
|
if (collator) return collator.compare(leftStr, rightStr);
|
|
4606
4997
|
return leftStr < rightStr ? -1 : leftStr > rightStr ? 1 : 0;
|
|
4607
4998
|
}
|
|
4999
|
+
function isNumeric(value) {
|
|
5000
|
+
return typeof value === "number" || typeof value === "bigint";
|
|
5001
|
+
}
|
|
4608
5002
|
function numericOrNaN(value) {
|
|
4609
5003
|
if (typeof value === "number") return value;
|
|
4610
5004
|
if (typeof value === "string" && value.trim() !== "") {
|
|
@@ -4621,12 +5015,17 @@ function toTime(value) {
|
|
|
4621
5015
|
return Number.isNaN(t) ? void 0 : t;
|
|
4622
5016
|
}
|
|
4623
5017
|
}
|
|
4624
|
-
/**
|
|
5018
|
+
/**
|
|
5019
|
+
* Equality with a string filter value allowed to name a number, a boolean or
|
|
5020
|
+
* an instant, but never across NULL. Two strings are equal only when they are
|
|
5021
|
+
* the same string, as text is in Postgres.
|
|
5022
|
+
*/
|
|
4625
5023
|
function looseEquals(a, b) {
|
|
4626
5024
|
const left = toComparable(a);
|
|
4627
5025
|
const right = toComparable(b);
|
|
4628
5026
|
if (isNullish(left) || isNullish(right)) return isNullish(left) && isNullish(right);
|
|
4629
5027
|
if (left === right) return true;
|
|
5028
|
+
if (typeof left === "string" && typeof right === "string") return false;
|
|
4630
5029
|
return compareValues(left, right) === 0;
|
|
4631
5030
|
}
|
|
4632
5031
|
/**
|
|
@@ -4911,14 +5310,14 @@ function logicalReachesThroughRelation(condition, depth = 0) {
|
|
|
4911
5310
|
*
|
|
4912
5311
|
* Asked of the rows rather than of the query, because unlike a filter this one
|
|
4913
5312
|
* *is* decidable from the data in hand: {@link compareValues} reaches the
|
|
4914
|
-
* collator only when
|
|
4915
|
-
*
|
|
5313
|
+
* collator only when neither operand is a number, and `toComparable` has
|
|
5314
|
+
* already turned dates and relations into numbers and ids by then. If every
|
|
4916
5315
|
* value on the sort column normalises to a number, the collator is unreachable
|
|
4917
5316
|
* and the local order is the server's order.
|
|
4918
5317
|
*
|
|
4919
5318
|
* A text column is therefore refused — see {@link isExactlyEvaluable} for why
|
|
4920
|
-
* the two cannot be made to agree —
|
|
4921
|
-
*
|
|
5319
|
+
* the two cannot be made to agree — including one holding only digits, which
|
|
5320
|
+
* Postgres orders as text ("10", "100", "9").
|
|
4922
5321
|
*
|
|
4923
5322
|
* Nulls are fine either way: they are ordered by an explicit rule (last
|
|
4924
5323
|
* ascending, first descending) that matches Postgres and never reaches the
|
|
@@ -4933,7 +5332,6 @@ function isLocallySortable(rows, orderBy) {
|
|
|
4933
5332
|
if (isNullish(value)) return true;
|
|
4934
5333
|
if (typeof value === "number" || typeof value === "boolean") return true;
|
|
4935
5334
|
if (typeof value === "bigint") return true;
|
|
4936
|
-
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) return true;
|
|
4937
5335
|
return false;
|
|
4938
5336
|
}));
|
|
4939
5337
|
}
|
|
@@ -5025,6 +5423,29 @@ function optimisticRow(fields, id) {
|
|
|
5025
5423
|
id
|
|
5026
5424
|
};
|
|
5027
5425
|
}
|
|
5426
|
+
/**
|
|
5427
|
+
* Whether a query answers with something narrower than whole rows: `fields`
|
|
5428
|
+
* returns only the columns it names (and the key), and `distinct` collapses
|
|
5429
|
+
* rows into their distinct projections. Either way what comes back is not a
|
|
5430
|
+
* row of the collection, and must not be stored as one.
|
|
5431
|
+
*/
|
|
5432
|
+
function isProjection(params) {
|
|
5433
|
+
return (params?.fields?.length ?? 0) > 0 || params?.distinct === true;
|
|
5434
|
+
}
|
|
5435
|
+
/** `row` narrowed to `keys`: a local row in the shape a projection has. */
|
|
5436
|
+
function pick(row, keys) {
|
|
5437
|
+
const out = {};
|
|
5438
|
+
for (const key of keys) if (key in row) out[key] = row[key];
|
|
5439
|
+
return out;
|
|
5440
|
+
}
|
|
5441
|
+
/** A snapshot as read back from the store, its projected rows revived. */
|
|
5442
|
+
function readSnapshot(value) {
|
|
5443
|
+
const snapshot = value;
|
|
5444
|
+
return snapshot.rows ? {
|
|
5445
|
+
...snapshot,
|
|
5446
|
+
rows: snapshot.rows.map((row) => hydrateRow(row))
|
|
5447
|
+
} : snapshot;
|
|
5448
|
+
}
|
|
5028
5449
|
var MISSING = "\0missing";
|
|
5029
5450
|
/**
|
|
5030
5451
|
* Replays to spend on a mutation whose idempotency key the server is still
|
|
@@ -5203,7 +5624,7 @@ var OfflineManager = class {
|
|
|
5203
5624
|
if (this.connectivity.shouldAttempt()) try {
|
|
5204
5625
|
const res = await inner.find(params);
|
|
5205
5626
|
this.connectivity.markSuccess();
|
|
5206
|
-
await this.ingest(slug, res.data ?? []);
|
|
5627
|
+
await this.ingest(slug, res.data ?? [], isProjection(params));
|
|
5207
5628
|
const snapshot = this.recordSnapshot(slug, params, res);
|
|
5208
5629
|
const answer = this.answer(slug, params, snapshot);
|
|
5209
5630
|
this.notifyCollection(slug, false);
|
|
@@ -5259,10 +5680,15 @@ var OfflineManager = class {
|
|
|
5259
5680
|
});
|
|
5260
5681
|
return row;
|
|
5261
5682
|
},
|
|
5262
|
-
create: async (data, id) => {
|
|
5683
|
+
create: async (data, id, options) => {
|
|
5263
5684
|
await this.ensureCollection(slug);
|
|
5685
|
+
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
5686
|
+
let sent = false;
|
|
5264
5687
|
if (this.connectivity.shouldAttempt()) try {
|
|
5265
|
-
const row = await inner.create(data, id
|
|
5688
|
+
const row = await inner.create(data, id, {
|
|
5689
|
+
...options,
|
|
5690
|
+
idempotencyKey
|
|
5691
|
+
});
|
|
5266
5692
|
this.connectivity.markSuccess();
|
|
5267
5693
|
await this.ingest(slug, [row]);
|
|
5268
5694
|
this.notifyCollection(slug);
|
|
@@ -5271,6 +5697,7 @@ var OfflineManager = class {
|
|
|
5271
5697
|
} catch (error) {
|
|
5272
5698
|
if (!isNetworkError(error)) throw error;
|
|
5273
5699
|
this.connectivity.markFailure();
|
|
5700
|
+
sent = true;
|
|
5274
5701
|
}
|
|
5275
5702
|
const providedId = id ?? data.id;
|
|
5276
5703
|
const rowId = providedId ?? this.mintOfflineId(slug);
|
|
@@ -5281,6 +5708,11 @@ var OfflineManager = class {
|
|
|
5281
5708
|
id: rowId,
|
|
5282
5709
|
data: row,
|
|
5283
5710
|
generatedId: providedId === void 0,
|
|
5711
|
+
...sent ? { sent: {
|
|
5712
|
+
idempotencyKey,
|
|
5713
|
+
data: { ...data },
|
|
5714
|
+
id
|
|
5715
|
+
} } : {},
|
|
5284
5716
|
rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
|
|
5285
5717
|
});
|
|
5286
5718
|
this.setLocalRow(slug, rowId, row);
|
|
@@ -5291,8 +5723,13 @@ var OfflineManager = class {
|
|
|
5291
5723
|
await this.ensureCollection(slug);
|
|
5292
5724
|
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
5293
5725
|
if (data.length === 0) return [];
|
|
5726
|
+
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
5727
|
+
let sent = false;
|
|
5294
5728
|
if (this.connectivity.shouldAttempt()) try {
|
|
5295
|
-
const rows = await inner.createMany(data,
|
|
5729
|
+
const rows = await inner.createMany(data, {
|
|
5730
|
+
...options,
|
|
5731
|
+
idempotencyKey
|
|
5732
|
+
});
|
|
5296
5733
|
this.connectivity.markSuccess();
|
|
5297
5734
|
await this.ingest(slug, rows);
|
|
5298
5735
|
this.notifyCollection(slug);
|
|
@@ -5301,7 +5738,9 @@ var OfflineManager = class {
|
|
|
5301
5738
|
} catch (error) {
|
|
5302
5739
|
if (!isNetworkError(error)) throw error;
|
|
5303
5740
|
this.connectivity.markFailure();
|
|
5741
|
+
sent = true;
|
|
5304
5742
|
}
|
|
5743
|
+
if (!sent && options?.onConflict?.length) throw new RebaseClientError(`Cannot upsert into "${slug}" while offline: the rows it would replace can only be found by the server. Upserting on the primary key, with the key in each row, is queued; upserting on a natural key is not.`, { code: "OFFLINE_UPSERT_UNSUPPORTED" });
|
|
5305
5744
|
const rows = data.map((r) => optimisticRow(r, r.id ?? this.mintOfflineId(slug)));
|
|
5306
5745
|
const rollback = {};
|
|
5307
5746
|
for (const row of rows) {
|
|
@@ -5313,6 +5752,11 @@ var OfflineManager = class {
|
|
|
5313
5752
|
type: "createMany",
|
|
5314
5753
|
data: rows,
|
|
5315
5754
|
upsert: options?.upsert,
|
|
5755
|
+
...sent ? { sent: {
|
|
5756
|
+
idempotencyKey,
|
|
5757
|
+
data: data.map((r) => ({ ...r })),
|
|
5758
|
+
...options?.onConflict?.length ? { onConflict: [...options.onConflict] } : {}
|
|
5759
|
+
} } : {},
|
|
5316
5760
|
rollback: { rows: rollback }
|
|
5317
5761
|
});
|
|
5318
5762
|
for (const row of rows) this.setLocalRow(slug, row.id, row);
|
|
@@ -5424,8 +5868,14 @@ var OfflineManager = class {
|
|
|
5424
5868
|
},
|
|
5425
5869
|
update: async (id, data, options) => {
|
|
5426
5870
|
await this.ensureCollection(slug);
|
|
5871
|
+
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
5872
|
+
let sent = false;
|
|
5873
|
+
let sendFailure;
|
|
5427
5874
|
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
|
|
5428
|
-
const row = await inner.update(id, data,
|
|
5875
|
+
const row = await inner.update(id, data, {
|
|
5876
|
+
...options,
|
|
5877
|
+
idempotencyKey
|
|
5878
|
+
});
|
|
5429
5879
|
this.connectivity.markSuccess();
|
|
5430
5880
|
await this.ingest(slug, [row]);
|
|
5431
5881
|
this.notifyCollection(slug);
|
|
@@ -5433,14 +5883,25 @@ var OfflineManager = class {
|
|
|
5433
5883
|
} catch (error) {
|
|
5434
5884
|
if (!isNetworkError(error)) throw error;
|
|
5435
5885
|
this.connectivity.markFailure();
|
|
5886
|
+
sent = true;
|
|
5887
|
+
sendFailure = error;
|
|
5888
|
+
}
|
|
5889
|
+
if (hasFieldOperation(data)) {
|
|
5890
|
+
if (sent) throw new RebaseApiError(`The connection failed after a field operation on "${slug}" was sent, so it may or may not have been applied. Retry it with the idempotencyKey in \`details\` to apply it at most once.`, {
|
|
5891
|
+
status: 0,
|
|
5892
|
+
code: "NETWORK_ERROR",
|
|
5893
|
+
details: { idempotencyKey },
|
|
5894
|
+
cause: sendFailure
|
|
5895
|
+
});
|
|
5896
|
+
throw new RebaseClientError(`Cannot apply a field operation to "${slug}" while offline: it is evaluated against the stored value, which this device does not have a current copy of. Send the value itself, or retry when the connection is back.`, { code: "OFFLINE_FIELD_OP_UNSUPPORTED" });
|
|
5436
5897
|
}
|
|
5437
|
-
if (hasFieldOperation(data)) throw new RebaseClientError(`Cannot apply a field operation to "${slug}" while offline: it is evaluated against the stored value, which this device does not have a current copy of. Send the value itself, or retry when the connection is back.`, { code: "OFFLINE_FIELD_OP_UNSUPPORTED" });
|
|
5438
5898
|
const base = this.rawLocalRow(slug, id);
|
|
5439
5899
|
await this.enqueue({
|
|
5440
5900
|
collection: slug,
|
|
5441
5901
|
type: "update",
|
|
5442
5902
|
id,
|
|
5443
5903
|
data,
|
|
5904
|
+
...sent ? { sent: { idempotencyKey } } : {},
|
|
5444
5905
|
rollback: { rows: { [String(id)]: base ?? null } }
|
|
5445
5906
|
});
|
|
5446
5907
|
const optimistic = optimisticRow({
|
|
@@ -5453,8 +5914,13 @@ var OfflineManager = class {
|
|
|
5453
5914
|
},
|
|
5454
5915
|
delete: async (id, options) => {
|
|
5455
5916
|
await this.ensureCollection(slug);
|
|
5917
|
+
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
5918
|
+
let sent = false;
|
|
5456
5919
|
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
|
|
5457
|
-
await inner.delete(id,
|
|
5920
|
+
await inner.delete(id, {
|
|
5921
|
+
...options,
|
|
5922
|
+
idempotencyKey
|
|
5923
|
+
});
|
|
5458
5924
|
this.connectivity.markSuccess();
|
|
5459
5925
|
this.removeLocalRow(slug, id, true);
|
|
5460
5926
|
this.notifyCollection(slug);
|
|
@@ -5463,11 +5929,13 @@ var OfflineManager = class {
|
|
|
5463
5929
|
} catch (error) {
|
|
5464
5930
|
if (!isNetworkError(error)) throw error;
|
|
5465
5931
|
this.connectivity.markFailure();
|
|
5932
|
+
sent = true;
|
|
5466
5933
|
}
|
|
5467
5934
|
await this.enqueue({
|
|
5468
5935
|
collection: slug,
|
|
5469
5936
|
type: "delete",
|
|
5470
5937
|
id,
|
|
5938
|
+
...sent ? { sent: { idempotencyKey } } : {},
|
|
5471
5939
|
rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
|
|
5472
5940
|
});
|
|
5473
5941
|
this.removeLocalRow(slug, id);
|
|
@@ -5511,7 +5979,7 @@ var OfflineManager = class {
|
|
|
5511
5979
|
listenById: inner.listenById
|
|
5512
5980
|
};
|
|
5513
5981
|
if (!isUnsupported(inner.listen)) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
|
|
5514
|
-
this.ingest(slug, response.data ?? []).then(() => this.notifyCollection(slug, false));
|
|
5982
|
+
this.ingest(slug, response.data ?? [], isProjection(params)).then(() => this.notifyCollection(slug, false));
|
|
5515
5983
|
onUpdate(response);
|
|
5516
5984
|
}, onError);
|
|
5517
5985
|
if (!isUnsupported(inner.listenById)) wrapped.listenById = (id, onUpdate, onError) => inner.listenById(id, (row) => {
|
|
@@ -5560,7 +6028,7 @@ var OfflineManager = class {
|
|
|
5560
6028
|
if (!closed) observer.emit();
|
|
5561
6029
|
})();
|
|
5562
6030
|
if (options?.realtime !== false && !isUnsupported(inner.listen)) unlisten = inner.listen(params, (response) => {
|
|
5563
|
-
this.ingest(slug, response.data ?? []).then(() => {
|
|
6031
|
+
this.ingest(slug, response.data ?? [], isProjection(params)).then(() => {
|
|
5564
6032
|
this.recordSnapshot(slug, params, response);
|
|
5565
6033
|
this.notifyCollection(slug, false);
|
|
5566
6034
|
});
|
|
@@ -5638,7 +6106,8 @@ var OfflineManager = class {
|
|
|
5638
6106
|
const state = this.collections.get(slug);
|
|
5639
6107
|
return `${total}|${rows.map((row) => {
|
|
5640
6108
|
const key = String(row.id);
|
|
5641
|
-
|
|
6109
|
+
const entry = state?.rows.get(key);
|
|
6110
|
+
return entry?.row === row ? `${key}:${entry.rev}` : `${key}=${JSON.stringify(row)}`;
|
|
5642
6111
|
}).join(",")}`;
|
|
5643
6112
|
}
|
|
5644
6113
|
notifyCollection(slug, broadcast = true) {
|
|
@@ -5694,7 +6163,7 @@ var OfflineManager = class {
|
|
|
5694
6163
|
}
|
|
5695
6164
|
for (const entry of snapshots) {
|
|
5696
6165
|
const key = entry.key.slice(`${scope}|q|${slug}|`.length);
|
|
5697
|
-
if (entry.value) state.snapshots.set(key, entry.value);
|
|
6166
|
+
if (entry.value) state.snapshots.set(key, readSnapshot(entry.value));
|
|
5698
6167
|
}
|
|
5699
6168
|
for (const entry of absent) state.absent.add(entry.key.slice(`${scope}|abs|${slug}|`.length));
|
|
5700
6169
|
})().catch(() => void 0).finally(() => {
|
|
@@ -5744,6 +6213,7 @@ var OfflineManager = class {
|
|
|
5744
6213
|
partial: true
|
|
5745
6214
|
};
|
|
5746
6215
|
}
|
|
6216
|
+
if (snapshot.rows) return this.projectedAnswer(slug, params, snapshot, snapshot.rows, fromCache);
|
|
5747
6217
|
const rows = [];
|
|
5748
6218
|
const seen = /* @__PURE__ */ new Set();
|
|
5749
6219
|
/** Rows the server counted that we know are no longer in the result. */
|
|
@@ -5786,6 +6256,71 @@ var OfflineManager = class {
|
|
|
5786
6256
|
partial: !exact || !orderIsLocal
|
|
5787
6257
|
};
|
|
5788
6258
|
}
|
|
6259
|
+
/**
|
|
6260
|
+
* Answer a projected query (`fields`, `distinct`) from the server's own
|
|
6261
|
+
* answer to it.
|
|
6262
|
+
*
|
|
6263
|
+
* The server's rows are the answer. The local database contributes only
|
|
6264
|
+
* what it knows better: a row with unsynced writes shows them, narrowed to
|
|
6265
|
+
* the columns the server sent, or drops out if it was deleted here or
|
|
6266
|
+
* edited out of a filter this side can evaluate; and rows created here
|
|
6267
|
+
* join the first page when there is somewhere to put them. A distinct
|
|
6268
|
+
* query, or an order on a column the projection does not carry, leaves
|
|
6269
|
+
* them out, and the result says it is partial.
|
|
6270
|
+
*/
|
|
6271
|
+
projectedAnswer(slug, params, snapshot, served, fromCache) {
|
|
6272
|
+
const state = this.collectionState(slug);
|
|
6273
|
+
const exact = isExactlyEvaluable(params);
|
|
6274
|
+
const rows = [];
|
|
6275
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6276
|
+
let removed = 0;
|
|
6277
|
+
for (const row of served) {
|
|
6278
|
+
const key = row.id === void 0 || row.id === null ? void 0 : String(row.id);
|
|
6279
|
+
if (key !== void 0) seen.add(key);
|
|
6280
|
+
if (key === void 0 || !this.hasPending(slug, key)) {
|
|
6281
|
+
rows.push(row);
|
|
6282
|
+
continue;
|
|
6283
|
+
}
|
|
6284
|
+
const local = state.rows.get(key)?.row;
|
|
6285
|
+
if (!local || exact && !matchesParams(local, params)) {
|
|
6286
|
+
removed++;
|
|
6287
|
+
continue;
|
|
6288
|
+
}
|
|
6289
|
+
rows.push(pick(local, Object.keys(row)));
|
|
6290
|
+
}
|
|
6291
|
+
let added = 0;
|
|
6292
|
+
let unplaced = false;
|
|
6293
|
+
const offset = snapshot.offset ?? 0;
|
|
6294
|
+
if (exact && offset === 0) {
|
|
6295
|
+
const shape = served.length > 0 ? Object.keys(served[0]) : ["id", ...params?.fields ?? []];
|
|
6296
|
+
const placeable = !params?.distinct && (normalizeOrderBy(params?.orderBy) ?? []).every(([field]) => shape.includes(field));
|
|
6297
|
+
for (const [key, entry] of state.rows) {
|
|
6298
|
+
if (seen.has(key) || !this.hasPending(slug, key)) continue;
|
|
6299
|
+
if (!this.isLocallyCreated(slug, key)) continue;
|
|
6300
|
+
if (!matchesParams(entry.row, params)) continue;
|
|
6301
|
+
if (!placeable) {
|
|
6302
|
+
unplaced = true;
|
|
6303
|
+
continue;
|
|
6304
|
+
}
|
|
6305
|
+
rows.push(pick(entry.row, shape));
|
|
6306
|
+
added++;
|
|
6307
|
+
}
|
|
6308
|
+
}
|
|
6309
|
+
const orderIsLocal = added === 0 || isLocallySortable(rows, params?.orderBy);
|
|
6310
|
+
if (added > 0 && params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
|
|
6311
|
+
return {
|
|
6312
|
+
data: rows,
|
|
6313
|
+
meta: {
|
|
6314
|
+
total: Math.max(rows.length, snapshot.total - removed + added),
|
|
6315
|
+
limit: snapshot.limit,
|
|
6316
|
+
offset,
|
|
6317
|
+
hasMore: snapshot.hasMore
|
|
6318
|
+
},
|
|
6319
|
+
fromCache,
|
|
6320
|
+
hasPendingWrites: rows.some((row) => row.id !== void 0 && row.id !== null && this.hasPending(slug, row.id)),
|
|
6321
|
+
partial: !exact || !orderIsLocal || unplaced
|
|
6322
|
+
};
|
|
6323
|
+
}
|
|
5789
6324
|
localFind(slug, params) {
|
|
5790
6325
|
const state = this.collections.get(slug);
|
|
5791
6326
|
const snapshot = this.snapshotFor(slug, params);
|
|
@@ -5869,8 +6404,14 @@ var OfflineManager = class {
|
|
|
5869
6404
|
* Rows that came back unchanged keep their identity and revision, so a
|
|
5870
6405
|
* refetch that changed nothing does not re-render every live query that
|
|
5871
6406
|
* touches them — or rewrite them all to disk.
|
|
6407
|
+
*
|
|
6408
|
+
* A `projection` (see {@link isProjection}) only refreshes the columns it
|
|
6409
|
+
* carries on rows already held, and never creates one. It is not a row:
|
|
6410
|
+
* stored as one, it replaced the full row every other query reads and
|
|
6411
|
+
* wrote the narrowed copy to disk, so a list lost its columns because a
|
|
6412
|
+
* dropdown elsewhere asked for titles.
|
|
5872
6413
|
*/
|
|
5873
|
-
async ingest(slug, rows) {
|
|
6414
|
+
async ingest(slug, rows, projection = false) {
|
|
5874
6415
|
if (rows.length === 0) return;
|
|
5875
6416
|
const state = await this.ensureCollection(slug);
|
|
5876
6417
|
const cachedAt = Date.now();
|
|
@@ -5879,15 +6420,20 @@ var OfflineManager = class {
|
|
|
5879
6420
|
for (const raw of rows) {
|
|
5880
6421
|
if (!raw || raw.id === void 0 || raw.id === null) continue;
|
|
5881
6422
|
const key = String(raw.id);
|
|
5882
|
-
const
|
|
6423
|
+
const existing = state.rows.get(key);
|
|
6424
|
+
if (projection && !existing) continue;
|
|
6425
|
+
const base = projection && existing ? {
|
|
6426
|
+
...existing.row,
|
|
6427
|
+
...raw
|
|
6428
|
+
} : { ...raw };
|
|
6429
|
+
const merged = this.hasPending(slug, key) ? this.applyPendingToRow(slug, key, base) : base;
|
|
5883
6430
|
if (merged === void 0) {
|
|
5884
6431
|
state.rows.delete(key);
|
|
5885
6432
|
deletes.push(this.rowKey(slug, key));
|
|
5886
6433
|
continue;
|
|
5887
6434
|
}
|
|
5888
6435
|
this.forgetTombstone(slug, key);
|
|
5889
|
-
state.freshRows.add(key);
|
|
5890
|
-
const existing = state.rows.get(key);
|
|
6436
|
+
if (!projection) state.freshRows.add(key);
|
|
5891
6437
|
if (existing && JSON.stringify(existing.row) === JSON.stringify(merged)) {
|
|
5892
6438
|
existing.cachedAt = cachedAt;
|
|
5893
6439
|
continue;
|
|
@@ -5952,13 +6498,17 @@ var OfflineManager = class {
|
|
|
5952
6498
|
total: meta.total ?? result.data?.length ?? 0,
|
|
5953
6499
|
limit: meta.limit ?? window.limit,
|
|
5954
6500
|
offset: meta.offset ?? window.offset,
|
|
5955
|
-
hasMore: meta.hasMore ?? false
|
|
6501
|
+
hasMore: meta.hasMore ?? false,
|
|
6502
|
+
...isProjection(params) ? { rows: (result.data ?? []).map((row) => ({ ...row })) } : {}
|
|
5956
6503
|
};
|
|
5957
6504
|
const state = this.collectionState(slug);
|
|
5958
6505
|
const key = buildQueryString(params);
|
|
5959
6506
|
state.snapshots.set(key, snapshot);
|
|
5960
6507
|
state.fresh.add(key);
|
|
5961
|
-
this.writeCache(`${this.scope}|q|${slug}|${key}`, snapshot
|
|
6508
|
+
this.writeCache(`${this.scope}|q|${slug}|${key}`, snapshot.rows ? {
|
|
6509
|
+
...snapshot,
|
|
6510
|
+
rows: snapshot.rows.map((row) => dehydrateRow(row))
|
|
6511
|
+
} : snapshot);
|
|
5962
6512
|
this.evictSnapshots(slug);
|
|
5963
6513
|
return snapshot;
|
|
5964
6514
|
}
|
|
@@ -6022,7 +6572,7 @@ var OfflineManager = class {
|
|
|
6022
6572
|
await this.ensureQueueLoaded();
|
|
6023
6573
|
if (mutation.type === "update") {
|
|
6024
6574
|
const tail = this.queue[this.queue.length - 1];
|
|
6025
|
-
if (tail && tail.mutationId !== this.inFlightId && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
|
|
6575
|
+
if (tail && tail.mutationId !== this.inFlightId && tail.sent === void 0 && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
|
|
6026
6576
|
tail.data = {
|
|
6027
6577
|
...tail.data,
|
|
6028
6578
|
...mutation.data,
|
|
@@ -6033,7 +6583,7 @@ var OfflineManager = class {
|
|
|
6033
6583
|
}
|
|
6034
6584
|
}
|
|
6035
6585
|
if (mutation.type === "delete") {
|
|
6036
|
-
if (this.queue.some((m) => m.collection === mutation.collection && m.type === "create" && m.id === mutation.id && m.generatedId === true && m.mutationId !== this.inFlightId)) {
|
|
6586
|
+
if (this.queue.some((m) => m.collection === mutation.collection && m.type === "create" && m.id === mutation.id && m.generatedId === true && m.sent === void 0 && m.mutationId !== this.inFlightId)) {
|
|
6037
6587
|
const doomed = this.queue.filter((m) => m.collection === mutation.collection && m.id === mutation.id && (m.type === "create" || m.type === "update") && m.mutationId !== this.inFlightId);
|
|
6038
6588
|
for (const op of doomed) await this.store.dequeue(this.queueKey(op));
|
|
6039
6589
|
this.queue = this.queue.filter((m) => !doomed.includes(m));
|
|
@@ -6158,7 +6708,7 @@ var OfflineManager = class {
|
|
|
6158
6708
|
if (op.type === "create") {
|
|
6159
6709
|
let row;
|
|
6160
6710
|
try {
|
|
6161
|
-
row = await inner.create(op.data, void 0, { idempotencyKey:
|
|
6711
|
+
row = await this.sendQueuedWrite(op, (idempotencyKey) => inner.create(op.data, void 0, { idempotencyKey }), (sent) => inner.create(sent.data, sent.id, { idempotencyKey: sent.idempotencyKey }));
|
|
6162
6712
|
} catch (error) {
|
|
6163
6713
|
if (!(op.generatedId === true && isDuplicateKeyError(error))) throw error;
|
|
6164
6714
|
row = await inner.findById(op.id).catch(() => void 0);
|
|
@@ -6167,10 +6717,15 @@ var OfflineManager = class {
|
|
|
6167
6717
|
await this.adoptServerRow(op, op.id, row);
|
|
6168
6718
|
} else if (op.type === "createMany") {
|
|
6169
6719
|
const queued = op.data ?? [];
|
|
6170
|
-
const
|
|
6720
|
+
const asSent = (sent) => inner.createMany(sent.data, {
|
|
6171
6721
|
...op.upsert ? { upsert: true } : {},
|
|
6172
|
-
|
|
6722
|
+
...sent.onConflict?.length ? { onConflict: sent.onConflict } : {},
|
|
6723
|
+
idempotencyKey: sent.idempotencyKey
|
|
6173
6724
|
});
|
|
6725
|
+
const rows = op.sent?.onConflict?.length ? await asSent(op.sent) : await this.sendQueuedWrite(op, (idempotencyKey) => inner.createMany(queued, {
|
|
6726
|
+
...op.upsert ? { upsert: true } : {},
|
|
6727
|
+
idempotencyKey
|
|
6728
|
+
}), asSent);
|
|
6174
6729
|
for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
|
|
6175
6730
|
} else if (op.type === "updateMany") {
|
|
6176
6731
|
const queued = op.updates ?? [];
|
|
@@ -6180,18 +6735,36 @@ var OfflineManager = class {
|
|
|
6180
6735
|
})), { idempotencyKey: op.mutationId });
|
|
6181
6736
|
for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
|
|
6182
6737
|
} else if (op.type === "update") {
|
|
6183
|
-
const row = await inner.update(op.id, op.data);
|
|
6738
|
+
const row = await inner.update(op.id, op.data, op.sent ? { idempotencyKey: op.sent.idempotencyKey } : void 0);
|
|
6184
6739
|
await this.ingestReplaced(op, op.id, row);
|
|
6185
6740
|
} else if (op.type === "deleteMany") {
|
|
6186
6741
|
const ids = op.ids ?? [];
|
|
6187
6742
|
await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
|
|
6188
6743
|
for (const id of ids) this.removeLocalRow(op.collection, id, true);
|
|
6189
6744
|
} else if (op.type === "delete") {
|
|
6190
|
-
await inner.delete(op.id);
|
|
6745
|
+
await inner.delete(op.id, { idempotencyKey: op.sent?.idempotencyKey ?? op.mutationId });
|
|
6191
6746
|
this.removeLocalRow(op.collection, op.id, true);
|
|
6192
6747
|
}
|
|
6193
6748
|
}
|
|
6194
6749
|
/**
|
|
6750
|
+
* Send a queued write under its key.
|
|
6751
|
+
*
|
|
6752
|
+
* A write the queue sent once already, before it was queued, carries that
|
|
6753
|
+
* attempt's key and is sent under it again, as queued. If the server holds
|
|
6754
|
+
* the key for a different request, the difference is the ids minted here
|
|
6755
|
+
* — so the first attempt committed and only its answer was lost, and
|
|
6756
|
+
* sending that request again returns the stored answer instead of a
|
|
6757
|
+
* second copy of the rows.
|
|
6758
|
+
*/
|
|
6759
|
+
async sendQueuedWrite(op, asQueued, asSent) {
|
|
6760
|
+
try {
|
|
6761
|
+
return await asQueued(op.sent?.idempotencyKey ?? op.mutationId);
|
|
6762
|
+
} catch (error) {
|
|
6763
|
+
if (!op.sent || !isIdempotencyKeyReusedError(error)) throw error;
|
|
6764
|
+
return asSent(op.sent);
|
|
6765
|
+
}
|
|
6766
|
+
}
|
|
6767
|
+
/**
|
|
6195
6768
|
* Take the server's version of a row the client created offline.
|
|
6196
6769
|
*
|
|
6197
6770
|
* The server may have assigned a different id — a serial column ignores
|
|
@@ -6362,7 +6935,7 @@ var OfflineManager = class {
|
|
|
6362
6935
|
state.snapshots = /* @__PURE__ */ new Map();
|
|
6363
6936
|
for (const entry of snapshots) {
|
|
6364
6937
|
const key = entry.key.slice(`${scope}|q|${slug}|`.length);
|
|
6365
|
-
if (entry.value) state.snapshots.set(key, entry.value);
|
|
6938
|
+
if (entry.value) state.snapshots.set(key, readSnapshot(entry.value));
|
|
6366
6939
|
}
|
|
6367
6940
|
state.absent = new Set(absent.map((entry) => entry.key.slice(`${scope}|abs|${slug}|`.length)));
|
|
6368
6941
|
this.notifyCollection(slug, false);
|
|
@@ -6503,15 +7076,28 @@ function createRebaseClient(options) {
|
|
|
6503
7076
|
let session = auth.getSession();
|
|
6504
7077
|
if (session && session.expiresAt <= Date.now() + 1e4) try {
|
|
6505
7078
|
session = await auth.refreshSession();
|
|
6506
|
-
} catch (e) {
|
|
7079
|
+
} catch (e) {
|
|
7080
|
+
session = auth.getSession();
|
|
7081
|
+
}
|
|
6507
7082
|
return session?.accessToken || options.token || "";
|
|
6508
7083
|
},
|
|
6509
7084
|
onUnauthorized: options.onUnauthorized || (() => auth.handleUnauthorized())
|
|
6510
7085
|
});
|
|
7086
|
+
let realtimeUid = auth.getSession()?.user?.uid ?? null;
|
|
6511
7087
|
auth.onAuthStateChange((event, session) => {
|
|
6512
7088
|
if (!ws) return;
|
|
6513
|
-
if (event === "SIGNED_OUT")
|
|
6514
|
-
|
|
7089
|
+
if (event === "SIGNED_OUT") {
|
|
7090
|
+
realtimeUid = null;
|
|
7091
|
+
ws.disconnect();
|
|
7092
|
+
} else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
|
7093
|
+
const uid = session?.user?.uid ?? null;
|
|
7094
|
+
if (uid !== realtimeUid) {
|
|
7095
|
+
realtimeUid = uid;
|
|
7096
|
+
const hadSocket = ws.hasSocket;
|
|
7097
|
+
ws.disconnect();
|
|
7098
|
+
if (hadSocket) ws.ensureConnected();
|
|
7099
|
+
return;
|
|
7100
|
+
}
|
|
6515
7101
|
if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
|
|
6516
7102
|
}
|
|
6517
7103
|
});
|
|
@@ -6651,7 +7237,7 @@ channel: (name, options) => {
|
|
|
6651
7237
|
const prefix = endpoint.startsWith("/") ? "" : "/";
|
|
6652
7238
|
return transport.request(`${prefix}${endpoint}`, {
|
|
6653
7239
|
method: "POST",
|
|
6654
|
-
body: payload ? JSON.stringify(payload) : void 0
|
|
7240
|
+
body: payload !== void 0 ? JSON.stringify(payload) : void 0
|
|
6655
7241
|
});
|
|
6656
7242
|
},
|
|
6657
7243
|
batch: async (operations, options) => {
|