@rebasepro/client 0.21.2-canary.g1ea48be → 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 +29 -5
- package/dist/backups.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.es.js +733 -140
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +8 -0
- package/dist/offline-query.d.ts +15 -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,6 +1,6 @@
|
|
|
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";
|
|
3
|
-
import { toSnakeCase, unref } from "@rebasepro/utils";
|
|
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
|
+
import { splitSearchTerms, toSnakeCase, unref } from "@rebasepro/utils";
|
|
4
4
|
//#region src/reviver.ts
|
|
5
5
|
function rebaseReviver(_key, value) {
|
|
6
6
|
if (value && typeof value === "object" && "__type" in 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,12 +3057,46 @@ 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);
|
|
2820
3070
|
}
|
|
2821
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;
|
|
3097
|
+
}
|
|
3098
|
+
return true;
|
|
3099
|
+
}
|
|
2822
3100
|
attemptReconnect() {
|
|
2823
3101
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
2824
3102
|
console.error("Max reconnection attempts reached");
|
|
@@ -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
|
/**
|
|
@@ -4742,16 +5141,23 @@ function matchesLogical(row, condition) {
|
|
|
4742
5141
|
* cached row does not carry — a local list may therefore be missing rows the
|
|
4743
5142
|
* server would have returned, which is why {@link isExactlyEvaluable} refuses
|
|
4744
5143
|
* to call a search query exact.
|
|
5144
|
+
*
|
|
5145
|
+
* Every term has to match, but they may match different fields — the same rule
|
|
5146
|
+
* both drivers apply, and the one that finds `sebastian melendez` on a row that
|
|
5147
|
+
* keeps the two halves of a name in separate fields. Matching the typed string
|
|
5148
|
+
* as a whole found neither that row nor, because the space was part of the
|
|
5149
|
+
* needle, anything at all for `sebastian `.
|
|
4745
5150
|
*/
|
|
4746
5151
|
function matchesSearch(row, searchString) {
|
|
4747
5152
|
if (!searchString) return true;
|
|
4748
|
-
const
|
|
4749
|
-
if (
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
if (typeof value === "
|
|
4753
|
-
|
|
4754
|
-
|
|
5153
|
+
const terms = splitSearchTerms(searchString).map((term) => term.toLowerCase());
|
|
5154
|
+
if (terms.length === 0) return true;
|
|
5155
|
+
const values = Object.values(row);
|
|
5156
|
+
return terms.every((term) => values.some((value) => {
|
|
5157
|
+
if (typeof value === "string") return value.toLowerCase().includes(term);
|
|
5158
|
+
if (typeof value === "number") return String(value).includes(term);
|
|
5159
|
+
return false;
|
|
5160
|
+
}));
|
|
4755
5161
|
}
|
|
4756
5162
|
/** Does this row belong in the result set for `params`, ignoring pagination? */
|
|
4757
5163
|
function matchesParams(row, params) {
|
|
@@ -4904,14 +5310,14 @@ function logicalReachesThroughRelation(condition, depth = 0) {
|
|
|
4904
5310
|
*
|
|
4905
5311
|
* Asked of the rows rather than of the query, because unlike a filter this one
|
|
4906
5312
|
* *is* decidable from the data in hand: {@link compareValues} reaches the
|
|
4907
|
-
* collator only when
|
|
4908
|
-
*
|
|
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
|
|
4909
5315
|
* value on the sort column normalises to a number, the collator is unreachable
|
|
4910
5316
|
* and the local order is the server's order.
|
|
4911
5317
|
*
|
|
4912
5318
|
* A text column is therefore refused — see {@link isExactlyEvaluable} for why
|
|
4913
|
-
* the two cannot be made to agree —
|
|
4914
|
-
*
|
|
5319
|
+
* the two cannot be made to agree — including one holding only digits, which
|
|
5320
|
+
* Postgres orders as text ("10", "100", "9").
|
|
4915
5321
|
*
|
|
4916
5322
|
* Nulls are fine either way: they are ordered by an explicit rule (last
|
|
4917
5323
|
* ascending, first descending) that matches Postgres and never reaches the
|
|
@@ -4926,7 +5332,6 @@ function isLocallySortable(rows, orderBy) {
|
|
|
4926
5332
|
if (isNullish(value)) return true;
|
|
4927
5333
|
if (typeof value === "number" || typeof value === "boolean") return true;
|
|
4928
5334
|
if (typeof value === "bigint") return true;
|
|
4929
|
-
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) return true;
|
|
4930
5335
|
return false;
|
|
4931
5336
|
}));
|
|
4932
5337
|
}
|
|
@@ -5018,6 +5423,29 @@ function optimisticRow(fields, id) {
|
|
|
5018
5423
|
id
|
|
5019
5424
|
};
|
|
5020
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
|
+
}
|
|
5021
5449
|
var MISSING = "\0missing";
|
|
5022
5450
|
/**
|
|
5023
5451
|
* Replays to spend on a mutation whose idempotency key the server is still
|
|
@@ -5196,7 +5624,7 @@ var OfflineManager = class {
|
|
|
5196
5624
|
if (this.connectivity.shouldAttempt()) try {
|
|
5197
5625
|
const res = await inner.find(params);
|
|
5198
5626
|
this.connectivity.markSuccess();
|
|
5199
|
-
await this.ingest(slug, res.data ?? []);
|
|
5627
|
+
await this.ingest(slug, res.data ?? [], isProjection(params));
|
|
5200
5628
|
const snapshot = this.recordSnapshot(slug, params, res);
|
|
5201
5629
|
const answer = this.answer(slug, params, snapshot);
|
|
5202
5630
|
this.notifyCollection(slug, false);
|
|
@@ -5252,10 +5680,15 @@ var OfflineManager = class {
|
|
|
5252
5680
|
});
|
|
5253
5681
|
return row;
|
|
5254
5682
|
},
|
|
5255
|
-
create: async (data, id) => {
|
|
5683
|
+
create: async (data, id, options) => {
|
|
5256
5684
|
await this.ensureCollection(slug);
|
|
5685
|
+
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
5686
|
+
let sent = false;
|
|
5257
5687
|
if (this.connectivity.shouldAttempt()) try {
|
|
5258
|
-
const row = await inner.create(data, id
|
|
5688
|
+
const row = await inner.create(data, id, {
|
|
5689
|
+
...options,
|
|
5690
|
+
idempotencyKey
|
|
5691
|
+
});
|
|
5259
5692
|
this.connectivity.markSuccess();
|
|
5260
5693
|
await this.ingest(slug, [row]);
|
|
5261
5694
|
this.notifyCollection(slug);
|
|
@@ -5264,6 +5697,7 @@ var OfflineManager = class {
|
|
|
5264
5697
|
} catch (error) {
|
|
5265
5698
|
if (!isNetworkError(error)) throw error;
|
|
5266
5699
|
this.connectivity.markFailure();
|
|
5700
|
+
sent = true;
|
|
5267
5701
|
}
|
|
5268
5702
|
const providedId = id ?? data.id;
|
|
5269
5703
|
const rowId = providedId ?? this.mintOfflineId(slug);
|
|
@@ -5274,6 +5708,11 @@ var OfflineManager = class {
|
|
|
5274
5708
|
id: rowId,
|
|
5275
5709
|
data: row,
|
|
5276
5710
|
generatedId: providedId === void 0,
|
|
5711
|
+
...sent ? { sent: {
|
|
5712
|
+
idempotencyKey,
|
|
5713
|
+
data: { ...data },
|
|
5714
|
+
id
|
|
5715
|
+
} } : {},
|
|
5277
5716
|
rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
|
|
5278
5717
|
});
|
|
5279
5718
|
this.setLocalRow(slug, rowId, row);
|
|
@@ -5284,8 +5723,13 @@ var OfflineManager = class {
|
|
|
5284
5723
|
await this.ensureCollection(slug);
|
|
5285
5724
|
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
5286
5725
|
if (data.length === 0) return [];
|
|
5726
|
+
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
5727
|
+
let sent = false;
|
|
5287
5728
|
if (this.connectivity.shouldAttempt()) try {
|
|
5288
|
-
const rows = await inner.createMany(data,
|
|
5729
|
+
const rows = await inner.createMany(data, {
|
|
5730
|
+
...options,
|
|
5731
|
+
idempotencyKey
|
|
5732
|
+
});
|
|
5289
5733
|
this.connectivity.markSuccess();
|
|
5290
5734
|
await this.ingest(slug, rows);
|
|
5291
5735
|
this.notifyCollection(slug);
|
|
@@ -5294,7 +5738,9 @@ var OfflineManager = class {
|
|
|
5294
5738
|
} catch (error) {
|
|
5295
5739
|
if (!isNetworkError(error)) throw error;
|
|
5296
5740
|
this.connectivity.markFailure();
|
|
5741
|
+
sent = true;
|
|
5297
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" });
|
|
5298
5744
|
const rows = data.map((r) => optimisticRow(r, r.id ?? this.mintOfflineId(slug)));
|
|
5299
5745
|
const rollback = {};
|
|
5300
5746
|
for (const row of rows) {
|
|
@@ -5306,6 +5752,11 @@ var OfflineManager = class {
|
|
|
5306
5752
|
type: "createMany",
|
|
5307
5753
|
data: rows,
|
|
5308
5754
|
upsert: options?.upsert,
|
|
5755
|
+
...sent ? { sent: {
|
|
5756
|
+
idempotencyKey,
|
|
5757
|
+
data: data.map((r) => ({ ...r })),
|
|
5758
|
+
...options?.onConflict?.length ? { onConflict: [...options.onConflict] } : {}
|
|
5759
|
+
} } : {},
|
|
5309
5760
|
rollback: { rows: rollback }
|
|
5310
5761
|
});
|
|
5311
5762
|
for (const row of rows) this.setLocalRow(slug, row.id, row);
|
|
@@ -5417,8 +5868,14 @@ var OfflineManager = class {
|
|
|
5417
5868
|
},
|
|
5418
5869
|
update: async (id, data, options) => {
|
|
5419
5870
|
await this.ensureCollection(slug);
|
|
5871
|
+
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
5872
|
+
let sent = false;
|
|
5873
|
+
let sendFailure;
|
|
5420
5874
|
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
|
|
5421
|
-
const row = await inner.update(id, data,
|
|
5875
|
+
const row = await inner.update(id, data, {
|
|
5876
|
+
...options,
|
|
5877
|
+
idempotencyKey
|
|
5878
|
+
});
|
|
5422
5879
|
this.connectivity.markSuccess();
|
|
5423
5880
|
await this.ingest(slug, [row]);
|
|
5424
5881
|
this.notifyCollection(slug);
|
|
@@ -5426,14 +5883,25 @@ var OfflineManager = class {
|
|
|
5426
5883
|
} catch (error) {
|
|
5427
5884
|
if (!isNetworkError(error)) throw error;
|
|
5428
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" });
|
|
5429
5897
|
}
|
|
5430
|
-
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" });
|
|
5431
5898
|
const base = this.rawLocalRow(slug, id);
|
|
5432
5899
|
await this.enqueue({
|
|
5433
5900
|
collection: slug,
|
|
5434
5901
|
type: "update",
|
|
5435
5902
|
id,
|
|
5436
5903
|
data,
|
|
5904
|
+
...sent ? { sent: { idempotencyKey } } : {},
|
|
5437
5905
|
rollback: { rows: { [String(id)]: base ?? null } }
|
|
5438
5906
|
});
|
|
5439
5907
|
const optimistic = optimisticRow({
|
|
@@ -5446,8 +5914,13 @@ var OfflineManager = class {
|
|
|
5446
5914
|
},
|
|
5447
5915
|
delete: async (id, options) => {
|
|
5448
5916
|
await this.ensureCollection(slug);
|
|
5917
|
+
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
5918
|
+
let sent = false;
|
|
5449
5919
|
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
|
|
5450
|
-
await inner.delete(id,
|
|
5920
|
+
await inner.delete(id, {
|
|
5921
|
+
...options,
|
|
5922
|
+
idempotencyKey
|
|
5923
|
+
});
|
|
5451
5924
|
this.connectivity.markSuccess();
|
|
5452
5925
|
this.removeLocalRow(slug, id, true);
|
|
5453
5926
|
this.notifyCollection(slug);
|
|
@@ -5456,11 +5929,13 @@ var OfflineManager = class {
|
|
|
5456
5929
|
} catch (error) {
|
|
5457
5930
|
if (!isNetworkError(error)) throw error;
|
|
5458
5931
|
this.connectivity.markFailure();
|
|
5932
|
+
sent = true;
|
|
5459
5933
|
}
|
|
5460
5934
|
await this.enqueue({
|
|
5461
5935
|
collection: slug,
|
|
5462
5936
|
type: "delete",
|
|
5463
5937
|
id,
|
|
5938
|
+
...sent ? { sent: { idempotencyKey } } : {},
|
|
5464
5939
|
rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
|
|
5465
5940
|
});
|
|
5466
5941
|
this.removeLocalRow(slug, id);
|
|
@@ -5504,7 +5979,7 @@ var OfflineManager = class {
|
|
|
5504
5979
|
listenById: inner.listenById
|
|
5505
5980
|
};
|
|
5506
5981
|
if (!isUnsupported(inner.listen)) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
|
|
5507
|
-
this.ingest(slug, response.data ?? []).then(() => this.notifyCollection(slug, false));
|
|
5982
|
+
this.ingest(slug, response.data ?? [], isProjection(params)).then(() => this.notifyCollection(slug, false));
|
|
5508
5983
|
onUpdate(response);
|
|
5509
5984
|
}, onError);
|
|
5510
5985
|
if (!isUnsupported(inner.listenById)) wrapped.listenById = (id, onUpdate, onError) => inner.listenById(id, (row) => {
|
|
@@ -5553,7 +6028,7 @@ var OfflineManager = class {
|
|
|
5553
6028
|
if (!closed) observer.emit();
|
|
5554
6029
|
})();
|
|
5555
6030
|
if (options?.realtime !== false && !isUnsupported(inner.listen)) unlisten = inner.listen(params, (response) => {
|
|
5556
|
-
this.ingest(slug, response.data ?? []).then(() => {
|
|
6031
|
+
this.ingest(slug, response.data ?? [], isProjection(params)).then(() => {
|
|
5557
6032
|
this.recordSnapshot(slug, params, response);
|
|
5558
6033
|
this.notifyCollection(slug, false);
|
|
5559
6034
|
});
|
|
@@ -5631,7 +6106,8 @@ var OfflineManager = class {
|
|
|
5631
6106
|
const state = this.collections.get(slug);
|
|
5632
6107
|
return `${total}|${rows.map((row) => {
|
|
5633
6108
|
const key = String(row.id);
|
|
5634
|
-
|
|
6109
|
+
const entry = state?.rows.get(key);
|
|
6110
|
+
return entry?.row === row ? `${key}:${entry.rev}` : `${key}=${JSON.stringify(row)}`;
|
|
5635
6111
|
}).join(",")}`;
|
|
5636
6112
|
}
|
|
5637
6113
|
notifyCollection(slug, broadcast = true) {
|
|
@@ -5687,7 +6163,7 @@ var OfflineManager = class {
|
|
|
5687
6163
|
}
|
|
5688
6164
|
for (const entry of snapshots) {
|
|
5689
6165
|
const key = entry.key.slice(`${scope}|q|${slug}|`.length);
|
|
5690
|
-
if (entry.value) state.snapshots.set(key, entry.value);
|
|
6166
|
+
if (entry.value) state.snapshots.set(key, readSnapshot(entry.value));
|
|
5691
6167
|
}
|
|
5692
6168
|
for (const entry of absent) state.absent.add(entry.key.slice(`${scope}|abs|${slug}|`.length));
|
|
5693
6169
|
})().catch(() => void 0).finally(() => {
|
|
@@ -5737,6 +6213,7 @@ var OfflineManager = class {
|
|
|
5737
6213
|
partial: true
|
|
5738
6214
|
};
|
|
5739
6215
|
}
|
|
6216
|
+
if (snapshot.rows) return this.projectedAnswer(slug, params, snapshot, snapshot.rows, fromCache);
|
|
5740
6217
|
const rows = [];
|
|
5741
6218
|
const seen = /* @__PURE__ */ new Set();
|
|
5742
6219
|
/** Rows the server counted that we know are no longer in the result. */
|
|
@@ -5779,6 +6256,71 @@ var OfflineManager = class {
|
|
|
5779
6256
|
partial: !exact || !orderIsLocal
|
|
5780
6257
|
};
|
|
5781
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
|
+
}
|
|
5782
6324
|
localFind(slug, params) {
|
|
5783
6325
|
const state = this.collections.get(slug);
|
|
5784
6326
|
const snapshot = this.snapshotFor(slug, params);
|
|
@@ -5862,8 +6404,14 @@ var OfflineManager = class {
|
|
|
5862
6404
|
* Rows that came back unchanged keep their identity and revision, so a
|
|
5863
6405
|
* refetch that changed nothing does not re-render every live query that
|
|
5864
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.
|
|
5865
6413
|
*/
|
|
5866
|
-
async ingest(slug, rows) {
|
|
6414
|
+
async ingest(slug, rows, projection = false) {
|
|
5867
6415
|
if (rows.length === 0) return;
|
|
5868
6416
|
const state = await this.ensureCollection(slug);
|
|
5869
6417
|
const cachedAt = Date.now();
|
|
@@ -5872,15 +6420,20 @@ var OfflineManager = class {
|
|
|
5872
6420
|
for (const raw of rows) {
|
|
5873
6421
|
if (!raw || raw.id === void 0 || raw.id === null) continue;
|
|
5874
6422
|
const key = String(raw.id);
|
|
5875
|
-
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;
|
|
5876
6430
|
if (merged === void 0) {
|
|
5877
6431
|
state.rows.delete(key);
|
|
5878
6432
|
deletes.push(this.rowKey(slug, key));
|
|
5879
6433
|
continue;
|
|
5880
6434
|
}
|
|
5881
6435
|
this.forgetTombstone(slug, key);
|
|
5882
|
-
state.freshRows.add(key);
|
|
5883
|
-
const existing = state.rows.get(key);
|
|
6436
|
+
if (!projection) state.freshRows.add(key);
|
|
5884
6437
|
if (existing && JSON.stringify(existing.row) === JSON.stringify(merged)) {
|
|
5885
6438
|
existing.cachedAt = cachedAt;
|
|
5886
6439
|
continue;
|
|
@@ -5945,13 +6498,17 @@ var OfflineManager = class {
|
|
|
5945
6498
|
total: meta.total ?? result.data?.length ?? 0,
|
|
5946
6499
|
limit: meta.limit ?? window.limit,
|
|
5947
6500
|
offset: meta.offset ?? window.offset,
|
|
5948
|
-
hasMore: meta.hasMore ?? false
|
|
6501
|
+
hasMore: meta.hasMore ?? false,
|
|
6502
|
+
...isProjection(params) ? { rows: (result.data ?? []).map((row) => ({ ...row })) } : {}
|
|
5949
6503
|
};
|
|
5950
6504
|
const state = this.collectionState(slug);
|
|
5951
6505
|
const key = buildQueryString(params);
|
|
5952
6506
|
state.snapshots.set(key, snapshot);
|
|
5953
6507
|
state.fresh.add(key);
|
|
5954
|
-
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);
|
|
5955
6512
|
this.evictSnapshots(slug);
|
|
5956
6513
|
return snapshot;
|
|
5957
6514
|
}
|
|
@@ -6015,7 +6572,7 @@ var OfflineManager = class {
|
|
|
6015
6572
|
await this.ensureQueueLoaded();
|
|
6016
6573
|
if (mutation.type === "update") {
|
|
6017
6574
|
const tail = this.queue[this.queue.length - 1];
|
|
6018
|
-
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) {
|
|
6019
6576
|
tail.data = {
|
|
6020
6577
|
...tail.data,
|
|
6021
6578
|
...mutation.data,
|
|
@@ -6026,7 +6583,7 @@ var OfflineManager = class {
|
|
|
6026
6583
|
}
|
|
6027
6584
|
}
|
|
6028
6585
|
if (mutation.type === "delete") {
|
|
6029
|
-
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)) {
|
|
6030
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);
|
|
6031
6588
|
for (const op of doomed) await this.store.dequeue(this.queueKey(op));
|
|
6032
6589
|
this.queue = this.queue.filter((m) => !doomed.includes(m));
|
|
@@ -6151,7 +6708,7 @@ var OfflineManager = class {
|
|
|
6151
6708
|
if (op.type === "create") {
|
|
6152
6709
|
let row;
|
|
6153
6710
|
try {
|
|
6154
|
-
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 }));
|
|
6155
6712
|
} catch (error) {
|
|
6156
6713
|
if (!(op.generatedId === true && isDuplicateKeyError(error))) throw error;
|
|
6157
6714
|
row = await inner.findById(op.id).catch(() => void 0);
|
|
@@ -6160,10 +6717,15 @@ var OfflineManager = class {
|
|
|
6160
6717
|
await this.adoptServerRow(op, op.id, row);
|
|
6161
6718
|
} else if (op.type === "createMany") {
|
|
6162
6719
|
const queued = op.data ?? [];
|
|
6163
|
-
const
|
|
6720
|
+
const asSent = (sent) => inner.createMany(sent.data, {
|
|
6164
6721
|
...op.upsert ? { upsert: true } : {},
|
|
6165
|
-
|
|
6722
|
+
...sent.onConflict?.length ? { onConflict: sent.onConflict } : {},
|
|
6723
|
+
idempotencyKey: sent.idempotencyKey
|
|
6166
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);
|
|
6167
6729
|
for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
|
|
6168
6730
|
} else if (op.type === "updateMany") {
|
|
6169
6731
|
const queued = op.updates ?? [];
|
|
@@ -6173,18 +6735,36 @@ var OfflineManager = class {
|
|
|
6173
6735
|
})), { idempotencyKey: op.mutationId });
|
|
6174
6736
|
for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
|
|
6175
6737
|
} else if (op.type === "update") {
|
|
6176
|
-
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);
|
|
6177
6739
|
await this.ingestReplaced(op, op.id, row);
|
|
6178
6740
|
} else if (op.type === "deleteMany") {
|
|
6179
6741
|
const ids = op.ids ?? [];
|
|
6180
6742
|
await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
|
|
6181
6743
|
for (const id of ids) this.removeLocalRow(op.collection, id, true);
|
|
6182
6744
|
} else if (op.type === "delete") {
|
|
6183
|
-
await inner.delete(op.id);
|
|
6745
|
+
await inner.delete(op.id, { idempotencyKey: op.sent?.idempotencyKey ?? op.mutationId });
|
|
6184
6746
|
this.removeLocalRow(op.collection, op.id, true);
|
|
6185
6747
|
}
|
|
6186
6748
|
}
|
|
6187
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
|
+
/**
|
|
6188
6768
|
* Take the server's version of a row the client created offline.
|
|
6189
6769
|
*
|
|
6190
6770
|
* The server may have assigned a different id — a serial column ignores
|
|
@@ -6355,7 +6935,7 @@ var OfflineManager = class {
|
|
|
6355
6935
|
state.snapshots = /* @__PURE__ */ new Map();
|
|
6356
6936
|
for (const entry of snapshots) {
|
|
6357
6937
|
const key = entry.key.slice(`${scope}|q|${slug}|`.length);
|
|
6358
|
-
if (entry.value) state.snapshots.set(key, entry.value);
|
|
6938
|
+
if (entry.value) state.snapshots.set(key, readSnapshot(entry.value));
|
|
6359
6939
|
}
|
|
6360
6940
|
state.absent = new Set(absent.map((entry) => entry.key.slice(`${scope}|abs|${slug}|`.length)));
|
|
6361
6941
|
this.notifyCollection(slug, false);
|
|
@@ -6496,15 +7076,28 @@ function createRebaseClient(options) {
|
|
|
6496
7076
|
let session = auth.getSession();
|
|
6497
7077
|
if (session && session.expiresAt <= Date.now() + 1e4) try {
|
|
6498
7078
|
session = await auth.refreshSession();
|
|
6499
|
-
} catch (e) {
|
|
7079
|
+
} catch (e) {
|
|
7080
|
+
session = auth.getSession();
|
|
7081
|
+
}
|
|
6500
7082
|
return session?.accessToken || options.token || "";
|
|
6501
7083
|
},
|
|
6502
7084
|
onUnauthorized: options.onUnauthorized || (() => auth.handleUnauthorized())
|
|
6503
7085
|
});
|
|
7086
|
+
let realtimeUid = auth.getSession()?.user?.uid ?? null;
|
|
6504
7087
|
auth.onAuthStateChange((event, session) => {
|
|
6505
7088
|
if (!ws) return;
|
|
6506
|
-
if (event === "SIGNED_OUT")
|
|
6507
|
-
|
|
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
|
+
}
|
|
6508
7101
|
if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
|
|
6509
7102
|
}
|
|
6510
7103
|
});
|
|
@@ -6644,7 +7237,7 @@ channel: (name, options) => {
|
|
|
6644
7237
|
const prefix = endpoint.startsWith("/") ? "" : "/";
|
|
6645
7238
|
return transport.request(`${prefix}${endpoint}`, {
|
|
6646
7239
|
method: "POST",
|
|
6647
|
-
body: payload ? JSON.stringify(payload) : void 0
|
|
7240
|
+
body: payload !== void 0 ? JSON.stringify(payload) : void 0
|
|
6648
7241
|
});
|
|
6649
7242
|
},
|
|
6650
7243
|
batch: async (operations, options) => {
|