@rebasepro/client 0.22.0 → 0.24.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 +20 -13
- package/dist/api-keys.d.ts +29 -11
- package/dist/auth.d.ts +65 -14
- package/dist/backups.d.ts +2 -6
- package/dist/collection.d.ts +4 -4
- package/dist/cron.d.ts +2 -4
- package/dist/index.d.ts +37 -12
- package/dist/index.es.js +1814 -333
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +14 -0
- package/dist/offline-query.d.ts +9 -5
- package/dist/offline-store.d.ts +30 -0
- package/dist/offline.d.ts +117 -3
- package/dist/query-contract.types.d.ts +23 -23
- package/dist/realtime-channel.d.ts +35 -0
- package/dist/sdk_query_builder.d.ts +2 -2
- package/dist/sql-script.d.ts +10 -0
- package/dist/transport.d.ts +32 -2
- package/dist/websocket.d.ts +144 -17
- package/package.json +4 -4
package/dist/index.es.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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";
|
|
1
|
+
import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, IMPERSONATE_HEADER, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, RebaseClientError as RebaseClientError$1, SCHEMA_VERSION_HEADER, STORAGE_REFERENCE_SCHEME, Vector, hasFieldOperation, isPublicStoragePath, isStorageReference, isUnsupported, parseRelationAggregateSort, parseStorageReference, resolveStorageReferences, sortKeyToString, storageObjectPath, storageReference, toCanonicalOp, unsupportedMethod } from "@rebasepro/types";
|
|
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`.
|
|
@@ -145,21 +165,46 @@ function buildAggregateQueryString(params) {
|
|
|
145
165
|
parts.push(`select=${encodeURIComponent(select)}`);
|
|
146
166
|
if (params.groupBy && params.groupBy.length > 0) parts.push(`groupBy=${encodeURIComponent(params.groupBy.join(","))}`);
|
|
147
167
|
if (params.limit != null) parts.push(`limit=${params.limit}`);
|
|
168
|
+
if (params.offset != null) parts.push(`offset=${params.offset}`);
|
|
148
169
|
if (params.searchString) parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
|
|
149
170
|
if (params.logical) {
|
|
150
171
|
const root = params.logical;
|
|
151
172
|
const serialized = (root.conditions ?? []).map(serializeLogicalCondition).join(",");
|
|
152
173
|
parts.push(`${root.type}=${encodeURIComponent(`(${serialized})`)}`);
|
|
153
174
|
}
|
|
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
|
-
}
|
|
175
|
+
if (params.where) appendWhere(parts, params.where);
|
|
160
176
|
return "?" + parts.join("&");
|
|
161
177
|
}
|
|
162
178
|
/**
|
|
179
|
+
* The 401 codes that mean "the credential on this request is no good", which a
|
|
180
|
+
* refreshed token can fix.
|
|
181
|
+
*
|
|
182
|
+
* The rest of the 401s are answers about something the caller *sent*: a wrong
|
|
183
|
+
* current password (`INVALID_CREDENTIALS`), a mistyped MFA code (`INVALID_CODE`,
|
|
184
|
+
* `CHALLENGE_EXHAUSTED`), a sign-in that needs a second factor
|
|
185
|
+
* (`MFA_REQUIRED`). Refreshing and resending one of those rotated the session
|
|
186
|
+
* for nothing, sent the same wrong answer twice — and an MFA challenge claims
|
|
187
|
+
* an attempt before it judges the code, so every typo cost two of its five.
|
|
188
|
+
*/
|
|
189
|
+
var TOKEN_REFUSAL_CODES = /* @__PURE__ */ new Set([
|
|
190
|
+
"UNAUTHORIZED",
|
|
191
|
+
"INVALID_TOKEN",
|
|
192
|
+
"TOKEN_EXPIRED",
|
|
193
|
+
"SESSION_REVOKED"
|
|
194
|
+
]);
|
|
195
|
+
/**
|
|
196
|
+
* Whether a 401's body says the token was the problem.
|
|
197
|
+
*
|
|
198
|
+
* A 401 with no code at all counts: it is what an older server, a proxy or
|
|
199
|
+
* anything not speaking the envelope sends, and there the token is the
|
|
200
|
+
* likeliest cause — refreshing is what this client always did with it.
|
|
201
|
+
*/
|
|
202
|
+
function isTokenRefusal(body) {
|
|
203
|
+
const error = body?.error;
|
|
204
|
+
const code = error && typeof error === "object" ? error.code : void 0;
|
|
205
|
+
return typeof code !== "string" || code === "" || TOKEN_REFUSAL_CODES.has(code);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
163
208
|
* The base every request and every caller-built URL resolves against.
|
|
164
209
|
*
|
|
165
210
|
* `baseUrl` is optional because the common production shape is a Rebase
|
|
@@ -248,15 +293,18 @@ function createTransport(config, environment) {
|
|
|
248
293
|
}
|
|
249
294
|
const defaultHeaders = {
|
|
250
295
|
...config.headers ?? {},
|
|
251
|
-
...config.schemaVersion ? { [SCHEMA_VERSION_HEADER]: config.schemaVersion } : {}
|
|
296
|
+
...config.schemaVersion ? { [SCHEMA_VERSION_HEADER]: config.schemaVersion } : {},
|
|
297
|
+
...config.impersonate !== void 0 ? { [IMPERSONATE_HEADER]: config.impersonate } : {}
|
|
252
298
|
};
|
|
253
299
|
function getHeaders(activeToken, init) {
|
|
254
|
-
|
|
300
|
+
const headers = {
|
|
255
301
|
"Content-Type": "application/json",
|
|
256
302
|
...defaultHeaders,
|
|
257
303
|
...activeToken ? { Authorization: `Bearer ${activeToken}` } : {},
|
|
258
304
|
...init?.headers || {}
|
|
259
305
|
};
|
|
306
|
+
if (init?.body instanceof FormData) delete headers["Content-Type"];
|
|
307
|
+
return headers;
|
|
260
308
|
}
|
|
261
309
|
/**
|
|
262
310
|
* The refusal for a success status carrying a body this client cannot read.
|
|
@@ -330,7 +378,6 @@ function createTransport(config, environment) {
|
|
|
330
378
|
} catch (e) {}
|
|
331
379
|
warnIfAnonymousServerClient(activeToken);
|
|
332
380
|
const headers = getHeaders(activeToken, init);
|
|
333
|
-
if (init?.body instanceof FormData) delete headers["Content-Type"];
|
|
334
381
|
const res = await fetchFn(url, {
|
|
335
382
|
...init,
|
|
336
383
|
headers
|
|
@@ -363,7 +410,7 @@ function createTransport(config, environment) {
|
|
|
363
410
|
} catch (e) {
|
|
364
411
|
unreadableBody = true;
|
|
365
412
|
}
|
|
366
|
-
if (res.status === 401 && onUnauthorizedHandler) {
|
|
413
|
+
if (res.status === 401 && onUnauthorizedHandler && isTokenRefusal(body)) {
|
|
367
414
|
if (await onUnauthorizedHandler()) {
|
|
368
415
|
let retryToken = token;
|
|
369
416
|
if (tokenGetter) try {
|
|
@@ -450,6 +497,7 @@ function mapRawUser(raw) {
|
|
|
450
497
|
providerId: raw.providerId ?? "password",
|
|
451
498
|
isAnonymous: raw.isAnonymous ?? false,
|
|
452
499
|
emailVerified: raw.emailVerified,
|
|
500
|
+
...raw.pendingEmail !== void 0 ? { pendingEmail: raw.pendingEmail } : {},
|
|
453
501
|
roles: raw.roles,
|
|
454
502
|
metadata: raw.metadata
|
|
455
503
|
};
|
|
@@ -505,9 +553,22 @@ function createAuth(transport, options) {
|
|
|
505
553
|
const REFRESH_RETRY_BASE_MS = 1e3;
|
|
506
554
|
const REFRESH_RETRY_MAX_MS = 3e4;
|
|
507
555
|
let currentSession = null;
|
|
556
|
+
/**
|
|
557
|
+
* Which sign-in `currentSession` belongs to. Bumped whenever the session
|
|
558
|
+
* is replaced by anything other than its own refresh — a sign-in, a
|
|
559
|
+
* sign-out, a revocation, a local abandon — so a refresh already in flight
|
|
560
|
+
* can tell, when its answer lands, that the session it was refreshing has
|
|
561
|
+
* ended. Its answer is then dropped: adopting it put a signed-out session
|
|
562
|
+
* back in memory, in storage and on the transport, announced
|
|
563
|
+
* TOKEN_REFRESHED after SIGNED_OUT, and overwrote whoever had signed in
|
|
564
|
+
* since.
|
|
565
|
+
*/
|
|
566
|
+
let sessionEpoch = 0;
|
|
508
567
|
const listeners = /* @__PURE__ */ new Set();
|
|
509
568
|
let refreshTimeout = null;
|
|
510
569
|
let inFlightRefresh = null;
|
|
570
|
+
/** The `sessionEpoch` the in-flight refresh was started for. */
|
|
571
|
+
let inFlightEpoch = -1;
|
|
511
572
|
let resolveInitialized;
|
|
512
573
|
const isInitialized = new Promise((resolve) => {
|
|
513
574
|
resolveInitialized = resolve;
|
|
@@ -518,13 +579,56 @@ function createAuth(transport, options) {
|
|
|
518
579
|
function getFetch() {
|
|
519
580
|
return transport.fetchFn || globalThis.fetch;
|
|
520
581
|
}
|
|
521
|
-
|
|
522
|
-
|
|
582
|
+
/**
|
|
583
|
+
* The body of a request that presents the refresh token — `/refresh` and
|
|
584
|
+
* `/logout`.
|
|
585
|
+
*
|
|
586
|
+
* Empty in cookie mode. The token is in an HttpOnly cookie the browser
|
|
587
|
+
* attaches, and this client never holds it: what it holds is the `""` the
|
|
588
|
+
* server answers in its place. Sending that back was sending a blank token
|
|
589
|
+
* beside the cookie, and a server that refused the blank (0.10.0 through
|
|
590
|
+
* 0.23, with 400 INVALID_INPUT) never read the cookie — so no tab that outlived
|
|
591
|
+
* its access token could renew it, while a reload, which had no session in
|
|
592
|
+
* memory and so sent no field, could.
|
|
593
|
+
*/
|
|
594
|
+
function refreshTokenBody() {
|
|
595
|
+
return JSON.stringify(authFlowMode === "cookie" ? {} : { refreshToken: currentSession?.refreshToken });
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* The refresh token a new session keeps, given the one an answer carried.
|
|
599
|
+
*
|
|
600
|
+
* In cookie mode, none: the server answers `refreshToken: ""` because the
|
|
601
|
+
* cookie holds it, and that `""` is "not held here", not a token to carry
|
|
602
|
+
* forward or present. Otherwise the answer's token, or — from a server
|
|
603
|
+
* that omits it on a rotation it did not make — the one already held.
|
|
604
|
+
*/
|
|
605
|
+
function heldRefreshToken(received) {
|
|
606
|
+
if (authFlowMode === "cookie") return "";
|
|
607
|
+
return received || currentSession?.refreshToken || "";
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Whether the server refused a refresh as malformed: a 400, which says the
|
|
611
|
+
* request this client built is not one that server accepts.
|
|
612
|
+
*
|
|
613
|
+
* That is a client/server contract mismatch, not a blip. The identical
|
|
614
|
+
* request is refused identically however often it is sent, so retrying it
|
|
615
|
+
* on a backoff only hides the error behind six identical ones — which is
|
|
616
|
+
* how a cookie-mode tab, refused this way after every wake from sleep,
|
|
617
|
+
* quietly ended on its login screen.
|
|
618
|
+
*/
|
|
619
|
+
function isMalformedRefreshError(err) {
|
|
620
|
+
return err instanceof RebaseApiError && err.status === 400;
|
|
621
|
+
}
|
|
622
|
+
function apiError(status, body, statusText) {
|
|
623
|
+
return new RebaseApiError(body?.error?.message || body?.message || statusText, {
|
|
523
624
|
status,
|
|
524
625
|
code: body?.error?.code || body?.code,
|
|
525
626
|
details: body?.error?.details || body?.details
|
|
526
627
|
});
|
|
527
628
|
}
|
|
629
|
+
function throwApiError(status, body, statusText) {
|
|
630
|
+
throw apiError(status, body, statusText);
|
|
631
|
+
}
|
|
528
632
|
function emit(event, session) {
|
|
529
633
|
for (const fn of listeners) try {
|
|
530
634
|
fn(event, session);
|
|
@@ -569,10 +673,25 @@ function createAuth(transport, options) {
|
|
|
569
673
|
* may be stale precisely because a sibling tab holds a live one, and
|
|
570
674
|
* logging out on its behalf would turn one tab's bad luck into everybody
|
|
571
675
|
* being signed out — the exact failure this work exists to remove.
|
|
676
|
+
*
|
|
677
|
+
* Storage is left alone when it no longer holds the session being
|
|
678
|
+
* abandoned. It is shared with every other tab of the app, so a different
|
|
679
|
+
* session there is a sibling's live one — a rotation this tab missed, or
|
|
680
|
+
* another sign-in — and deleting it signed that tab out on its next load.
|
|
572
681
|
*/
|
|
573
682
|
function abandonSessionLocally() {
|
|
683
|
+
endSession(storageHoldsCurrentSession());
|
|
684
|
+
}
|
|
685
|
+
/** Whether storage holds this client's session — or no session at all. */
|
|
686
|
+
function storageHoldsCurrentSession() {
|
|
687
|
+
const persisted = loadStoredSession();
|
|
688
|
+
return !persisted || !currentSession || persisted.refreshToken === currentSession.refreshToken;
|
|
689
|
+
}
|
|
690
|
+
/** Drop the session from memory, storage and the transport, and say so. */
|
|
691
|
+
function endSession(clearStorage = true) {
|
|
692
|
+
sessionEpoch++;
|
|
574
693
|
currentSession = null;
|
|
575
|
-
clearStoredSession();
|
|
694
|
+
if (clearStorage) clearStoredSession();
|
|
576
695
|
if (refreshTimeout) {
|
|
577
696
|
clearTimeout(refreshTimeout);
|
|
578
697
|
refreshTimeout = null;
|
|
@@ -600,32 +719,40 @@ function createAuth(transport, options) {
|
|
|
600
719
|
abandonSessionLocally();
|
|
601
720
|
return false;
|
|
602
721
|
}
|
|
722
|
+
const epoch = sessionEpoch;
|
|
603
723
|
try {
|
|
604
724
|
await refreshSession();
|
|
605
|
-
return
|
|
725
|
+
return epoch === sessionEpoch;
|
|
606
726
|
} catch (err) {
|
|
607
|
-
if (isFatalRefreshError(err)) abandonSessionLocally();
|
|
727
|
+
if (epoch === sessionEpoch && isFatalRefreshError(err)) abandonSessionLocally();
|
|
608
728
|
return false;
|
|
609
729
|
}
|
|
610
730
|
}
|
|
611
731
|
async function attemptScheduledRefresh(attempt) {
|
|
732
|
+
const epoch = sessionEpoch;
|
|
612
733
|
try {
|
|
613
734
|
await refreshSession();
|
|
614
735
|
} catch (err) {
|
|
736
|
+
if (epoch !== sessionEpoch) return;
|
|
615
737
|
if (isFatalRefreshError(err)) {
|
|
616
738
|
abandonSessionLocally();
|
|
617
739
|
return;
|
|
618
740
|
}
|
|
741
|
+
if (isMalformedRefreshError(err)) return;
|
|
619
742
|
if (attempt >= MAX_REFRESH_RETRIES) {
|
|
620
|
-
abandonSessionLocally();
|
|
743
|
+
if (err instanceof RebaseApiError && (err.status === 401 || err.status === 403)) abandonSessionLocally();
|
|
621
744
|
return;
|
|
622
745
|
}
|
|
623
|
-
|
|
624
|
-
refreshTimeout = setTimeout(() => {
|
|
625
|
-
attemptScheduledRefresh(attempt + 1);
|
|
626
|
-
}, backoff);
|
|
746
|
+
scheduleRetry(attempt);
|
|
627
747
|
}
|
|
628
748
|
}
|
|
749
|
+
/** Try the scheduled refresh again after a backoff: 1s, 2s, 4s, … capped. */
|
|
750
|
+
function scheduleRetry(attempt) {
|
|
751
|
+
const backoff = Math.min(REFRESH_RETRY_BASE_MS * 2 ** attempt, REFRESH_RETRY_MAX_MS);
|
|
752
|
+
refreshTimeout = setTimeout(() => {
|
|
753
|
+
attemptScheduledRefresh(attempt + 1);
|
|
754
|
+
}, backoff);
|
|
755
|
+
}
|
|
629
756
|
function scheduleRefresh(expiresAt) {
|
|
630
757
|
if (refreshTimeout) clearTimeout(refreshTimeout);
|
|
631
758
|
if (!autoRefresh) return;
|
|
@@ -666,10 +793,11 @@ function createAuth(transport, options) {
|
|
|
666
793
|
}
|
|
667
794
|
}
|
|
668
795
|
function handleAuthResponse(data, event) {
|
|
796
|
+
sessionEpoch++;
|
|
669
797
|
const user = mapRawUser(data.user);
|
|
670
798
|
const session = {
|
|
671
799
|
accessToken: data.tokens.accessToken,
|
|
672
|
-
refreshToken: data.tokens.refreshToken
|
|
800
|
+
refreshToken: heldRefreshToken(data.tokens.refreshToken),
|
|
673
801
|
expiresAt: data.tokens.accessTokenExpiresAt,
|
|
674
802
|
user
|
|
675
803
|
};
|
|
@@ -714,8 +842,15 @@ function createAuth(transport, options) {
|
|
|
714
842
|
});
|
|
715
843
|
const body = await res.json().catch(() => ({}));
|
|
716
844
|
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
845
|
+
if (body && typeof body === "object" && "confirmationRequired" in body && body.confirmationRequired === true) return {
|
|
846
|
+
confirmationRequired: true,
|
|
847
|
+
user: null,
|
|
848
|
+
accessToken: null,
|
|
849
|
+
refreshToken: null
|
|
850
|
+
};
|
|
717
851
|
const session = handleAuthResponse(body, "SIGNED_IN");
|
|
718
852
|
return {
|
|
853
|
+
confirmationRequired: false,
|
|
719
854
|
user: session.user,
|
|
720
855
|
accessToken: session.accessToken,
|
|
721
856
|
refreshToken: session.refreshToken
|
|
@@ -847,23 +982,17 @@ function createAuth(transport, options) {
|
|
|
847
982
|
});
|
|
848
983
|
}
|
|
849
984
|
async function signOut() {
|
|
985
|
+
sessionEpoch++;
|
|
850
986
|
const fetchFn = getFetch();
|
|
851
987
|
try {
|
|
852
988
|
if (authFlowMode === "cookie" || currentSession?.refreshToken) await fetchFn(authUrl("/logout"), {
|
|
853
989
|
method: "POST",
|
|
854
990
|
headers: { "Content-Type": "application/json" },
|
|
855
|
-
body:
|
|
991
|
+
body: refreshTokenBody(),
|
|
856
992
|
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
857
993
|
});
|
|
858
994
|
} catch (e) {}
|
|
859
|
-
|
|
860
|
-
clearStoredSession();
|
|
861
|
-
if (refreshTimeout) {
|
|
862
|
-
clearTimeout(refreshTimeout);
|
|
863
|
-
refreshTimeout = null;
|
|
864
|
-
}
|
|
865
|
-
transport.setToken(null);
|
|
866
|
-
emit("SIGNED_OUT", null);
|
|
995
|
+
endSession();
|
|
867
996
|
}
|
|
868
997
|
/**
|
|
869
998
|
* Serialise refreshes across TABS, not just within one.
|
|
@@ -899,32 +1028,112 @@ function createAuth(transport, options) {
|
|
|
899
1028
|
}
|
|
900
1029
|
}
|
|
901
1030
|
function refreshSession() {
|
|
902
|
-
if (inFlightRefresh) return inFlightRefresh;
|
|
903
|
-
|
|
904
|
-
inFlightRefresh = null;
|
|
1031
|
+
if (inFlightRefresh && inFlightEpoch === sessionEpoch) return inFlightRefresh;
|
|
1032
|
+
const refresh = withRefreshLock(() => doRefreshSession()).finally(() => {
|
|
1033
|
+
if (inFlightRefresh === refresh) inFlightRefresh = null;
|
|
905
1034
|
});
|
|
906
|
-
|
|
1035
|
+
inFlightRefresh = refresh;
|
|
1036
|
+
inFlightEpoch = sessionEpoch;
|
|
1037
|
+
return refresh;
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* What a refresh answers when its session ended while it was in flight.
|
|
1041
|
+
*
|
|
1042
|
+
* The tokens it received belong to a session this client no longer holds,
|
|
1043
|
+
* so they are dropped, and so is any error it hit: a refusal of the old
|
|
1044
|
+
* session's token says nothing about the new one, and acting on it signed
|
|
1045
|
+
* out whoever had signed in since. The caller gets the session there is
|
|
1046
|
+
* now — or, if there is none, is told so.
|
|
1047
|
+
*/
|
|
1048
|
+
function supersededRefresh() {
|
|
1049
|
+
if (currentSession) return currentSession;
|
|
1050
|
+
throw new RebaseClientError$1("The session was signed out while it was being refreshed.", { code: "NOT_SIGNED_IN" });
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* The session a sibling tab has persisted since this one last looked, when
|
|
1054
|
+
* it continues this one.
|
|
1055
|
+
*
|
|
1056
|
+
* Tabs of one app share `localStorage` but each holds its session in
|
|
1057
|
+
* memory, and a refresh rotates the refresh token. Once one tab has
|
|
1058
|
+
* refreshed, every other tab is holding a token the server has already
|
|
1059
|
+
* superseded: presented after the server's reuse window it is refused
|
|
1060
|
+
* with TOKEN_ALREADY_USED, again on every retry, until the tab gives up
|
|
1061
|
+
* and signs out. Its replacement was in storage all along.
|
|
1062
|
+
*
|
|
1063
|
+
* Only a session of the same user counts. A different user in storage
|
|
1064
|
+
* means another tab signed someone else in, and that is not this tab's
|
|
1065
|
+
* session to take over.
|
|
1066
|
+
*/
|
|
1067
|
+
function siblingSession() {
|
|
1068
|
+
if (!persistSession || authFlowMode === "cookie" || !currentSession) return null;
|
|
1069
|
+
const persisted = loadStoredSession();
|
|
1070
|
+
if (!persisted?.accessToken || !persisted.refreshToken) return null;
|
|
1071
|
+
if (persisted.refreshToken === currentSession.refreshToken) return null;
|
|
1072
|
+
if (!persisted.user?.uid || persisted.user.uid !== currentSession.user?.uid) return null;
|
|
1073
|
+
return persisted;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Take over a sibling's session. Its access token is used as it is while
|
|
1077
|
+
* it has time left — exactly as a session restored on load would be — and
|
|
1078
|
+
* otherwise this answers `null` and the caller refreshes, now with the
|
|
1079
|
+
* sibling's refresh token rather than the superseded one.
|
|
1080
|
+
*/
|
|
1081
|
+
function adoptSibling(sibling) {
|
|
1082
|
+
currentSession = sibling;
|
|
1083
|
+
if (sibling.expiresAt - REFRESH_BUFFER_MS <= Date.now()) return null;
|
|
1084
|
+
transport.setToken(sibling.accessToken);
|
|
1085
|
+
scheduleRefresh(sibling.expiresAt);
|
|
1086
|
+
emit("TOKEN_REFRESHED", sibling);
|
|
1087
|
+
return sibling;
|
|
907
1088
|
}
|
|
908
1089
|
async function doRefreshSession() {
|
|
1090
|
+
const epoch = sessionEpoch;
|
|
1091
|
+
try {
|
|
1092
|
+
const sibling = siblingSession();
|
|
1093
|
+
if (sibling) {
|
|
1094
|
+
const adopted = adoptSibling(sibling);
|
|
1095
|
+
if (adopted) return adopted;
|
|
1096
|
+
}
|
|
1097
|
+
try {
|
|
1098
|
+
return await requestRefresh(epoch);
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
const late = epoch === sessionEpoch && err instanceof RebaseApiError && err.code === "TOKEN_ALREADY_USED" ? siblingSession() : null;
|
|
1101
|
+
if (!late) throw err;
|
|
1102
|
+
return adoptSibling(late) ?? await requestRefresh(epoch);
|
|
1103
|
+
}
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
if (epoch !== sessionEpoch) return supersededRefresh();
|
|
1106
|
+
throw err;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
async function requestRefresh(epoch) {
|
|
909
1110
|
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
1111
|
const res = await getFetch()(authUrl("/refresh"), {
|
|
911
1112
|
method: "POST",
|
|
912
1113
|
headers: { "Content-Type": "application/json" },
|
|
913
|
-
body:
|
|
1114
|
+
body: refreshTokenBody(),
|
|
914
1115
|
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
915
1116
|
});
|
|
916
1117
|
const body = await res.json().catch(() => ({}));
|
|
917
|
-
if (
|
|
1118
|
+
if (epoch !== sessionEpoch) return supersededRefresh();
|
|
1119
|
+
if (!res.ok) {
|
|
1120
|
+
const err = apiError(res.status, body, res.statusText);
|
|
1121
|
+
if (isMalformedRefreshError(err)) console.error(`[rebase] POST ${authPath}/refresh was refused as malformed (${res.status}${err.code ? ` ${err.code}` : ""}): ${err.message}. The client and server disagree on the refresh request, so this session cannot be renewed and retrying will not help. Check that @rebasepro/client and @rebasepro/server are on compatible versions, and that \`authFlowMode\` matches the server's \`cookieAuth\`.`);
|
|
1122
|
+
throw err;
|
|
1123
|
+
}
|
|
918
1124
|
const accessToken = body.tokens.accessToken;
|
|
919
1125
|
transport.setToken(accessToken);
|
|
920
1126
|
let user = currentSession?.user;
|
|
921
1127
|
if (body.user && typeof body.user.uid === "string") user = mapRawUser(body.user);
|
|
922
|
-
else if (!user || !user.uid)
|
|
923
|
-
|
|
924
|
-
|
|
1128
|
+
else if (!user || !user.uid) {
|
|
1129
|
+
try {
|
|
1130
|
+
user = await getUser();
|
|
1131
|
+
} catch {}
|
|
1132
|
+
if (epoch !== sessionEpoch) return supersededRefresh();
|
|
1133
|
+
}
|
|
925
1134
|
const session = {
|
|
926
1135
|
accessToken,
|
|
927
|
-
refreshToken: body.tokens.refreshToken
|
|
1136
|
+
refreshToken: heldRefreshToken(body.tokens.refreshToken),
|
|
928
1137
|
expiresAt: body.tokens.accessTokenExpiresAt,
|
|
929
1138
|
user: user ?? EMPTY_USER
|
|
930
1139
|
};
|
|
@@ -988,14 +1197,36 @@ function createAuth(transport, options) {
|
|
|
988
1197
|
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
989
1198
|
return body;
|
|
990
1199
|
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Change the signed-in account's password. Every other session ends; this
|
|
1202
|
+
* one is replaced by the fresh session the server answers with, so the
|
|
1203
|
+
* client stays signed in.
|
|
1204
|
+
*/
|
|
991
1205
|
async function changePassword(oldPassword, newPassword) {
|
|
992
|
-
|
|
1206
|
+
if (currentSession && currentSession.expiresAt <= Date.now() + 1e4) await refreshSession().catch(() => void 0);
|
|
1207
|
+
const res = await getFetch()(authUrl("/change-password"), {
|
|
993
1208
|
method: "POST",
|
|
1209
|
+
headers: {
|
|
1210
|
+
"Content-Type": "application/json",
|
|
1211
|
+
...currentSession?.accessToken ? { Authorization: `Bearer ${currentSession.accessToken}` } : {}
|
|
1212
|
+
},
|
|
994
1213
|
body: JSON.stringify({
|
|
995
1214
|
oldPassword,
|
|
996
1215
|
newPassword
|
|
997
|
-
})
|
|
1216
|
+
}),
|
|
1217
|
+
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
998
1218
|
});
|
|
1219
|
+
const body = await res.json().catch(() => ({}));
|
|
1220
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
1221
|
+
const result = body;
|
|
1222
|
+
if (result.tokens && result.user) handleAuthResponse({
|
|
1223
|
+
tokens: result.tokens,
|
|
1224
|
+
user: result.user
|
|
1225
|
+
}, "TOKEN_REFRESHED");
|
|
1226
|
+
return {
|
|
1227
|
+
success: result.success,
|
|
1228
|
+
message: result.message
|
|
1229
|
+
};
|
|
999
1230
|
}
|
|
1000
1231
|
/**
|
|
1001
1232
|
* Link an OAuth provider to the **currently signed-in** account.
|
|
@@ -1024,14 +1255,113 @@ function createAuth(transport, options) {
|
|
|
1024
1255
|
async function sendVerificationEmail() {
|
|
1025
1256
|
return transport.request(authPath + "/send-verification", { method: "POST" });
|
|
1026
1257
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1258
|
+
/**
|
|
1259
|
+
* Verify the address with the token from the link.
|
|
1260
|
+
*
|
|
1261
|
+
* The link proves the inbox, not who registered the address, so the
|
|
1262
|
+
* server keeps only what this call also proves: the current session (sent
|
|
1263
|
+
* when this client is signed in as that account) or `password`. An account
|
|
1264
|
+
* carrying a password or identity neither proves is refused with
|
|
1265
|
+
* `PROOF_REQUIRED` and the token is left unspent — ask for the password,
|
|
1266
|
+
* or pass `removeUnproven: true` to verify without it. With `password`,
|
|
1267
|
+
* the call also signs in.
|
|
1268
|
+
*/
|
|
1269
|
+
async function verifyEmail(token, options) {
|
|
1270
|
+
const fetchFn = getFetch();
|
|
1271
|
+
const headers = { "Content-Type": "application/json" };
|
|
1272
|
+
if (currentSession?.accessToken) headers.Authorization = `Bearer ${currentSession.accessToken}`;
|
|
1273
|
+
const res = await fetchFn(authUrl("/verify-email"), {
|
|
1274
|
+
method: "POST",
|
|
1275
|
+
headers,
|
|
1276
|
+
body: JSON.stringify({
|
|
1277
|
+
token,
|
|
1278
|
+
...options
|
|
1279
|
+
}),
|
|
1280
|
+
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
1031
1281
|
});
|
|
1032
1282
|
const body = await res.json().catch(() => ({}));
|
|
1033
1283
|
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
1034
|
-
|
|
1284
|
+
const result = body;
|
|
1285
|
+
const session = result.tokens && result.user ? handleAuthResponse({
|
|
1286
|
+
tokens: result.tokens,
|
|
1287
|
+
user: result.user
|
|
1288
|
+
}, "SIGNED_IN") : null;
|
|
1289
|
+
return {
|
|
1290
|
+
success: result.success,
|
|
1291
|
+
passwordRemoved: result.passwordRemoved,
|
|
1292
|
+
removedProviders: result.removedProviders ?? [],
|
|
1293
|
+
session
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Move the signed-in account to `newEmail`. The server mails a link to the
|
|
1298
|
+
* new address and a notice to the current one; nothing changes until the
|
|
1299
|
+
* link is followed and {@link confirmEmailChange} is called with its
|
|
1300
|
+
* token. A new request replaces the last.
|
|
1301
|
+
*
|
|
1302
|
+
* Rejects with `EMAIL_EXISTS` or `UNDELIVERABLE_ADDRESS` (409),
|
|
1303
|
+
* `EMAIL_UNCHANGED` (400), `AAL2_REQUIRED` when the account has a second
|
|
1304
|
+
* factor this session did not present, and `ANONYMOUS_USER` for a guest.
|
|
1305
|
+
*/
|
|
1306
|
+
async function changeEmail(newEmail) {
|
|
1307
|
+
const data = await transport.request(authPath + "/change-email", {
|
|
1308
|
+
method: "POST",
|
|
1309
|
+
body: JSON.stringify({ newEmail })
|
|
1310
|
+
});
|
|
1311
|
+
if (currentSession) {
|
|
1312
|
+
currentSession = {
|
|
1313
|
+
...currentSession,
|
|
1314
|
+
user: {
|
|
1315
|
+
...currentSession.user,
|
|
1316
|
+
pendingEmail: data.pendingEmail
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
saveSession(currentSession);
|
|
1320
|
+
emit("USER_UPDATED", currentSession);
|
|
1321
|
+
}
|
|
1322
|
+
return {
|
|
1323
|
+
pendingEmail: data.pendingEmail,
|
|
1324
|
+
expiresAt: data.expiresAt
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Follow an address-change link: the token from
|
|
1329
|
+
* `<frontend>/confirm-email-change?token=…`. Needs no session — the link
|
|
1330
|
+
* proves the new inbox — and when this client is signed in as the account
|
|
1331
|
+
* it moved, its user is updated and `USER_UPDATED` is emitted.
|
|
1332
|
+
*
|
|
1333
|
+
* `removedProviders` names the sign-in providers detached because they
|
|
1334
|
+
* vouched for the old address. Rejects with `INVALID_TOKEN` for a spent,
|
|
1335
|
+
* replaced or expired link and `EMAIL_EXISTS` when the address was taken
|
|
1336
|
+
* while the link waited.
|
|
1337
|
+
*/
|
|
1338
|
+
async function confirmEmailChange(token) {
|
|
1339
|
+
const res = await getFetch()(authUrl("/confirm-email-change"), {
|
|
1340
|
+
method: "POST",
|
|
1341
|
+
headers: { "Content-Type": "application/json" },
|
|
1342
|
+
body: JSON.stringify({ token })
|
|
1343
|
+
});
|
|
1344
|
+
const body = await res.json().catch(() => ({}));
|
|
1345
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
1346
|
+
const result = body;
|
|
1347
|
+
if (currentSession && currentSession.user.uid === result.user.uid) {
|
|
1348
|
+
currentSession = {
|
|
1349
|
+
...currentSession,
|
|
1350
|
+
user: {
|
|
1351
|
+
...currentSession.user,
|
|
1352
|
+
email: result.user.email,
|
|
1353
|
+
emailVerified: true,
|
|
1354
|
+
pendingEmail: null
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
saveSession(currentSession);
|
|
1358
|
+
emit("USER_UPDATED", currentSession);
|
|
1359
|
+
}
|
|
1360
|
+
return {
|
|
1361
|
+
uid: result.user.uid,
|
|
1362
|
+
email: result.user.email,
|
|
1363
|
+
removedProviders: result.removedProviders ?? []
|
|
1364
|
+
};
|
|
1035
1365
|
}
|
|
1036
1366
|
async function sendMagicLink(email) {
|
|
1037
1367
|
const res = await getFetch()(authUrl("/magic-link"), {
|
|
@@ -1167,8 +1497,11 @@ function createAuth(transport, options) {
|
|
|
1167
1497
|
* Start enrolling a TOTP factor.
|
|
1168
1498
|
*
|
|
1169
1499
|
* Returns the secret and an `otpauth://` URI to render as a QR code,
|
|
1170
|
-
* plus
|
|
1171
|
-
* and never again
|
|
1500
|
+
* plus — with the account's first factor — ten single-use recovery
|
|
1501
|
+
* codes. **Show the recovery codes once and never again**: only their
|
|
1502
|
+
* hashes are stored. Adding another factor keeps the codes the account
|
|
1503
|
+
* has, and `recoveryCodes` is then `null`; `regenerateRecoveryCodes()`
|
|
1504
|
+
* replaces them.
|
|
1172
1505
|
*
|
|
1173
1506
|
* The factor is not usable until `verify` confirms the user's
|
|
1174
1507
|
* authenticator produced a correct code from it.
|
|
@@ -1197,33 +1530,52 @@ function createAuth(transport, options) {
|
|
|
1197
1530
|
* Remove a factor. Requires an `aal2` session — one that has already
|
|
1198
1531
|
* answered a challenge — so a stolen `aal1` token cannot turn MFA off.
|
|
1199
1532
|
*/
|
|
1533
|
+
/**
|
|
1534
|
+
* Replace the account's recovery codes with ten new ones, shown once.
|
|
1535
|
+
* Needs an `aal2` session. Adding a second factor keeps the codes the
|
|
1536
|
+
* account already has; this is how to get new ones.
|
|
1537
|
+
*/
|
|
1538
|
+
async regenerateRecoveryCodes() {
|
|
1539
|
+
return transport.request(authPath + "/mfa/recovery-codes", { method: "POST" });
|
|
1540
|
+
},
|
|
1200
1541
|
async unenroll(factorId) {
|
|
1201
1542
|
return transport.request(authPath + "/mfa/unenroll", {
|
|
1202
1543
|
method: "DELETE",
|
|
1203
1544
|
body: JSON.stringify({ factorId })
|
|
1204
1545
|
});
|
|
1205
1546
|
},
|
|
1206
|
-
/**
|
|
1207
|
-
|
|
1547
|
+
/**
|
|
1548
|
+
* Open a challenge against a verified factor. Expires in five minutes.
|
|
1549
|
+
*
|
|
1550
|
+
* Pass `mfaToken` to finish a sign-in that was refused with
|
|
1551
|
+
* `MFA_REQUIRED`: it is the `details.mfaToken` of that refusal, and the
|
|
1552
|
+
* challenge is opened with it as this one request's credential. Without
|
|
1553
|
+
* it, the challenge steps up the session this client already holds.
|
|
1554
|
+
*/
|
|
1555
|
+
async challenge(factorId, options) {
|
|
1556
|
+
const payload = JSON.stringify({ factorId });
|
|
1557
|
+
if (options?.mfaToken) return withPendingToken("/mfa/challenge", payload, options.mfaToken);
|
|
1208
1558
|
return transport.request(authPath + "/mfa/challenge", {
|
|
1209
1559
|
method: "POST",
|
|
1210
|
-
body:
|
|
1560
|
+
body: payload
|
|
1211
1561
|
});
|
|
1212
1562
|
},
|
|
1213
1563
|
/**
|
|
1214
1564
|
* Answer a challenge with a TOTP code or a recovery code.
|
|
1215
1565
|
*
|
|
1216
1566
|
* This is where an MFA-enrolled account's real session is minted, at
|
|
1217
|
-
* `aal2` — so
|
|
1218
|
-
*
|
|
1567
|
+
* `aal2` — so this client adopts the returned tokens and announces
|
|
1568
|
+
* `SIGNED_IN`, exactly as any other sign-in does. Pass the same
|
|
1569
|
+
* `mfaToken` the challenge was opened with when finishing a sign-in.
|
|
1219
1570
|
*/
|
|
1220
|
-
async verifyChallenge(challengeId, code) {
|
|
1221
|
-
const
|
|
1571
|
+
async verifyChallenge(challengeId, code, options) {
|
|
1572
|
+
const payload = JSON.stringify({
|
|
1573
|
+
challengeId,
|
|
1574
|
+
code
|
|
1575
|
+
});
|
|
1576
|
+
const session = handleAuthResponse(options?.mfaToken ? await withPendingToken("/mfa/challenge/verify", payload, options.mfaToken) : await transport.request(authPath + "/mfa/challenge/verify", {
|
|
1222
1577
|
method: "POST",
|
|
1223
|
-
body:
|
|
1224
|
-
challengeId,
|
|
1225
|
-
code
|
|
1226
|
-
})
|
|
1578
|
+
body: payload
|
|
1227
1579
|
}), "SIGNED_IN");
|
|
1228
1580
|
return {
|
|
1229
1581
|
user: session.user,
|
|
@@ -1232,6 +1584,31 @@ function createAuth(transport, options) {
|
|
|
1232
1584
|
};
|
|
1233
1585
|
}
|
|
1234
1586
|
};
|
|
1587
|
+
/**
|
|
1588
|
+
* POST to a challenge route with a sign-in's pending token as the credential.
|
|
1589
|
+
*
|
|
1590
|
+
* Straight to `fetch`, like the sign-in routes, and never through the
|
|
1591
|
+
* transport: the token is a credential for these two requests only. Set on
|
|
1592
|
+
* the transport it would go out on every request the app made while the
|
|
1593
|
+
* code step was open — and stay there if the step were abandoned. It would
|
|
1594
|
+
* also displace a session the client already holds, and a refused code
|
|
1595
|
+
* (401 `INVALID_CODE`) would be taken for an expired session and refreshed
|
|
1596
|
+
* and re-sent, spending a second of the challenge's attempts on it.
|
|
1597
|
+
*/
|
|
1598
|
+
async function withPendingToken(endpoint, payload, mfaToken) {
|
|
1599
|
+
const res = await getFetch()(authUrl(endpoint), {
|
|
1600
|
+
method: "POST",
|
|
1601
|
+
headers: {
|
|
1602
|
+
"Content-Type": "application/json",
|
|
1603
|
+
Authorization: `Bearer ${mfaToken}`
|
|
1604
|
+
},
|
|
1605
|
+
body: payload,
|
|
1606
|
+
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
1607
|
+
});
|
|
1608
|
+
const body = await res.json().catch(() => ({}));
|
|
1609
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
1610
|
+
return body;
|
|
1611
|
+
}
|
|
1235
1612
|
async function getSessions() {
|
|
1236
1613
|
return (await transport.request(authPath + "/sessions", { method: "GET" })).sessions;
|
|
1237
1614
|
}
|
|
@@ -1240,14 +1617,7 @@ function createAuth(transport, options) {
|
|
|
1240
1617
|
}
|
|
1241
1618
|
async function revokeAllSessions() {
|
|
1242
1619
|
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);
|
|
1620
|
+
endSession();
|
|
1251
1621
|
return result;
|
|
1252
1622
|
}
|
|
1253
1623
|
async function getAuthConfig() {
|
|
@@ -1275,12 +1645,18 @@ function createAuth(transport, options) {
|
|
|
1275
1645
|
resolveInitialized();
|
|
1276
1646
|
} else if (authFlowMode === "cookie" || stored.refreshToken) {
|
|
1277
1647
|
currentSession = stored;
|
|
1648
|
+
const epoch = sessionEpoch;
|
|
1278
1649
|
refreshSession().then(() => {
|
|
1279
1650
|
resolveInitialized();
|
|
1280
|
-
}).catch(() => {
|
|
1281
|
-
currentSession
|
|
1282
|
-
|
|
1283
|
-
|
|
1651
|
+
}).catch((err) => {
|
|
1652
|
+
if (epoch === sessionEpoch && currentSession) if (isFatalRefreshError(err)) {
|
|
1653
|
+
if (storageHoldsCurrentSession()) clearStoredSession();
|
|
1654
|
+
currentSession = null;
|
|
1655
|
+
transport.setToken(null);
|
|
1656
|
+
} else {
|
|
1657
|
+
transport.setToken(currentSession.accessToken);
|
|
1658
|
+
if (autoRefresh && !isMalformedRefreshError(err)) scheduleRetry(0);
|
|
1659
|
+
}
|
|
1284
1660
|
resolveInitialized();
|
|
1285
1661
|
});
|
|
1286
1662
|
} else resolveInitialized();
|
|
@@ -1323,6 +1699,8 @@ function createAuth(transport, options) {
|
|
|
1323
1699
|
linkProvider,
|
|
1324
1700
|
sendVerificationEmail,
|
|
1325
1701
|
verifyEmail,
|
|
1702
|
+
changeEmail,
|
|
1703
|
+
confirmEmailChange,
|
|
1326
1704
|
sendMagicLink,
|
|
1327
1705
|
verifyMagicLink,
|
|
1328
1706
|
sendEmailOtp,
|
|
@@ -1378,8 +1756,14 @@ function createCookieStorage(options = {}) {
|
|
|
1378
1756
|
//#region src/admin.ts
|
|
1379
1757
|
function createAdmin(transport, options) {
|
|
1380
1758
|
const adminPath = (options || {}).adminPath || "/admin";
|
|
1381
|
-
|
|
1382
|
-
|
|
1759
|
+
/**
|
|
1760
|
+
* One page of users — the route's default is 25 — with the `total` that
|
|
1761
|
+
* says whether there are more. Takes the same window as
|
|
1762
|
+
* {@link listUsersPaginated}; without one, it used to hand back the first
|
|
1763
|
+
* page typed as the whole list.
|
|
1764
|
+
*/
|
|
1765
|
+
async function listUsers(options) {
|
|
1766
|
+
return listUsersPaginated(options);
|
|
1383
1767
|
}
|
|
1384
1768
|
async function listUsersPaginated(options) {
|
|
1385
1769
|
const params = new URLSearchParams();
|
|
@@ -1400,6 +1784,10 @@ function createAdmin(transport, options) {
|
|
|
1400
1784
|
body: JSON.stringify(data)
|
|
1401
1785
|
});
|
|
1402
1786
|
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Change an account. `disabled: true` switches it off — no sign-in, no
|
|
1789
|
+
* refresh, and every token it holds refused at once — without deleting it.
|
|
1790
|
+
*/
|
|
1403
1791
|
async function updateUser(userId, data) {
|
|
1404
1792
|
return transport.request(adminPath + "/users/" + encodeURIComponent(userId), {
|
|
1405
1793
|
method: "PUT",
|
|
@@ -1409,6 +1797,14 @@ function createAdmin(transport, options) {
|
|
|
1409
1797
|
async function deleteUser(userId) {
|
|
1410
1798
|
return transport.request(adminPath + "/users/" + encodeURIComponent(userId), { method: "DELETE" });
|
|
1411
1799
|
}
|
|
1800
|
+
/**
|
|
1801
|
+
* Remove every second factor and recovery code the account has, and end
|
|
1802
|
+
* its sessions — for someone who lost their authenticator and their
|
|
1803
|
+
* codes. Needs `users:write`.
|
|
1804
|
+
*/
|
|
1805
|
+
async function resetMfa(userId) {
|
|
1806
|
+
return transport.request(adminPath + "/users/" + encodeURIComponent(userId) + "/mfa", { method: "DELETE" });
|
|
1807
|
+
}
|
|
1412
1808
|
async function resetPassword(userId, options) {
|
|
1413
1809
|
return transport.request(adminPath + "/users/" + encodeURIComponent(userId) + "/reset-password", {
|
|
1414
1810
|
method: "POST",
|
|
@@ -1428,6 +1824,7 @@ function createAdmin(transport, options) {
|
|
|
1428
1824
|
createUser,
|
|
1429
1825
|
updateUser,
|
|
1430
1826
|
deleteUser,
|
|
1827
|
+
resetMfa,
|
|
1431
1828
|
resetPassword,
|
|
1432
1829
|
listRoles,
|
|
1433
1830
|
bootstrap
|
|
@@ -1474,17 +1871,24 @@ function createBackups(transport, options) {
|
|
|
1474
1871
|
return transport.request(backupsPath, { method: "GET" });
|
|
1475
1872
|
}
|
|
1476
1873
|
/**
|
|
1477
|
-
* Download a backup's bytes.
|
|
1478
|
-
*
|
|
1874
|
+
* Download a backup's bytes. Not through the JSON `request()`, since the
|
|
1875
|
+
* answer is an octet-stream to hand back as a Blob — but through the
|
|
1876
|
+
* transport's own `fetch` and headers all the same. It used the global
|
|
1877
|
+
* `fetch` and a bare `Authorization` header, so a client configured with
|
|
1878
|
+
* its own `fetch` or with default headers (a gateway key, the schema
|
|
1879
|
+
* version) had every call but this one go where it was told.
|
|
1479
1880
|
*/
|
|
1480
1881
|
async function download(key) {
|
|
1481
|
-
const token = await transport.resolveToken();
|
|
1482
1882
|
const url = `${transport.baseUrl}${transport.apiPath}${backupsPath}/download?key=${encodeURIComponent(key)}`;
|
|
1483
|
-
const
|
|
1883
|
+
const token = await transport.resolveToken();
|
|
1884
|
+
const headers = transport.getHeaders();
|
|
1885
|
+
delete headers["Content-Type"];
|
|
1886
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
1887
|
+
const res = await transport.fetchFn(url, {
|
|
1484
1888
|
method: "GET",
|
|
1485
|
-
headers
|
|
1889
|
+
headers
|
|
1486
1890
|
});
|
|
1487
|
-
if (!res.ok) throw new
|
|
1891
|
+
if (!res.ok) throw new RebaseApiError$1(`Failed to download backup (${res.status})`, { status: res.status });
|
|
1488
1892
|
return res.blob();
|
|
1489
1893
|
}
|
|
1490
1894
|
return {
|
|
@@ -1495,7 +1899,8 @@ function createBackups(transport, options) {
|
|
|
1495
1899
|
//#endregion
|
|
1496
1900
|
//#region src/api-keys.ts
|
|
1497
1901
|
/**
|
|
1498
|
-
* Creates a client for
|
|
1902
|
+
* Creates a client for the project's service keys, via the admin routes.
|
|
1903
|
+
* Needs `keys:read` / `keys:write`.
|
|
1499
1904
|
*
|
|
1500
1905
|
* @param transport - The shared HTTP transport created by `createTransport`.
|
|
1501
1906
|
* @param options - Optional overrides (e.g. a custom base path).
|
|
@@ -1536,6 +1941,43 @@ function createApiKeys(transport, options) {
|
|
|
1536
1941
|
revokeKey
|
|
1537
1942
|
};
|
|
1538
1943
|
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Creates a client for the signed-in account's own API keys — each acts as the
|
|
1946
|
+
* account and holds no scope it does not. Answers `PERSONAL_KEYS_DISABLED`
|
|
1947
|
+
* unless the backend sets `personalKeys: true` on the users collection.
|
|
1948
|
+
*/
|
|
1949
|
+
function createPersonalKeys(transport, options) {
|
|
1950
|
+
const keysPath = options?.personalKeysPath || "/auth/keys";
|
|
1951
|
+
const scopesPath = options?.scopesPath || "/auth/scopes";
|
|
1952
|
+
/** List this account's keys (masked). */
|
|
1953
|
+
async function listKeys() {
|
|
1954
|
+
return transport.request(keysPath, { method: "GET" });
|
|
1955
|
+
}
|
|
1956
|
+
/** Create a key for this account. The full secret is included in the response. */
|
|
1957
|
+
async function createKey(data) {
|
|
1958
|
+
return transport.request(keysPath, {
|
|
1959
|
+
method: "POST",
|
|
1960
|
+
body: JSON.stringify(data)
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
/** Revoke one of this account's keys. */
|
|
1964
|
+
async function revokeKey(id) {
|
|
1965
|
+
return transport.request(keysPath + "/" + encodeURIComponent(id), { method: "DELETE" });
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* Every scope the backend knows, described, and the ones the caller holds —
|
|
1969
|
+
* what a screen offering scopes for a key needs.
|
|
1970
|
+
*/
|
|
1971
|
+
async function listScopes() {
|
|
1972
|
+
return transport.request(scopesPath, { method: "GET" });
|
|
1973
|
+
}
|
|
1974
|
+
return {
|
|
1975
|
+
listKeys,
|
|
1976
|
+
createKey,
|
|
1977
|
+
revokeKey,
|
|
1978
|
+
listScopes
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1539
1981
|
//#endregion
|
|
1540
1982
|
//#region src/sdk_query_builder.ts
|
|
1541
1983
|
/**
|
|
@@ -1816,6 +2258,28 @@ var SDKQueryBuilder = class {
|
|
|
1816
2258
|
*/
|
|
1817
2259
|
var NO_SOCKET = "Listen is only available when RebaseClient is configured with a websocketUrl, and not when it was created with realtime: false.";
|
|
1818
2260
|
/**
|
|
2261
|
+
* Why a live query cannot be opened for these parameters, or `undefined` when
|
|
2262
|
+
* it can.
|
|
2263
|
+
*
|
|
2264
|
+
* A `listen()` is the same query as the `find()` beside it, so what `find()`
|
|
2265
|
+
* refuses it refuses too. The socket would not: a frame is JSON, where an
|
|
2266
|
+
* `undefined` filter value becomes `null`, so `["!=", uid]` with `uid` unset
|
|
2267
|
+
* subscribed to every row whose column is NOT NULL — an ownership filter
|
|
2268
|
+
* widened instead of refused. And `after` names a place in one run of the
|
|
2269
|
+
* query, which a subscription re-runs on every write; the server keeps no
|
|
2270
|
+
* cursor for one, so it was dropped, and page one streamed to a caller who had
|
|
2271
|
+
* asked for page two.
|
|
2272
|
+
*/
|
|
2273
|
+
function listenRefusal(params) {
|
|
2274
|
+
if (params?.where) try {
|
|
2275
|
+
assertNoUndefinedFilterValues(params.where);
|
|
2276
|
+
} catch (error) {
|
|
2277
|
+
if (error instanceof RebaseClientError$1) return error;
|
|
2278
|
+
throw error;
|
|
2279
|
+
}
|
|
2280
|
+
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" });
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
1819
2283
|
* Counts currently in flight, **per client**, keyed by the exact request they
|
|
1820
2284
|
* issue. Entries live only for the duration of the request — see `count()` for
|
|
1821
2285
|
* why they exist at all.
|
|
@@ -1953,14 +2417,27 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1953
2417
|
* One statement server-side (`INSERT ... ON CONFLICT DO UPDATE`), so
|
|
1954
2418
|
* unlike a `findById` followed by `create`-or-`update` it cannot lose
|
|
1955
2419
|
* the race between the two.
|
|
2420
|
+
*
|
|
2421
|
+
* Sent as a one-row bulk write with `upsert: true`, which is what the
|
|
2422
|
+
* in-process accessor and the offline queue already send. It used to be
|
|
2423
|
+
* `POST /data/<slug>` with `?on_conflict=` only when a target was
|
|
2424
|
+
* named — and the route reads that POST without the parameter as the
|
|
2425
|
+
* plain insert a `create` is, so the documented default ("on the
|
|
2426
|
+
* primary key") never happened online: re-upserting an existing row
|
|
2427
|
+
* answered 409, while the same call succeeded offline and in a
|
|
2428
|
+
* function.
|
|
1956
2429
|
*/
|
|
1957
2430
|
async upsert(data, options) {
|
|
1958
|
-
const
|
|
1959
|
-
return await transport.request(`${basePath}${query}`, {
|
|
2431
|
+
const raw = await transport.request(`${basePath}/bulk`, {
|
|
1960
2432
|
method: "POST",
|
|
1961
|
-
body: JSON.stringify(
|
|
2433
|
+
body: JSON.stringify({
|
|
2434
|
+
rows: [data],
|
|
2435
|
+
upsert: true,
|
|
2436
|
+
...options?.onConflict?.length ? { onConflict: options.onConflict } : {}
|
|
2437
|
+
}),
|
|
1962
2438
|
...writeHeaders(options)
|
|
1963
2439
|
});
|
|
2440
|
+
return options?.returning === false ? void 0 : raw?.data?.[0];
|
|
1964
2441
|
},
|
|
1965
2442
|
async createMany(data, options) {
|
|
1966
2443
|
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
@@ -2067,10 +2544,20 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
2067
2544
|
partial: false
|
|
2068
2545
|
});
|
|
2069
2546
|
};
|
|
2547
|
+
const wantsLive = options?.realtime !== false && !isUnsupported(client.listen);
|
|
2548
|
+
const refusal = wantsLive ? listenRefusal(params) : void 0;
|
|
2549
|
+
if (refusal) {
|
|
2550
|
+
queueMicrotask(() => {
|
|
2551
|
+
if (!closed) onError?.(refusal);
|
|
2552
|
+
});
|
|
2553
|
+
return () => {
|
|
2554
|
+
closed = true;
|
|
2555
|
+
};
|
|
2556
|
+
}
|
|
2070
2557
|
client.find(params).then((result) => deliver(result, false)).catch((error) => {
|
|
2071
2558
|
if (!closed) onError?.(error);
|
|
2072
2559
|
});
|
|
2073
|
-
const live =
|
|
2560
|
+
const live = wantsLive ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
|
|
2074
2561
|
return () => {
|
|
2075
2562
|
closed = true;
|
|
2076
2563
|
live?.();
|
|
@@ -2144,7 +2631,13 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
2144
2631
|
*/
|
|
2145
2632
|
async aggregate(params) {
|
|
2146
2633
|
const qs = buildAggregateQueryString(params);
|
|
2147
|
-
|
|
2634
|
+
const raw = await transport.request(`${basePath}/aggregate${qs}`, { method: "GET" });
|
|
2635
|
+
const rows = raw.data || [];
|
|
2636
|
+
if (raw.meta) Object.defineProperty(rows, "meta", {
|
|
2637
|
+
value: raw.meta,
|
|
2638
|
+
enumerable: false
|
|
2639
|
+
});
|
|
2640
|
+
return rows;
|
|
2148
2641
|
},
|
|
2149
2642
|
listen: unsupportedMethod(NO_SOCKET),
|
|
2150
2643
|
listenById: unsupportedMethod(NO_SOCKET)
|
|
@@ -2152,6 +2645,15 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
2152
2645
|
if (ws) {
|
|
2153
2646
|
client.listen = (params, onUpdate, onError) => {
|
|
2154
2647
|
let active = true;
|
|
2648
|
+
const refusal = listenRefusal(params);
|
|
2649
|
+
if (refusal) {
|
|
2650
|
+
queueMicrotask(() => {
|
|
2651
|
+
if (active) onError?.(refusal);
|
|
2652
|
+
});
|
|
2653
|
+
return () => {
|
|
2654
|
+
active = false;
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2155
2657
|
let lastUpdateId = 0;
|
|
2156
2658
|
let lastKnownTotal;
|
|
2157
2659
|
const window = resolveFindWindow(params);
|
|
@@ -2301,7 +2803,10 @@ function createStorage(transport, storageId) {
|
|
|
2301
2803
|
formData.append("property", context.property);
|
|
2302
2804
|
}
|
|
2303
2805
|
let effectiveKey = key;
|
|
2304
|
-
if (isPublic
|
|
2806
|
+
if (isPublic) {
|
|
2807
|
+
effectiveKey ||= file instanceof File && file.name ? file.name : "blob";
|
|
2808
|
+
if (!isPublicStoragePath(effectiveKey)) effectiveKey = `${PUBLIC_STORAGE_PREFIX}${effectiveKey.replace(/^\/+/, "")}`;
|
|
2809
|
+
}
|
|
2305
2810
|
if (effectiveKey) formData.append("key", effectiveKey);
|
|
2306
2811
|
if (bucket) formData.append("bucket", bucket);
|
|
2307
2812
|
if (storageId) formData.append("storageId", storageId);
|
|
@@ -2321,9 +2826,7 @@ function createStorage(transport, storageId) {
|
|
|
2321
2826
|
if (!cachedEntry.expiresAt || cachedEntry.expiresAt > Date.now()) return cachedEntry.config;
|
|
2322
2827
|
urlsCache.delete(cacheKey);
|
|
2323
2828
|
}
|
|
2324
|
-
|
|
2325
|
-
if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://") || filePath.startsWith("gs://"))) filePath = filePath.substring(filePath.indexOf("://") + 3);
|
|
2326
|
-
if (bucket && filePath && !filePath.startsWith(bucket)) filePath = `${bucket}/${filePath}`;
|
|
2829
|
+
const filePath = storageObjectPath(keyOrUrl, bucket);
|
|
2327
2830
|
if (!filePath || filePath.trim() === "" || filePath === "/") return {
|
|
2328
2831
|
url: null,
|
|
2329
2832
|
fileNotFound: true
|
|
@@ -2374,9 +2877,7 @@ function createStorage(transport, storageId) {
|
|
|
2374
2877
|
return new File([blob], fileName, { type: blob.type });
|
|
2375
2878
|
}
|
|
2376
2879
|
async function deleteObject(key, bucket) {
|
|
2377
|
-
|
|
2378
|
-
if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://") || filePath.startsWith("gs://"))) filePath = filePath.substring(filePath.indexOf("://") + 3);
|
|
2379
|
-
if (bucket && filePath && !filePath.startsWith(bucket)) filePath = `${bucket}/${filePath}`;
|
|
2880
|
+
const filePath = storageObjectPath(key, bucket);
|
|
2380
2881
|
if (!filePath || filePath.trim() === "" || filePath === "/") return;
|
|
2381
2882
|
try {
|
|
2382
2883
|
await transport.request(withStorageId(`/storage/file/${encodeStorageKey(filePath)}`), { method: "DELETE" });
|
|
@@ -2459,6 +2960,87 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
|
|
|
2459
2960
|
}
|
|
2460
2961
|
};
|
|
2461
2962
|
//#endregion
|
|
2963
|
+
//#region src/sql-script.ts
|
|
2964
|
+
/**
|
|
2965
|
+
* Read an `EXECUTE_SQL_SUCCESS` payload for a script run as a
|
|
2966
|
+
* {@link SqlScriptResult}, checking every field rather than trusting the frame.
|
|
2967
|
+
*
|
|
2968
|
+
* A server from before scripts existed answers `{ result }` alone, with parsed
|
|
2969
|
+
* values. Its rows are kept, as text, and no column is said to come from
|
|
2970
|
+
* anywhere — the console edits no cell of a result it cannot trace.
|
|
2971
|
+
*/
|
|
2972
|
+
function readSqlScriptResult(payload) {
|
|
2973
|
+
const record = asRecord(payload);
|
|
2974
|
+
const rows = Array.isArray(record?.result) ? record.result.map(asRecord).filter(isDefined).map(textRow) : [];
|
|
2975
|
+
const result = {
|
|
2976
|
+
rows,
|
|
2977
|
+
columns: Array.isArray(record?.columns) ? record.columns.map(readColumn).filter(isDefined) : Object.keys(rows[0] ?? {}).map((name) => ({ name })),
|
|
2978
|
+
tables: Array.isArray(record?.tables) ? record.tables.map(readTable).filter(isDefined) : [],
|
|
2979
|
+
notices: Array.isArray(record?.notices) ? record.notices.map(readNotice).filter(isDefined) : []
|
|
2980
|
+
};
|
|
2981
|
+
if (typeof record?.command === "string") result.command = record.command;
|
|
2982
|
+
if (typeof record?.rowCount === "number") result.rowCount = record.rowCount;
|
|
2983
|
+
if (typeof record?.effectiveRole === "string") result.effectiveRole = record.effectiveRole;
|
|
2984
|
+
return result;
|
|
2985
|
+
}
|
|
2986
|
+
function isDefined(value) {
|
|
2987
|
+
return value !== void 0;
|
|
2988
|
+
}
|
|
2989
|
+
function asRecord(value) {
|
|
2990
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : void 0;
|
|
2991
|
+
}
|
|
2992
|
+
function textRow(row) {
|
|
2993
|
+
return Object.fromEntries(Object.entries(row).map(([key, value]) => [key, asText(value)]));
|
|
2994
|
+
}
|
|
2995
|
+
function asText(value) {
|
|
2996
|
+
if (value === null || value === void 0) return null;
|
|
2997
|
+
if (typeof value === "string") return value;
|
|
2998
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
2999
|
+
return String(value);
|
|
3000
|
+
}
|
|
3001
|
+
function readColumn(value) {
|
|
3002
|
+
const record = asRecord(value);
|
|
3003
|
+
if (typeof record?.name !== "string") return void 0;
|
|
3004
|
+
const column = { name: record.name };
|
|
3005
|
+
if (typeof record.type === "string") column.type = record.type;
|
|
3006
|
+
const source = asRecord(record.source);
|
|
3007
|
+
if (typeof source?.schema === "string" && typeof source.table === "string" && typeof source.column === "string") column.source = {
|
|
3008
|
+
schema: source.schema,
|
|
3009
|
+
table: source.table,
|
|
3010
|
+
column: source.column
|
|
3011
|
+
};
|
|
3012
|
+
return column;
|
|
3013
|
+
}
|
|
3014
|
+
var TABLE_KINDS = [
|
|
3015
|
+
"table",
|
|
3016
|
+
"partitioned table",
|
|
3017
|
+
"view",
|
|
3018
|
+
"materialized view",
|
|
3019
|
+
"foreign table",
|
|
3020
|
+
"other"
|
|
3021
|
+
];
|
|
3022
|
+
function readTable(value) {
|
|
3023
|
+
const record = asRecord(value);
|
|
3024
|
+
if (typeof record?.schema !== "string" || typeof record.table !== "string") return void 0;
|
|
3025
|
+
const kind = TABLE_KINDS.find((candidate) => candidate === record.kind) ?? "other";
|
|
3026
|
+
const primaryKey = Array.isArray(record.primaryKey) && record.primaryKey.every((column) => typeof column === "string") ? record.primaryKey : [];
|
|
3027
|
+
return {
|
|
3028
|
+
schema: record.schema,
|
|
3029
|
+
table: record.table,
|
|
3030
|
+
kind,
|
|
3031
|
+
primaryKey,
|
|
3032
|
+
hasInheritors: record.hasInheritors !== false
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
function readNotice(value) {
|
|
3036
|
+
const record = asRecord(value);
|
|
3037
|
+
if (typeof record?.message !== "string") return void 0;
|
|
3038
|
+
return {
|
|
3039
|
+
severity: typeof record.severity === "string" ? record.severity : "NOTICE",
|
|
3040
|
+
message: record.message
|
|
3041
|
+
};
|
|
3042
|
+
}
|
|
3043
|
+
//#endregion
|
|
2462
3044
|
//#region src/websocket.ts
|
|
2463
3045
|
/**
|
|
2464
3046
|
* Extract error message and code from a WebSocket message payload.
|
|
@@ -2489,6 +3071,33 @@ function frameError(message) {
|
|
|
2489
3071
|
});
|
|
2490
3072
|
}
|
|
2491
3073
|
/**
|
|
3074
|
+
* A request whose socket closed after it was written and before the server
|
|
3075
|
+
* answered. The server may have run it. It is not re-sent — see `onclose`.
|
|
3076
|
+
*/
|
|
3077
|
+
function connectionLostError() {
|
|
3078
|
+
return new RebaseApiError$1("The realtime connection closed before the server answered. The request may or may not have been applied, and it was not sent again.", { code: "CONNECTION_LOST" });
|
|
3079
|
+
}
|
|
3080
|
+
/**
|
|
3081
|
+
* A request that got no answer within `requestTimeoutMs` of the call — whether
|
|
3082
|
+
* it waited in the queue for a socket the whole time, or was written and never
|
|
3083
|
+
* answered (a half-open connection looks exactly like that).
|
|
3084
|
+
*/
|
|
3085
|
+
function requestTimeoutError(written) {
|
|
3086
|
+
return new RebaseApiError$1(written ? "The server did not answer the realtime request in time. It may or may not have been applied." : "The realtime request timed out before it could be sent. It was not applied.", { code: "REQUEST_TIMEOUT" });
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* What a live subscription or a joined channel is told, once, when the
|
|
3090
|
+
* connection has been down for longer than a blip. The registration is kept and
|
|
3091
|
+
* resumes when the socket is back.
|
|
3092
|
+
*/
|
|
3093
|
+
function realtimeLostError() {
|
|
3094
|
+
return new RebaseApiError$1("The realtime connection is down, so live data is not updating. The client keeps reconnecting and refreshes this subscription when the connection is back.", { code: "CONNECTION_LOST" });
|
|
3095
|
+
}
|
|
3096
|
+
/** The ceiling of the reconnect backoff. */
|
|
3097
|
+
var MAX_RECONNECT_DELAY_MS = 3e4;
|
|
3098
|
+
/** Each reconnect delay is drawn from the last fifth below its step. */
|
|
3099
|
+
var RECONNECT_JITTER = .2;
|
|
3100
|
+
/**
|
|
2492
3101
|
* Broadcast and presence frames.
|
|
2493
3102
|
*
|
|
2494
3103
|
* Fire-and-forget (the server sends no response envelope), and exempt from the
|
|
@@ -2504,6 +3113,26 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
|
|
|
2504
3113
|
"channel_history"
|
|
2505
3114
|
]);
|
|
2506
3115
|
/**
|
|
3116
|
+
* Subscription frames. Like channel frames they get no response envelope, and
|
|
3117
|
+
* like them they go out without an account when there is none: whether an
|
|
3118
|
+
* anonymous visitor may subscribe is the server's `requireAuth` to decide, and
|
|
3119
|
+
* a server that refuses answers the frame, which reaches the listener's
|
|
3120
|
+
* `onError`.
|
|
3121
|
+
*/
|
|
3122
|
+
var SUBSCRIPTION_MESSAGE_TYPES = /* @__PURE__ */ new Set([
|
|
3123
|
+
"subscribe_collection",
|
|
3124
|
+
"subscribe_one",
|
|
3125
|
+
"unsubscribe"
|
|
3126
|
+
]);
|
|
3127
|
+
/**
|
|
3128
|
+
* The token getter's "there is no account" — an empty token, or its own
|
|
3129
|
+
* "not logged in". Distinct from a getter that failed, which says nothing about
|
|
3130
|
+
* whether there is one.
|
|
3131
|
+
*/
|
|
3132
|
+
function isNotSignedIn(error) {
|
|
3133
|
+
return (error instanceof Error ? error.message : String(error)).includes("not logged in");
|
|
3134
|
+
}
|
|
3135
|
+
/**
|
|
2507
3136
|
* The other direction: the frame types the *server* addresses by channel.
|
|
2508
3137
|
*
|
|
2509
3138
|
* Not the mirror of {@link CHANNEL_MESSAGE_TYPES} — a request and its answer
|
|
@@ -2563,16 +3192,56 @@ var RebaseWebSocketClient = class {
|
|
|
2563
3192
|
/** Set by `close()`. Blocks any later operation from silently redialling. */
|
|
2564
3193
|
closedByCaller = false;
|
|
2565
3194
|
/**
|
|
2566
|
-
* Set when the backoff budget ran out, cleared by
|
|
2567
|
-
* fresh one.
|
|
3195
|
+
* Set when the backoff budget ran out with nothing registered, cleared by
|
|
3196
|
+
* anything that earns a fresh one.
|
|
2568
3197
|
*
|
|
2569
|
-
*
|
|
2570
|
-
*
|
|
2571
|
-
*
|
|
2572
|
-
*
|
|
2573
|
-
* stopped for the rest of the page's life, with a reload the only cure.
|
|
3198
|
+
* Only reachable with nothing registered: while a subscription or a joined
|
|
3199
|
+
* channel exists the client never stops retrying (see `attemptReconnect`).
|
|
3200
|
+
* And it is not final like {@link closedByCaller} — nobody *asked* for the
|
|
3201
|
+
* socket to stay down, so the next subscribe dials again.
|
|
2574
3202
|
*/
|
|
2575
3203
|
gaveUp = false;
|
|
3204
|
+
/** See {@link state}. */
|
|
3205
|
+
connectionState = "idle";
|
|
3206
|
+
stateListeners = /* @__PURE__ */ new Set();
|
|
3207
|
+
/**
|
|
3208
|
+
* Whether the current outage has been reported to the registrations as
|
|
3209
|
+
* `CONNECTION_LOST`. Once per outage: cleared when a socket opens.
|
|
3210
|
+
*/
|
|
3211
|
+
outageReported = false;
|
|
3212
|
+
/**
|
|
3213
|
+
* Where the connection is.
|
|
3214
|
+
*
|
|
3215
|
+
* - `idle` — no socket, and none wanted yet (the connection is lazy), or
|
|
3216
|
+
* the last one was dropped by a sign-out.
|
|
3217
|
+
* - `connecting` — dialling, with no outage in progress.
|
|
3218
|
+
* - `connected` — the socket is open.
|
|
3219
|
+
* - `reconnecting` — the socket dropped and the client is redialling. A
|
|
3220
|
+
* blip, so far: nothing has been reported.
|
|
3221
|
+
* - `disconnected` — the outage has outlasted the blip budget. Every
|
|
3222
|
+
* subscription and joined channel has been told `CONNECTION_LOST` once;
|
|
3223
|
+
* the client keeps redialling (every 30 s at most) while any exists.
|
|
3224
|
+
* - `closed` — `client.close()`. Final.
|
|
3225
|
+
*/
|
|
3226
|
+
get state() {
|
|
3227
|
+
return this.connectionState;
|
|
3228
|
+
}
|
|
3229
|
+
/** Called on every change of {@link state}. Returns the unsubscribe. */
|
|
3230
|
+
onStateChange(listener) {
|
|
3231
|
+
this.stateListeners.add(listener);
|
|
3232
|
+
return () => {
|
|
3233
|
+
this.stateListeners.delete(listener);
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
setState(next) {
|
|
3237
|
+
if (next === this.connectionState) return;
|
|
3238
|
+
this.connectionState = next;
|
|
3239
|
+
for (const listener of [...this.stateListeners]) try {
|
|
3240
|
+
listener(next);
|
|
3241
|
+
} catch (error) {
|
|
3242
|
+
console.error("Error in realtime state listener:", error);
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
2576
3245
|
/**
|
|
2577
3246
|
* Whether a socket exists at all (open or still opening).
|
|
2578
3247
|
*
|
|
@@ -2599,6 +3268,18 @@ var RebaseWebSocketClient = class {
|
|
|
2599
3268
|
onReconnect(handler) {
|
|
2600
3269
|
return this.on("reconnect", handler);
|
|
2601
3270
|
}
|
|
3271
|
+
/**
|
|
3272
|
+
* Notified once per outage that outlasted a blip, with the
|
|
3273
|
+
* `CONNECTION_LOST` error — so a joined channel can tell its `onError`
|
|
3274
|
+
* handlers, as subscriptions are told through theirs.
|
|
3275
|
+
*/
|
|
3276
|
+
onConnectionLost(handler) {
|
|
3277
|
+
this.connectionLostHandlers.add(handler);
|
|
3278
|
+
return () => {
|
|
3279
|
+
this.connectionLostHandlers.delete(handler);
|
|
3280
|
+
};
|
|
3281
|
+
}
|
|
3282
|
+
connectionLostHandlers = /* @__PURE__ */ new Set();
|
|
2602
3283
|
on(event, cb) {
|
|
2603
3284
|
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
2604
3285
|
this.listeners.get(event).add(cb);
|
|
@@ -2613,7 +3294,29 @@ var RebaseWebSocketClient = class {
|
|
|
2613
3294
|
backendToEntityKey = /* @__PURE__ */ new Map();
|
|
2614
3295
|
pendingRequests = /* @__PURE__ */ new Map();
|
|
2615
3296
|
reconnectAttempts = 0;
|
|
3297
|
+
/**
|
|
3298
|
+
* With nothing registered, how many failed redials before the client stops
|
|
3299
|
+
* trying until something asks for the connection. With a registration it
|
|
3300
|
+
* never stops.
|
|
3301
|
+
*/
|
|
2616
3302
|
maxReconnectAttempts = 5;
|
|
3303
|
+
/**
|
|
3304
|
+
* How many failed redials make a blip an outage: the point at which every
|
|
3305
|
+
* registration is told `CONNECTION_LOST` and {@link state} reads
|
|
3306
|
+
* `disconnected`. Three is 2 + 4 + 8 s of backoff, about 14 s down.
|
|
3307
|
+
*/
|
|
3308
|
+
lostAfterAttempts = 3;
|
|
3309
|
+
/**
|
|
3310
|
+
* Whether a socket of this client has ever opened — what makes the next
|
|
3311
|
+
* open a *reconnect*, whose server has forgotten every subscription and
|
|
3312
|
+
* channel membership and must be told them again.
|
|
3313
|
+
*
|
|
3314
|
+
* Not `reconnectAttempts > 0`: that counter is a backoff budget, and a
|
|
3315
|
+
* fresh one (after giving up, or after a sign-out dropped the socket)
|
|
3316
|
+
* starts at zero, so the socket that finally came back looked like a first
|
|
3317
|
+
* connect and nothing was re-sent.
|
|
3318
|
+
*/
|
|
3319
|
+
hadConnection = false;
|
|
2617
3320
|
isConnected = false;
|
|
2618
3321
|
messageQueue = [];
|
|
2619
3322
|
requestTimeoutMs = 3e4;
|
|
@@ -2624,10 +3327,12 @@ var RebaseWebSocketClient = class {
|
|
|
2624
3327
|
WebSocketConstructor;
|
|
2625
3328
|
onUnauthorized;
|
|
2626
3329
|
refreshInProgress = null;
|
|
3330
|
+
impersonate;
|
|
2627
3331
|
constructor(config) {
|
|
2628
3332
|
this.websocketUrl = config.websocketUrl;
|
|
2629
3333
|
this.getAuthToken = config.getAuthToken;
|
|
2630
3334
|
this.onUnauthorized = config.onUnauthorized;
|
|
3335
|
+
this.impersonate = config.impersonate;
|
|
2631
3336
|
this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : void 0);
|
|
2632
3337
|
}
|
|
2633
3338
|
/**
|
|
@@ -2646,29 +3351,58 @@ var RebaseWebSocketClient = class {
|
|
|
2646
3351
|
}
|
|
2647
3352
|
return;
|
|
2648
3353
|
}
|
|
2649
|
-
this.
|
|
3354
|
+
this.installNetworkListeners();
|
|
2650
3355
|
if (this.ws || this.reconnectTimeout) return;
|
|
2651
3356
|
if (this.gaveUp) {
|
|
2652
3357
|
this.gaveUp = false;
|
|
2653
3358
|
this.reconnectAttempts = 0;
|
|
3359
|
+
this.outageReported = false;
|
|
2654
3360
|
}
|
|
3361
|
+
if (!this.outageReported) this.setState("connecting");
|
|
2655
3362
|
this.initWebSocket();
|
|
2656
3363
|
}
|
|
2657
3364
|
/**
|
|
2658
|
-
*
|
|
2659
|
-
*
|
|
2660
|
-
*
|
|
3365
|
+
* Dial now rather than at the next backoff step — the browser says the
|
|
3366
|
+
* network is back, or the tab is in front of the user again. Both are the
|
|
3367
|
+
* usual end of an outage; neither fires for a server restart, which the
|
|
3368
|
+
* backoff covers.
|
|
2661
3369
|
*/
|
|
2662
|
-
|
|
3370
|
+
retryNow(reason) {
|
|
3371
|
+
if (this.closedByCaller) return;
|
|
3372
|
+
if (this.connectionState !== "reconnecting" && this.connectionState !== "disconnected") return;
|
|
3373
|
+
if (this.ws) return;
|
|
3374
|
+
console.debug(`${reason} — retrying the realtime connection`);
|
|
3375
|
+
if (this.reconnectTimeout) {
|
|
3376
|
+
clearTimeout(this.reconnectTimeout);
|
|
3377
|
+
this.reconnectTimeout = null;
|
|
3378
|
+
}
|
|
3379
|
+
this.gaveUp = false;
|
|
3380
|
+
this.reconnectAttempts = 0;
|
|
3381
|
+
this.initWebSocket();
|
|
3382
|
+
}
|
|
3383
|
+
/**
|
|
3384
|
+
* `online` and `visibilitychange`. Registered lazily so a Node client, or a
|
|
3385
|
+
* page that never subscribes, adds no listener.
|
|
3386
|
+
*/
|
|
3387
|
+
installNetworkListeners() {
|
|
2663
3388
|
if (this.onlineListener || typeof window === "undefined" || typeof window.addEventListener !== "function") return;
|
|
2664
|
-
this.onlineListener = () =>
|
|
2665
|
-
if (this.closedByCaller || !this.gaveUp) return;
|
|
2666
|
-
console.debug("Network is back — retrying the realtime connection");
|
|
2667
|
-
this.ensureConnected();
|
|
2668
|
-
};
|
|
3389
|
+
this.onlineListener = () => this.retryNow("Network is back");
|
|
2669
3390
|
window.addEventListener("online", this.onlineListener);
|
|
3391
|
+
if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
|
|
3392
|
+
this.visibilityListener = () => {
|
|
3393
|
+
if (document.visibilityState === "visible") this.retryNow("Tab is visible again");
|
|
3394
|
+
};
|
|
3395
|
+
document.addEventListener("visibilitychange", this.visibilityListener);
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
removeNetworkListeners() {
|
|
3399
|
+
if (this.onlineListener && typeof window !== "undefined") window.removeEventListener("online", this.onlineListener);
|
|
3400
|
+
if (this.visibilityListener && typeof document !== "undefined") document.removeEventListener("visibilitychange", this.visibilityListener);
|
|
3401
|
+
this.onlineListener = null;
|
|
3402
|
+
this.visibilityListener = null;
|
|
2670
3403
|
}
|
|
2671
3404
|
onlineListener = null;
|
|
3405
|
+
visibilityListener = null;
|
|
2672
3406
|
/**
|
|
2673
3407
|
* Authenticate the WebSocket connection
|
|
2674
3408
|
*/
|
|
@@ -2693,7 +3427,10 @@ var RebaseWebSocketClient = class {
|
|
|
2693
3427
|
const message = {
|
|
2694
3428
|
type: "AUTHENTICATE",
|
|
2695
3429
|
requestId,
|
|
2696
|
-
payload:
|
|
3430
|
+
payload: this.impersonate !== void 0 ? {
|
|
3431
|
+
token,
|
|
3432
|
+
impersonate: this.impersonate
|
|
3433
|
+
} : { token }
|
|
2697
3434
|
};
|
|
2698
3435
|
if (!this.isConnected || !this.ws) this.messageQueue.unshift(message);
|
|
2699
3436
|
else this.ws.send(JSON.stringify(message));
|
|
@@ -2726,17 +3463,19 @@ var RebaseWebSocketClient = class {
|
|
|
2726
3463
|
*/
|
|
2727
3464
|
disconnect(permanent = false) {
|
|
2728
3465
|
if (permanent) this.closedByCaller = true;
|
|
2729
|
-
if (permanent
|
|
2730
|
-
window.removeEventListener("online", this.onlineListener);
|
|
2731
|
-
this.onlineListener = null;
|
|
2732
|
-
}
|
|
3466
|
+
if (permanent) this.removeNetworkListeners();
|
|
2733
3467
|
this.isAuthenticated = false;
|
|
2734
3468
|
this.authPromise = null;
|
|
2735
3469
|
if (this.reconnectTimeout) {
|
|
2736
3470
|
clearTimeout(this.reconnectTimeout);
|
|
2737
3471
|
this.reconnectTimeout = null;
|
|
2738
3472
|
}
|
|
3473
|
+
this.reconnectAttempts = 0;
|
|
3474
|
+
this.outageReported = false;
|
|
3475
|
+
this.gaveUp = false;
|
|
3476
|
+
this.setState(permanent ? "closed" : "idle");
|
|
2739
3477
|
this.suspendSubscribeWatchdogs();
|
|
3478
|
+
this.forgetCachedData();
|
|
2740
3479
|
if (this.ws) {
|
|
2741
3480
|
this.ws.onclose = null;
|
|
2742
3481
|
this.ws.onerror = null;
|
|
@@ -2759,9 +3498,13 @@ var RebaseWebSocketClient = class {
|
|
|
2759
3498
|
this.ws = socket;
|
|
2760
3499
|
this.ws.onopen = async () => {
|
|
2761
3500
|
console.debug("Connected to PostgreSQL backend");
|
|
2762
|
-
const wasReconnect = this.
|
|
3501
|
+
const wasReconnect = this.hadConnection;
|
|
3502
|
+
this.hadConnection = true;
|
|
2763
3503
|
this.isConnected = true;
|
|
2764
3504
|
this.reconnectAttempts = 0;
|
|
3505
|
+
this.outageReported = false;
|
|
3506
|
+
this.gaveUp = false;
|
|
3507
|
+
this.setState("connected");
|
|
2765
3508
|
if (this.getAuthToken && !this.isAuthenticated) try {
|
|
2766
3509
|
const token = await this.getAuthToken();
|
|
2767
3510
|
if (token) {
|
|
@@ -2772,7 +3515,7 @@ var RebaseWebSocketClient = class {
|
|
|
2772
3515
|
console.debug("WebSocket connected without auth:", error?.message || error);
|
|
2773
3516
|
}
|
|
2774
3517
|
this.emit(wasReconnect ? "reconnect" : "connect");
|
|
2775
|
-
this.processMessageQueue();
|
|
3518
|
+
this.processMessageQueue(wasReconnect);
|
|
2776
3519
|
if (wasReconnect) this.resubscribeAll();
|
|
2777
3520
|
this.armPendingSubscribeWatchdogs();
|
|
2778
3521
|
};
|
|
@@ -2793,14 +3536,11 @@ var RebaseWebSocketClient = class {
|
|
|
2793
3536
|
this.suspendSubscribeWatchdogs();
|
|
2794
3537
|
this.emit("disconnect");
|
|
2795
3538
|
for (const [reqId, request] of this.pendingRequests.entries()) {
|
|
2796
|
-
if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
|
|
2797
|
-
else if (request.message) {
|
|
2798
|
-
request.message._queuedResolve = request.resolve;
|
|
2799
|
-
request.message._queuedReject = request.reject;
|
|
2800
|
-
this.messageQueue.push(request.message);
|
|
2801
|
-
} else request.reject(new RebaseApiError$1("Connection closed"));
|
|
2802
3539
|
this.pendingRequests.delete(reqId);
|
|
3540
|
+
if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
|
|
3541
|
+
else request.reject(connectionLostError());
|
|
2803
3542
|
}
|
|
3543
|
+
this.setState(this.outageReported ? "disconnected" : "reconnecting");
|
|
2804
3544
|
this.attemptReconnect();
|
|
2805
3545
|
};
|
|
2806
3546
|
this.ws.onerror = (error) => {
|
|
@@ -2813,27 +3553,117 @@ var RebaseWebSocketClient = class {
|
|
|
2813
3553
|
this.attemptReconnect();
|
|
2814
3554
|
}
|
|
2815
3555
|
}
|
|
2816
|
-
processMessageQueue() {
|
|
3556
|
+
processMessageQueue(isReconnect) {
|
|
2817
3557
|
while (this.messageQueue.length > 0 && this.isConnected) {
|
|
2818
3558
|
const message = this.messageQueue.shift();
|
|
2819
|
-
if (message)
|
|
3559
|
+
if (!message) continue;
|
|
3560
|
+
if (!this.shouldSendQueued(message, isReconnect)) {
|
|
3561
|
+
const resolveQueued = message._queuedResolve;
|
|
3562
|
+
if (typeof resolveQueued === "function") resolveQueued(void 0);
|
|
3563
|
+
continue;
|
|
3564
|
+
}
|
|
3565
|
+
this.sendMessage(message);
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
/**
|
|
3569
|
+
* Whether a frame that waited in the queue should go out on the socket
|
|
3570
|
+
* that just opened. Only a subscribe can be refused, and one is exactly
|
|
3571
|
+
* when sending it would give the server a subscription this side will not
|
|
3572
|
+
* read:
|
|
3573
|
+
*
|
|
3574
|
+
* - Its registration is gone, or has been given a new id, while it waited.
|
|
3575
|
+
* A listener that unmounted before the socket opened (a StrictMode double
|
|
3576
|
+
* mount does exactly this) sends no `unsubscribe`, because there was no
|
|
3577
|
+
* socket to send it on, so the server would keep that one forever.
|
|
3578
|
+
* - This open is a reconnect. `resubscribeAll` sends every registration
|
|
3579
|
+
* under a fresh id right after the queue drains, so the queued copy would
|
|
3580
|
+
* be the second of two. It is handed back to that pass instead.
|
|
3581
|
+
*/
|
|
3582
|
+
shouldSendQueued(message, isReconnect) {
|
|
3583
|
+
if (message.type !== "subscribe_collection" && message.type !== "subscribe_one") return true;
|
|
3584
|
+
const payload = message.payload;
|
|
3585
|
+
if (!payload || typeof payload !== "object" || !("subscriptionId" in payload)) return true;
|
|
3586
|
+
const subscriptionId = payload.subscriptionId;
|
|
3587
|
+
if (typeof subscriptionId !== "string") return true;
|
|
3588
|
+
const registration = message.type === "subscribe_collection" ? this.collectionSubscriptions.get(this.backendToCollectionKey.get(subscriptionId) ?? "") : this.singleSubscriptions.get(this.backendToEntityKey.get(subscriptionId) ?? "");
|
|
3589
|
+
if (!registration || registration.backendSubscriptionId !== subscriptionId) return false;
|
|
3590
|
+
if (isReconnect) {
|
|
3591
|
+
registration.subscribeInFlight = false;
|
|
3592
|
+
return false;
|
|
2820
3593
|
}
|
|
3594
|
+
return true;
|
|
2821
3595
|
}
|
|
2822
3596
|
attemptReconnect() {
|
|
2823
|
-
if (this.
|
|
2824
|
-
|
|
3597
|
+
if (this.closedByCaller) return;
|
|
3598
|
+
if (this.reconnectAttempts >= this.lostAfterAttempts) this.reportConnectionLost();
|
|
3599
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts && !this.hasRegistrations()) {
|
|
3600
|
+
console.warn("[Rebase] Realtime: the server is unreachable and nothing is subscribed. Not retrying until something asks for the connection.");
|
|
2825
3601
|
this.gaveUp = true;
|
|
2826
|
-
this.failAllPendingSubscriptions(new RebaseApiError$1("Connection lost", { code: "CONNECTION_LOST" }));
|
|
2827
3602
|
return;
|
|
2828
3603
|
}
|
|
2829
|
-
this.reconnectAttempts
|
|
2830
|
-
const
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
this.reconnectTimeout
|
|
2834
|
-
|
|
2835
|
-
this.
|
|
2836
|
-
|
|
3604
|
+
this.reconnectAttempts = Math.min(this.reconnectAttempts + 1, 30);
|
|
3605
|
+
const ceiling = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), MAX_RECONNECT_DELAY_MS);
|
|
3606
|
+
const delay = ceiling - Math.floor(Math.random() * ceiling * RECONNECT_JITTER);
|
|
3607
|
+
console.debug(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
3608
|
+
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
|
|
3609
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
3610
|
+
this.reconnectTimeout = null;
|
|
3611
|
+
this.initWebSocket();
|
|
3612
|
+
}, delay);
|
|
3613
|
+
}
|
|
3614
|
+
/**
|
|
3615
|
+
* Whether anything depends on the socket coming back: a subscription, a
|
|
3616
|
+
* joined channel, or a request whose caller is still waiting for a socket.
|
|
3617
|
+
*/
|
|
3618
|
+
hasRegistrations() {
|
|
3619
|
+
return this.collectionSubscriptions.size > 0 || this.singleSubscriptions.size > 0 || this.channelHandlers.size > 0 || this.messageQueue.some((frame) => typeof frame._queuedReject === "function");
|
|
3620
|
+
}
|
|
3621
|
+
/**
|
|
3622
|
+
* Tell every subscription and joined channel, once per outage, that the
|
|
3623
|
+
* connection is down and their data is not updating.
|
|
3624
|
+
*
|
|
3625
|
+
* Registrations are kept — that is the difference from a subscribe that
|
|
3626
|
+
* failed. When the socket is back they are re-subscribed, and the fresh
|
|
3627
|
+
* `onUpdate` is the recovery.
|
|
3628
|
+
*/
|
|
3629
|
+
reportConnectionLost() {
|
|
3630
|
+
if (this.outageReported) return;
|
|
3631
|
+
this.outageReported = true;
|
|
3632
|
+
this.setState("disconnected");
|
|
3633
|
+
const error = realtimeLostError();
|
|
3634
|
+
for (const subscription of this.collectionSubscriptions.values()) this.notifyError(subscription.callbacks, error);
|
|
3635
|
+
for (const subscription of this.singleSubscriptions.values()) this.notifyError(subscription.callbacks, error);
|
|
3636
|
+
for (const handler of [...this.connectionLostHandlers]) try {
|
|
3637
|
+
handler(error);
|
|
3638
|
+
} catch (handlerError) {
|
|
3639
|
+
console.error("Error in connection-lost handler:", handlerError);
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
notifyError(callbacks, error) {
|
|
3643
|
+
for (const callback of [...callbacks.values()]) {
|
|
3644
|
+
if (!callback.onError) continue;
|
|
3645
|
+
try {
|
|
3646
|
+
callback.onError(error);
|
|
3647
|
+
} catch (callbackError) {
|
|
3648
|
+
console.error("Error in subscription error callback:", callbackError);
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
/**
|
|
3653
|
+
* A listener attaching while an outage is being reported is told at once:
|
|
3654
|
+
* its subscribe waits for a socket that may be minutes away, and nothing
|
|
3655
|
+
* else would say so.
|
|
3656
|
+
*/
|
|
3657
|
+
reportOutageTo(onError) {
|
|
3658
|
+
if (!this.outageReported || !onError) return;
|
|
3659
|
+
queueMicrotask(() => {
|
|
3660
|
+
if (!this.outageReported) return;
|
|
3661
|
+
try {
|
|
3662
|
+
onError(realtimeLostError());
|
|
3663
|
+
} catch (callbackError) {
|
|
3664
|
+
console.error("Error in subscription error callback:", callbackError);
|
|
3665
|
+
}
|
|
3666
|
+
});
|
|
2837
3667
|
}
|
|
2838
3668
|
isAuthError(message) {
|
|
2839
3669
|
if (message.type === "AUTH_ERROR") return true;
|
|
@@ -2977,8 +3807,9 @@ var RebaseWebSocketClient = class {
|
|
|
2977
3807
|
}
|
|
2978
3808
|
}
|
|
2979
3809
|
}
|
|
2980
|
-
|
|
2981
|
-
|
|
3810
|
+
const erroredSubscriptionId = subscriptionId ?? requestId;
|
|
3811
|
+
if (erroredSubscriptionId && (type === "ERROR" || message.error)) {
|
|
3812
|
+
const collectionKey = this.backendToCollectionKey.get(erroredSubscriptionId);
|
|
2982
3813
|
if (collectionKey) {
|
|
2983
3814
|
const collectionSub = this.collectionSubscriptions.get(collectionKey);
|
|
2984
3815
|
if (collectionSub) {
|
|
@@ -2996,7 +3827,7 @@ var RebaseWebSocketClient = class {
|
|
|
2996
3827
|
return;
|
|
2997
3828
|
}
|
|
2998
3829
|
}
|
|
2999
|
-
const entityKey = this.backendToEntityKey.get(
|
|
3830
|
+
const entityKey = this.backendToEntityKey.get(erroredSubscriptionId);
|
|
3000
3831
|
if (entityKey) {
|
|
3001
3832
|
const entitySub = this.singleSubscriptions.get(entityKey);
|
|
3002
3833
|
if (entitySub) {
|
|
@@ -3080,49 +3911,89 @@ var RebaseWebSocketClient = class {
|
|
|
3080
3911
|
*/
|
|
3081
3912
|
sendMessage(message) {
|
|
3082
3913
|
const queuedMsg = message;
|
|
3083
|
-
if (queuedMsg._queuedResolve && queuedMsg._queuedReject)
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
queueable._queuedReject = reject;
|
|
3090
|
-
this.messageQueue.push(message);
|
|
3091
|
-
});
|
|
3914
|
+
if (queuedMsg._queuedResolve && queuedMsg._queuedReject) {
|
|
3915
|
+
const resolve = queuedMsg._queuedResolve;
|
|
3916
|
+
const reject = queuedMsg._queuedReject;
|
|
3917
|
+
delete queuedMsg._queuedResolve;
|
|
3918
|
+
delete queuedMsg._queuedReject;
|
|
3919
|
+
return this.doSendMessage(message, resolve, reject);
|
|
3092
3920
|
}
|
|
3093
3921
|
return new Promise((resolve, reject) => {
|
|
3094
|
-
|
|
3922
|
+
let settled = false;
|
|
3923
|
+
let deadline;
|
|
3924
|
+
const onResolve = (value) => {
|
|
3925
|
+
if (settled) return;
|
|
3926
|
+
settled = true;
|
|
3927
|
+
if (deadline) clearTimeout(deadline);
|
|
3928
|
+
resolve(value);
|
|
3929
|
+
};
|
|
3930
|
+
const onReject = (error) => {
|
|
3931
|
+
if (settled) return;
|
|
3932
|
+
settled = true;
|
|
3933
|
+
if (deadline) clearTimeout(deadline);
|
|
3934
|
+
this.abandonedFrames.add(message);
|
|
3935
|
+
reject(error);
|
|
3936
|
+
};
|
|
3937
|
+
if (this.expectsResponse(message)) deadline = setTimeout(() => {
|
|
3938
|
+
const queuedAt = this.messageQueue.indexOf(message);
|
|
3939
|
+
if (queuedAt >= 0) this.messageQueue.splice(queuedAt, 1);
|
|
3940
|
+
let written = false;
|
|
3941
|
+
for (const [requestId, pending] of this.pendingRequests) {
|
|
3942
|
+
if (pending.message !== message) continue;
|
|
3943
|
+
this.pendingRequests.delete(requestId);
|
|
3944
|
+
written = true;
|
|
3945
|
+
}
|
|
3946
|
+
onReject(requestTimeoutError(written));
|
|
3947
|
+
}, this.requestTimeoutMs);
|
|
3948
|
+
if (!this.isConnected || !this.ws) {
|
|
3949
|
+
this.ensureConnected();
|
|
3950
|
+
queuedMsg._queuedResolve = onResolve;
|
|
3951
|
+
queuedMsg._queuedReject = onReject;
|
|
3952
|
+
this.messageQueue.push(message);
|
|
3953
|
+
return;
|
|
3954
|
+
}
|
|
3955
|
+
this.doSendMessage(message, onResolve, onReject);
|
|
3095
3956
|
});
|
|
3096
3957
|
}
|
|
3958
|
+
/**
|
|
3959
|
+
* Frames whose caller has already been answered — timed out, or rejected
|
|
3960
|
+
* by a close. One can still be on its way to the socket (awaiting
|
|
3961
|
+
* authentication), and must not be written after its caller was told it
|
|
3962
|
+
* failed.
|
|
3963
|
+
*/
|
|
3964
|
+
abandonedFrames = /* @__PURE__ */ new WeakSet();
|
|
3965
|
+
/** Whether the server answers this frame with a response envelope. */
|
|
3966
|
+
expectsResponse(message) {
|
|
3967
|
+
const type = message.type;
|
|
3968
|
+
return !(SUBSCRIPTION_MESSAGE_TYPES.has(type) || CHANNEL_MESSAGE_TYPES.has(type));
|
|
3969
|
+
}
|
|
3097
3970
|
async doSendMessage(message, resolve, reject) {
|
|
3971
|
+
const isSubscriptionFrame = SUBSCRIPTION_MESSAGE_TYPES.has(message.type);
|
|
3098
3972
|
if (message.type !== "AUTHENTICATE" && !CHANNEL_MESSAGE_TYPES.has(message.type) && this.getAuthToken && !this.isAuthenticated) try {
|
|
3099
3973
|
await this.ensureAuthenticated();
|
|
3100
3974
|
} catch (error) {
|
|
3101
|
-
|
|
3975
|
+
if (!(isSubscriptionFrame && isNotSignedIn(error))) {
|
|
3976
|
+
reject(new RebaseApiError$1(error instanceof Error ? error.message : "Authentication required"));
|
|
3977
|
+
return;
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
if (this.abandonedFrames.has(message)) return;
|
|
3981
|
+
if (!this.isConnected || !this.ws) {
|
|
3982
|
+
const queueable = message;
|
|
3983
|
+
queueable._queuedResolve = resolve;
|
|
3984
|
+
queueable._queuedReject = reject;
|
|
3985
|
+
this.messageQueue.push(message);
|
|
3986
|
+
this.ensureConnected();
|
|
3102
3987
|
return;
|
|
3103
3988
|
}
|
|
3104
3989
|
const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
3105
3990
|
message.requestId = requestId;
|
|
3106
|
-
const expectsResponse =
|
|
3107
|
-
if (expectsResponse && !this.pendingRequests.has(requestId)) {
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
}
|
|
3113
|
-
}, this.requestTimeoutMs);
|
|
3114
|
-
this.pendingRequests.set(requestId, {
|
|
3115
|
-
resolve: (value) => {
|
|
3116
|
-
clearTimeout(timeoutHandle);
|
|
3117
|
-
resolve(value);
|
|
3118
|
-
},
|
|
3119
|
-
reject: (error) => {
|
|
3120
|
-
clearTimeout(timeoutHandle);
|
|
3121
|
-
reject(error);
|
|
3122
|
-
},
|
|
3123
|
-
message
|
|
3124
|
-
});
|
|
3125
|
-
}
|
|
3991
|
+
const expectsResponse = this.expectsResponse(message);
|
|
3992
|
+
if (expectsResponse && !this.pendingRequests.has(requestId)) this.pendingRequests.set(requestId, {
|
|
3993
|
+
resolve,
|
|
3994
|
+
reject,
|
|
3995
|
+
message
|
|
3996
|
+
});
|
|
3126
3997
|
try {
|
|
3127
3998
|
this.ws.send(JSON.stringify(message));
|
|
3128
3999
|
if (!expectsResponse) resolve(void 0);
|
|
@@ -3164,6 +4035,25 @@ var RebaseWebSocketClient = class {
|
|
|
3164
4035
|
}
|
|
3165
4036
|
})).result || [];
|
|
3166
4037
|
}
|
|
4038
|
+
/**
|
|
4039
|
+
* Run a script a person wrote — the Studio console — and get back what its
|
|
4040
|
+
* last statement returned, every value as the database's text, with the
|
|
4041
|
+
* table column each result column was read from. See
|
|
4042
|
+
* `SQLAdmin.runSqlScript`.
|
|
4043
|
+
*/
|
|
4044
|
+
async runSqlScript(sql, options) {
|
|
4045
|
+
return readSqlScriptResult(await this.sendMessage({
|
|
4046
|
+
type: "EXECUTE_SQL",
|
|
4047
|
+
payload: {
|
|
4048
|
+
sql,
|
|
4049
|
+
options: {
|
|
4050
|
+
database: options?.database,
|
|
4051
|
+
role: options?.role
|
|
4052
|
+
},
|
|
4053
|
+
mode: "script"
|
|
4054
|
+
}
|
|
4055
|
+
}));
|
|
4056
|
+
}
|
|
3167
4057
|
async fetchAvailableDatabases() {
|
|
3168
4058
|
return (await this.sendMessage({
|
|
3169
4059
|
type: "FETCH_DATABASES",
|
|
@@ -3345,6 +4235,7 @@ var RebaseWebSocketClient = class {
|
|
|
3345
4235
|
onUpdate,
|
|
3346
4236
|
onError
|
|
3347
4237
|
});
|
|
4238
|
+
this.reportOutageTo(onError);
|
|
3348
4239
|
if (existingSubscription.latestData !== void 0 && existingSubscription.isInitialDataReceived) try {
|
|
3349
4240
|
onUpdate(existingSubscription.latestData, existingSubscription.latestMeta);
|
|
3350
4241
|
} catch (error) {
|
|
@@ -3372,6 +4263,7 @@ var RebaseWebSocketClient = class {
|
|
|
3372
4263
|
onUpdate,
|
|
3373
4264
|
onError
|
|
3374
4265
|
});
|
|
4266
|
+
this.reportOutageTo(onError);
|
|
3375
4267
|
this.collectionSubscriptions.set(subscriptionKey, {
|
|
3376
4268
|
backendSubscriptionId,
|
|
3377
4269
|
callbacks: callbackMap,
|
|
@@ -3407,6 +4299,7 @@ var RebaseWebSocketClient = class {
|
|
|
3407
4299
|
onUpdate,
|
|
3408
4300
|
onError
|
|
3409
4301
|
});
|
|
4302
|
+
this.reportOutageTo(onError);
|
|
3410
4303
|
if (existingSubscription.latestData !== void 0 && existingSubscription.isInitialDataReceived) try {
|
|
3411
4304
|
onUpdate(existingSubscription.latestData);
|
|
3412
4305
|
} catch (error) {
|
|
@@ -3434,6 +4327,7 @@ var RebaseWebSocketClient = class {
|
|
|
3434
4327
|
onUpdate,
|
|
3435
4328
|
onError
|
|
3436
4329
|
});
|
|
4330
|
+
this.reportOutageTo(onError);
|
|
3437
4331
|
this.singleSubscriptions.set(subscriptionKey, {
|
|
3438
4332
|
backendSubscriptionId,
|
|
3439
4333
|
callbacks: callbackMap,
|
|
@@ -3476,6 +4370,7 @@ var RebaseWebSocketClient = class {
|
|
|
3476
4370
|
if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
|
|
3477
4371
|
this.sendMessage({
|
|
3478
4372
|
type: "subscribe_collection",
|
|
4373
|
+
requestId: backendSubscriptionId,
|
|
3479
4374
|
payload: {
|
|
3480
4375
|
...subscription.props,
|
|
3481
4376
|
subscriptionId: backendSubscriptionId
|
|
@@ -3497,6 +4392,7 @@ var RebaseWebSocketClient = class {
|
|
|
3497
4392
|
if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
|
|
3498
4393
|
this.sendMessage({
|
|
3499
4394
|
type: "subscribe_one",
|
|
4395
|
+
requestId: backendSubscriptionId,
|
|
3500
4396
|
payload: {
|
|
3501
4397
|
...subscription.props,
|
|
3502
4398
|
subscriptionId: backendSubscriptionId
|
|
@@ -3564,6 +4460,26 @@ var RebaseWebSocketClient = class {
|
|
|
3564
4460
|
}
|
|
3565
4461
|
}
|
|
3566
4462
|
/**
|
|
4463
|
+
* Drop every registration's cached rows, keeping the registrations.
|
|
4464
|
+
*
|
|
4465
|
+
* A registration without rows counts as not loaded, so the next listener
|
|
4466
|
+
* to attach asks the server (or waits for the resubscribe) instead of
|
|
4467
|
+
* being handed what is cached.
|
|
4468
|
+
*/
|
|
4469
|
+
forgetCachedData() {
|
|
4470
|
+
for (const sub of this.collectionSubscriptions.values()) {
|
|
4471
|
+
sub.latestData = void 0;
|
|
4472
|
+
sub.latestMeta = void 0;
|
|
4473
|
+
sub.lastUpdated = void 0;
|
|
4474
|
+
sub.isInitialDataReceived = false;
|
|
4475
|
+
}
|
|
4476
|
+
for (const sub of this.singleSubscriptions.values()) {
|
|
4477
|
+
sub.latestData = void 0;
|
|
4478
|
+
sub.lastUpdated = void 0;
|
|
4479
|
+
sub.isInitialDataReceived = false;
|
|
4480
|
+
}
|
|
4481
|
+
}
|
|
4482
|
+
/**
|
|
3567
4483
|
* Arm watchdogs for subscribes that were requested while offline and have
|
|
3568
4484
|
* just been flushed to the socket. Their timers were deliberately not set at
|
|
3569
4485
|
* request time, so without this they would have no timeout at all.
|
|
@@ -3595,20 +4511,6 @@ var RebaseWebSocketClient = class {
|
|
|
3595
4511
|
}, this.subscriptionTimeoutMs);
|
|
3596
4512
|
}
|
|
3597
4513
|
/**
|
|
3598
|
-
* Fail every subscription that never received data. Called when reconnection
|
|
3599
|
-
* is given up on, so views surface an error instead of spinning forever.
|
|
3600
|
-
*/
|
|
3601
|
-
failAllPendingSubscriptions(error) {
|
|
3602
|
-
for (const key of [...this.collectionSubscriptions.keys()]) {
|
|
3603
|
-
const sub = this.collectionSubscriptions.get(key);
|
|
3604
|
-
if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
|
|
3605
|
-
}
|
|
3606
|
-
for (const key of [...this.singleSubscriptions.keys()]) {
|
|
3607
|
-
const sub = this.singleSubscriptions.get(key);
|
|
3608
|
-
if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
|
|
3609
|
-
}
|
|
3610
|
-
}
|
|
3611
|
-
/**
|
|
3612
4514
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
3613
4515
|
* The server wipes subscription state when a client disconnects, so
|
|
3614
4516
|
* we need to re-register everything to resume receiving updates.
|
|
@@ -3616,6 +4518,7 @@ var RebaseWebSocketClient = class {
|
|
|
3616
4518
|
resubscribeAll() {
|
|
3617
4519
|
console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.singleSubscriptions.size} row(ies)`);
|
|
3618
4520
|
for (const [key, sub] of this.collectionSubscriptions.entries()) {
|
|
4521
|
+
if (sub.subscribeInFlight) continue;
|
|
3619
4522
|
const oldBackendId = sub.backendSubscriptionId;
|
|
3620
4523
|
const newBackendId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
3621
4524
|
sub.backendSubscriptionId = newBackendId;
|
|
@@ -3624,6 +4527,7 @@ var RebaseWebSocketClient = class {
|
|
|
3624
4527
|
this.sendCollectionSubscribe(key);
|
|
3625
4528
|
}
|
|
3626
4529
|
for (const [key, sub] of this.singleSubscriptions.entries()) {
|
|
4530
|
+
if (sub.subscribeInFlight) continue;
|
|
3627
4531
|
const oldBackendId = sub.backendSubscriptionId;
|
|
3628
4532
|
const newBackendId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
3629
4533
|
sub.backendSubscriptionId = newBackendId;
|
|
@@ -3702,6 +4606,27 @@ var RebaseRealtimeChannel = class {
|
|
|
3702
4606
|
pendingLive = [];
|
|
3703
4607
|
catchUpInFlight = false;
|
|
3704
4608
|
/**
|
|
4609
|
+
* Whether {@link lastSeq} is a position this client reached, rather than
|
|
4610
|
+
* the zero of a handle that has not caught up yet.
|
|
4611
|
+
*
|
|
4612
|
+
* A client joining for the first time asks for whatever is retained, so
|
|
4613
|
+
* history pruned before it ever joined is not something it missed. Only a
|
|
4614
|
+
* client with a position can have a hole in what it saw.
|
|
4615
|
+
*/
|
|
4616
|
+
positioned = false;
|
|
4617
|
+
/** The page size the catch-up in flight asked for, reused for its later pages. */
|
|
4618
|
+
catchUpLimit;
|
|
4619
|
+
/**
|
|
4620
|
+
* `latestSeq` of an empty page that left this client behind, while a
|
|
4621
|
+
* second request confirms it.
|
|
4622
|
+
*
|
|
4623
|
+
* The server reads the retained messages and then the cursor, so a message
|
|
4624
|
+
* committed between the two shows up in `latestSeq` and not in the page.
|
|
4625
|
+
* The second request returns it if it exists; only a second empty page
|
|
4626
|
+
* means the messages up to this seq are gone.
|
|
4627
|
+
*/
|
|
4628
|
+
emptyTailSeq = null;
|
|
4629
|
+
/**
|
|
3705
4630
|
* Deadline for a catch-up response.
|
|
3706
4631
|
*
|
|
3707
4632
|
* Buffering live messages is only safe because the wait is bounded. A
|
|
@@ -3773,6 +4698,8 @@ var RebaseRealtimeChannel = class {
|
|
|
3773
4698
|
this.unsubscribers.push(this.transport.onReconnect(() => {
|
|
3774
4699
|
this.rejoin();
|
|
3775
4700
|
}));
|
|
4701
|
+
const onConnectionLost = this.transport.onConnectionLost?.((error) => this.emitError(error));
|
|
4702
|
+
if (onConnectionLost) this.unsubscribers.push(onConnectionLost);
|
|
3776
4703
|
await this.send("join_channel");
|
|
3777
4704
|
await this.send("presence_state");
|
|
3778
4705
|
if (this.wantsHistory) await this.requestHistory();
|
|
@@ -3793,6 +4720,7 @@ var RebaseRealtimeChannel = class {
|
|
|
3793
4720
|
*/
|
|
3794
4721
|
async requestHistory(limit) {
|
|
3795
4722
|
this.catchUpInFlight = true;
|
|
4723
|
+
this.catchUpLimit = limit;
|
|
3796
4724
|
if (this.catchUpTimeout) clearTimeout(this.catchUpTimeout);
|
|
3797
4725
|
this.catchUpTimeout = setTimeout(() => this.abandonCatchUp(), CATCH_UP_TIMEOUT_MS);
|
|
3798
4726
|
unref(this.catchUpTimeout);
|
|
@@ -3820,6 +4748,7 @@ var RebaseRealtimeChannel = class {
|
|
|
3820
4748
|
}
|
|
3821
4749
|
if (!this.catchUpInFlight) return;
|
|
3822
4750
|
this.catchUpInFlight = false;
|
|
4751
|
+
this.emptyTailSeq = null;
|
|
3823
4752
|
for (const resolve of this.historyWaiters.splice(0)) resolve({
|
|
3824
4753
|
messages: [],
|
|
3825
4754
|
retained: false
|
|
@@ -3886,6 +4815,12 @@ var RebaseRealtimeChannel = class {
|
|
|
3886
4815
|
* retention cannot be served; `CHANNEL_BUS_PAYLOAD_TOO_LARGE` when a
|
|
3887
4816
|
* broadcast on an ephemeral channel is too big to cross the bus.
|
|
3888
4817
|
*
|
|
4818
|
+
* `CHANNEL_HISTORY_GAP` is raised by the client: a catch-up found that the
|
|
4819
|
+
* server no longer retains messages this client never received, so its
|
|
4820
|
+
* state has a hole a replay cannot fill. `details` is `{ from, to }`, the
|
|
4821
|
+
* inclusive range of sequence numbers missed. Resync the channel's state
|
|
4822
|
+
* from its source of truth.
|
|
4823
|
+
*
|
|
3889
4824
|
* These used to be dropped on the floor — there is no promise to reject on
|
|
3890
4825
|
* a fire-and-forget frame, so a forbidden broadcast looked exactly like a
|
|
3891
4826
|
* delivered one. With no handler attached they are logged as a warning,
|
|
@@ -3927,7 +4862,10 @@ var RebaseRealtimeChannel = class {
|
|
|
3927
4862
|
*/
|
|
3928
4863
|
async history(options = {}) {
|
|
3929
4864
|
await this.join();
|
|
3930
|
-
if (options.sinceSeq !== void 0)
|
|
4865
|
+
if (options.sinceSeq !== void 0) {
|
|
4866
|
+
this.lastSeq = options.sinceSeq;
|
|
4867
|
+
this.positioned = options.sinceSeq > 0;
|
|
4868
|
+
}
|
|
3931
4869
|
const result = new Promise((resolve) => {
|
|
3932
4870
|
this.historyWaiters.push(resolve);
|
|
3933
4871
|
});
|
|
@@ -3942,6 +4880,8 @@ var RebaseRealtimeChannel = class {
|
|
|
3942
4880
|
this.presenceHandlers.clear();
|
|
3943
4881
|
this.broadcastHandlers.clear();
|
|
3944
4882
|
this.lastSeq = 0;
|
|
4883
|
+
this.positioned = false;
|
|
4884
|
+
this.emptyTailSeq = null;
|
|
3945
4885
|
this.pendingLive = [];
|
|
3946
4886
|
this.catchUpInFlight = false;
|
|
3947
4887
|
if (this.catchUpTimeout) {
|
|
@@ -3999,11 +4939,11 @@ var RebaseRealtimeChannel = class {
|
|
|
3999
4939
|
}
|
|
4000
4940
|
if (seq <= this.lastSeq) break;
|
|
4001
4941
|
this.lastSeq = seq;
|
|
4942
|
+
this.positioned = true;
|
|
4002
4943
|
this.deliver(event);
|
|
4003
4944
|
break;
|
|
4004
4945
|
}
|
|
4005
4946
|
case "channel_history": {
|
|
4006
|
-
this.catchUpInFlight = false;
|
|
4007
4947
|
if (this.catchUpTimeout) {
|
|
4008
4948
|
clearTimeout(this.catchUpTimeout);
|
|
4009
4949
|
this.catchUpTimeout = null;
|
|
@@ -4014,6 +4954,9 @@ var RebaseRealtimeChannel = class {
|
|
|
4014
4954
|
retained,
|
|
4015
4955
|
latestSeq
|
|
4016
4956
|
});
|
|
4957
|
+
const wasPositioned = this.positioned;
|
|
4958
|
+
const firstNew = entries.find((entry) => entry.seq > this.lastSeq);
|
|
4959
|
+
if (firstNew && wasPositioned && firstNew.seq > this.lastSeq + 1) this.reportHistoryGap(this.lastSeq + 1, firstNew.seq - 1);
|
|
4017
4960
|
for (const entry of entries) {
|
|
4018
4961
|
if (entry.seq <= this.lastSeq) continue;
|
|
4019
4962
|
this.lastSeq = entry.seq;
|
|
@@ -4024,6 +4967,28 @@ var RebaseRealtimeChannel = class {
|
|
|
4024
4967
|
replayed: true
|
|
4025
4968
|
});
|
|
4026
4969
|
}
|
|
4970
|
+
if (retained) this.positioned = true;
|
|
4971
|
+
const behind = retained && latestSeq !== void 0 && latestSeq > this.lastSeq;
|
|
4972
|
+
if (behind && firstNew) {
|
|
4973
|
+
this.emptyTailSeq = null;
|
|
4974
|
+
this.requestHistory(this.catchUpLimit);
|
|
4975
|
+
break;
|
|
4976
|
+
}
|
|
4977
|
+
if (behind && wasPositioned) {
|
|
4978
|
+
if (this.emptyTailSeq === null) {
|
|
4979
|
+
this.emptyTailSeq = latestSeq;
|
|
4980
|
+
this.requestHistory(this.catchUpLimit);
|
|
4981
|
+
break;
|
|
4982
|
+
}
|
|
4983
|
+
const firstLive = this.pendingLive.reduce((min, event) => event.seq !== void 0 && (min === void 0 || event.seq < min) ? event.seq : min, void 0);
|
|
4984
|
+
const end = firstLive !== void 0 ? Math.min(this.emptyTailSeq, firstLive - 1) : this.emptyTailSeq;
|
|
4985
|
+
if (end > this.lastSeq) {
|
|
4986
|
+
this.reportHistoryGap(this.lastSeq + 1, end);
|
|
4987
|
+
this.lastSeq = end;
|
|
4988
|
+
}
|
|
4989
|
+
}
|
|
4990
|
+
this.emptyTailSeq = null;
|
|
4991
|
+
this.catchUpInFlight = false;
|
|
4027
4992
|
this.flushPendingLive();
|
|
4028
4993
|
break;
|
|
4029
4994
|
}
|
|
@@ -4032,20 +4997,32 @@ var RebaseRealtimeChannel = class {
|
|
|
4032
4997
|
const raw = message.payload?.error;
|
|
4033
4998
|
const asObject = typeof raw === "object" ? raw : void 0;
|
|
4034
4999
|
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
|
-
}
|
|
5000
|
+
this.emitError(new RebaseApiError$1(text, { ...asObject?.code ? { code: asObject.code } : {} }));
|
|
4045
5001
|
break;
|
|
4046
5002
|
}
|
|
4047
5003
|
}
|
|
4048
5004
|
}
|
|
5005
|
+
/** Tell the app that retention dropped messages it never received. */
|
|
5006
|
+
reportHistoryGap(from, to) {
|
|
5007
|
+
this.emitError(new RebaseApiError$1(`Channel "${this.name}" missed messages ${from}–${to}: the server no longer retains them. Resync this channel's state.`, {
|
|
5008
|
+
code: "CHANNEL_HISTORY_GAP",
|
|
5009
|
+
details: {
|
|
5010
|
+
from,
|
|
5011
|
+
to
|
|
5012
|
+
}
|
|
5013
|
+
}));
|
|
5014
|
+
}
|
|
5015
|
+
emitError(error) {
|
|
5016
|
+
if (this.errorHandlers.size === 0) {
|
|
5017
|
+
console.warn(`[Rebase] Channel "${this.name}" error${error.code ? ` (${error.code})` : ""}: ${error.message}. Attach \`channel.onError(...)\` to handle it.`);
|
|
5018
|
+
return;
|
|
5019
|
+
}
|
|
5020
|
+
for (const handler of [...this.errorHandlers]) try {
|
|
5021
|
+
handler(error);
|
|
5022
|
+
} catch (e) {
|
|
5023
|
+
console.error("Error in channel error handler:", e);
|
|
5024
|
+
}
|
|
5025
|
+
}
|
|
4049
5026
|
/** Deliver everything held back during a catch-up, in sequence order. */
|
|
4050
5027
|
flushPendingLive() {
|
|
4051
5028
|
if (this.pendingLive.length === 0) return;
|
|
@@ -4056,6 +5033,7 @@ var RebaseRealtimeChannel = class {
|
|
|
4056
5033
|
if (seq !== void 0) {
|
|
4057
5034
|
if (seq <= this.lastSeq) continue;
|
|
4058
5035
|
this.lastSeq = seq;
|
|
5036
|
+
this.positioned = true;
|
|
4059
5037
|
}
|
|
4060
5038
|
this.deliver(event);
|
|
4061
5039
|
}
|
|
@@ -4200,6 +5178,16 @@ var IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
|
|
|
4200
5178
|
function isIdempotencyInProgressError(error) {
|
|
4201
5179
|
return error instanceof RebaseApiError && error.status === 409 && error.code === IDEMPOTENCY_IN_PROGRESS;
|
|
4202
5180
|
}
|
|
5181
|
+
/**
|
|
5182
|
+
* Is this key held for a different request than the one just sent with it?
|
|
5183
|
+
*
|
|
5184
|
+
* For a create the queue already sent once, that is an answer rather than a
|
|
5185
|
+
* failure: the key is live only because the first attempt got through and
|
|
5186
|
+
* committed, and only its response went missing.
|
|
5187
|
+
*/
|
|
5188
|
+
function isIdempotencyKeyReusedError(error) {
|
|
5189
|
+
return error instanceof RebaseApiError && error.status === 422 && error.code === "IDEMPOTENCY_KEY_REUSED";
|
|
5190
|
+
}
|
|
4203
5191
|
/** Is this failure worth another attempt later? */
|
|
4204
5192
|
function isRetryableError(error) {
|
|
4205
5193
|
if (isNetworkError(error)) return true;
|
|
@@ -4242,12 +5230,18 @@ var ConnectivityMonitor = class {
|
|
|
4242
5230
|
clearTimer;
|
|
4243
5231
|
/** Called when the backoff window expires, to drive an automatic retry. */
|
|
4244
5232
|
onRetryDue;
|
|
5233
|
+
/**
|
|
5234
|
+
* Called when the browser says the connection is back. Its own hook rather
|
|
5235
|
+
* than {@link onRetryDue}: a client with automatic retries off sets no retry
|
|
5236
|
+
* callback, and the `online` event has to wake it all the same.
|
|
5237
|
+
*/
|
|
5238
|
+
onOnline;
|
|
4245
5239
|
handleOnline = () => {
|
|
4246
5240
|
this.retryAt = 0;
|
|
4247
5241
|
this.backoffMs = this.initialBackoffMs;
|
|
4248
5242
|
this.clearPendingTimer();
|
|
4249
5243
|
this.setState("online");
|
|
4250
|
-
this.onRetryDue?.();
|
|
5244
|
+
(this.onOnline ?? this.onRetryDue)?.();
|
|
4251
5245
|
};
|
|
4252
5246
|
handleOffline = () => {
|
|
4253
5247
|
this.setState("offline");
|
|
@@ -4322,6 +5316,7 @@ var ConnectivityMonitor = class {
|
|
|
4322
5316
|
this.clearPendingTimer();
|
|
4323
5317
|
this.listeners.clear();
|
|
4324
5318
|
this.onRetryDue = void 0;
|
|
5319
|
+
this.onOnline = void 0;
|
|
4325
5320
|
}
|
|
4326
5321
|
scheduleRetry(delay) {
|
|
4327
5322
|
this.clearPendingTimer();
|
|
@@ -4550,9 +5545,10 @@ var IndexedDBOfflineStore = class {
|
|
|
4550
5545
|
* would have included them.
|
|
4551
5546
|
* - `ORDER BY` puts nulls last ascending and first descending, which is the
|
|
4552
5547
|
* Postgres default.
|
|
4553
|
-
* -
|
|
4554
|
-
* compared numerically against
|
|
4555
|
-
*
|
|
5548
|
+
* - A filter value may arrive as a string (off a URL, say), so a string is
|
|
5549
|
+
* compared numerically against a number and as an instant against a date.
|
|
5550
|
+
* `["==", "3"]` matches the number `3`, as it does server-side. Two strings
|
|
5551
|
+
* compare as text: `"01234"` is not `"1234"`.
|
|
4556
5552
|
*
|
|
4557
5553
|
* Two things it deliberately approximates, both flagged by
|
|
4558
5554
|
* {@link isExactlyEvaluable}: `searchString` becomes a case-insensitive
|
|
@@ -4592,9 +5588,11 @@ function compareValues(a, b) {
|
|
|
4592
5588
|
const right = toComparable(b);
|
|
4593
5589
|
if (isNullish(left) || isNullish(right)) return void 0;
|
|
4594
5590
|
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
|
-
|
|
5591
|
+
if (isNumeric(left) || isNumeric(right)) {
|
|
5592
|
+
const leftNum = numericOrNaN(left);
|
|
5593
|
+
const rightNum = numericOrNaN(right);
|
|
5594
|
+
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) return leftNum < rightNum ? -1 : leftNum > rightNum ? 1 : 0;
|
|
5595
|
+
}
|
|
4598
5596
|
if (typeof left === "number" || typeof right === "number") {
|
|
4599
5597
|
const leftTime = toTime(left);
|
|
4600
5598
|
const rightTime = toTime(right);
|
|
@@ -4605,6 +5603,9 @@ function compareValues(a, b) {
|
|
|
4605
5603
|
if (collator) return collator.compare(leftStr, rightStr);
|
|
4606
5604
|
return leftStr < rightStr ? -1 : leftStr > rightStr ? 1 : 0;
|
|
4607
5605
|
}
|
|
5606
|
+
function isNumeric(value) {
|
|
5607
|
+
return typeof value === "number" || typeof value === "bigint";
|
|
5608
|
+
}
|
|
4608
5609
|
function numericOrNaN(value) {
|
|
4609
5610
|
if (typeof value === "number") return value;
|
|
4610
5611
|
if (typeof value === "string" && value.trim() !== "") {
|
|
@@ -4621,12 +5622,17 @@ function toTime(value) {
|
|
|
4621
5622
|
return Number.isNaN(t) ? void 0 : t;
|
|
4622
5623
|
}
|
|
4623
5624
|
}
|
|
4624
|
-
/**
|
|
5625
|
+
/**
|
|
5626
|
+
* Equality with a string filter value allowed to name a number, a boolean or
|
|
5627
|
+
* an instant, but never across NULL. Two strings are equal only when they are
|
|
5628
|
+
* the same string, as text is in Postgres.
|
|
5629
|
+
*/
|
|
4625
5630
|
function looseEquals(a, b) {
|
|
4626
5631
|
const left = toComparable(a);
|
|
4627
5632
|
const right = toComparable(b);
|
|
4628
5633
|
if (isNullish(left) || isNullish(right)) return isNullish(left) && isNullish(right);
|
|
4629
5634
|
if (left === right) return true;
|
|
5635
|
+
if (typeof left === "string" && typeof right === "string") return false;
|
|
4630
5636
|
return compareValues(left, right) === 0;
|
|
4631
5637
|
}
|
|
4632
5638
|
/**
|
|
@@ -4711,12 +5717,44 @@ function matchesOperator(rowValue, op, filterValue) {
|
|
|
4711
5717
|
function isTuple(value) {
|
|
4712
5718
|
return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && toCanonicalOp(value[0]) !== void 0;
|
|
4713
5719
|
}
|
|
5720
|
+
/**
|
|
5721
|
+
* One field's condition, as the tuples this evaluator can read.
|
|
5722
|
+
*
|
|
5723
|
+
* The single reading of a condition, for {@link matchesWhere} and
|
|
5724
|
+
* {@link isExactlyEvaluable} both. Each used to have its own, and only the
|
|
5725
|
+
* first was hardened: `{ status: undefined }` (a skipped filter) and the bare
|
|
5726
|
+
* wire string `"eq.active"` both reached `.filter` on a non-array in the
|
|
5727
|
+
* second and threw.
|
|
5728
|
+
*
|
|
5729
|
+
* `unreadable` is set when the condition is there but yields nothing this
|
|
5730
|
+
* build understands — a wire string, an operator from a newer server. Such a
|
|
5731
|
+
* condition filters nothing locally while the server does filter on it, so an
|
|
5732
|
+
* answer that ignored it would be wider than the server's.
|
|
5733
|
+
*/
|
|
5734
|
+
function conditionTuples(condition) {
|
|
5735
|
+
if (condition === void 0) return {
|
|
5736
|
+
tuples: [],
|
|
5737
|
+
unreadable: false
|
|
5738
|
+
};
|
|
5739
|
+
if (isTuple(condition)) return {
|
|
5740
|
+
tuples: [condition],
|
|
5741
|
+
unreadable: false
|
|
5742
|
+
};
|
|
5743
|
+
if (!Array.isArray(condition)) return {
|
|
5744
|
+
tuples: [],
|
|
5745
|
+
unreadable: true
|
|
5746
|
+
};
|
|
5747
|
+
const tuples = condition.filter(isTuple);
|
|
5748
|
+
return {
|
|
5749
|
+
tuples,
|
|
5750
|
+
unreadable: tuples.length !== condition.length || tuples.length === 0
|
|
5751
|
+
};
|
|
5752
|
+
}
|
|
4714
5753
|
/** Evaluate a `where` clause: every field, and every tuple on a field, AND-ed. */
|
|
4715
5754
|
function matchesWhere(row, where) {
|
|
4716
5755
|
if (!where) return true;
|
|
4717
5756
|
for (const [field, condition] of Object.entries(where)) {
|
|
4718
|
-
|
|
4719
|
-
const tuples = isTuple(condition) ? [condition] : Array.isArray(condition) ? condition.filter(isTuple) : [];
|
|
5757
|
+
const { tuples } = conditionTuples(condition);
|
|
4720
5758
|
for (const [rawOp, value] of tuples) {
|
|
4721
5759
|
const op = toCanonicalOp(rawOp) ?? rawOp;
|
|
4722
5760
|
if (!matchesOperator(row[field], op, value)) return false;
|
|
@@ -4830,10 +5868,18 @@ var ORDERING_OPS = /* @__PURE__ */ new Set([
|
|
|
4830
5868
|
">",
|
|
4831
5869
|
">="
|
|
4832
5870
|
]);
|
|
4833
|
-
/**
|
|
4834
|
-
|
|
5871
|
+
/**
|
|
5872
|
+
* Does any condition in this `where` clause order its operands — or say
|
|
5873
|
+
* something this evaluator cannot read at all? Either way the local answer is
|
|
5874
|
+
* not the server's.
|
|
5875
|
+
*/
|
|
5876
|
+
function whereNotExact(where) {
|
|
4835
5877
|
if (!where) return false;
|
|
4836
|
-
for (const condition of Object.values(where))
|
|
5878
|
+
for (const condition of Object.values(where)) {
|
|
5879
|
+
const { tuples, unreadable } = conditionTuples(condition);
|
|
5880
|
+
if (unreadable) return true;
|
|
5881
|
+
if (tuples.some(([op]) => ORDERING_OPS.has(toCanonicalOp(op) ?? op))) return true;
|
|
5882
|
+
}
|
|
4837
5883
|
return false;
|
|
4838
5884
|
}
|
|
4839
5885
|
/** The same question, through an `and(...)`/`or(...)` tree. */
|
|
@@ -4890,7 +5936,7 @@ function isExactlyEvaluable(params) {
|
|
|
4890
5936
|
if (hasAnyInclude(params.include)) return false;
|
|
4891
5937
|
if (params.searchString) return false;
|
|
4892
5938
|
if (params.vectorSearch) return false;
|
|
4893
|
-
if (
|
|
5939
|
+
if (whereNotExact(params.where)) return false;
|
|
4894
5940
|
if (logicalOrders(params.logical)) return false;
|
|
4895
5941
|
if (whereReachesThroughRelation(params.where)) return false;
|
|
4896
5942
|
if (logicalReachesThroughRelation(params.logical)) return false;
|
|
@@ -4911,14 +5957,14 @@ function logicalReachesThroughRelation(condition, depth = 0) {
|
|
|
4911
5957
|
*
|
|
4912
5958
|
* Asked of the rows rather than of the query, because unlike a filter this one
|
|
4913
5959
|
* *is* decidable from the data in hand: {@link compareValues} reaches the
|
|
4914
|
-
* collator only when
|
|
4915
|
-
*
|
|
5960
|
+
* collator only when neither operand is a number, and `toComparable` has
|
|
5961
|
+
* already turned dates and relations into numbers and ids by then. If every
|
|
4916
5962
|
* value on the sort column normalises to a number, the collator is unreachable
|
|
4917
5963
|
* and the local order is the server's order.
|
|
4918
5964
|
*
|
|
4919
5965
|
* A text column is therefore refused — see {@link isExactlyEvaluable} for why
|
|
4920
|
-
* the two cannot be made to agree —
|
|
4921
|
-
*
|
|
5966
|
+
* the two cannot be made to agree — including one holding only digits, which
|
|
5967
|
+
* Postgres orders as text ("10", "100", "9").
|
|
4922
5968
|
*
|
|
4923
5969
|
* Nulls are fine either way: they are ordered by an explicit rule (last
|
|
4924
5970
|
* ascending, first descending) that matches Postgres and never reaches the
|
|
@@ -4933,7 +5979,6 @@ function isLocallySortable(rows, orderBy) {
|
|
|
4933
5979
|
if (isNullish(value)) return true;
|
|
4934
5980
|
if (typeof value === "number" || typeof value === "boolean") return true;
|
|
4935
5981
|
if (typeof value === "bigint") return true;
|
|
4936
|
-
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) return true;
|
|
4937
5982
|
return false;
|
|
4938
5983
|
}));
|
|
4939
5984
|
}
|
|
@@ -4972,6 +6017,27 @@ function offlineError(message) {
|
|
|
4972
6017
|
code: "OFFLINE"
|
|
4973
6018
|
});
|
|
4974
6019
|
}
|
|
6020
|
+
/**
|
|
6021
|
+
* What a write discarded because an earlier write it depends on was refused is
|
|
6022
|
+
* reported with. It was never sent; the refusal that doomed it is its `cause`.
|
|
6023
|
+
*/
|
|
6024
|
+
function dependencyRejected(refused, error) {
|
|
6025
|
+
return new RebaseApiError(`Not sent: an earlier queued write to the same row was refused (${error.message}).`, {
|
|
6026
|
+
status: error instanceof RebaseApiError ? error.status : void 0,
|
|
6027
|
+
code: "DEPENDENCY_REJECTED",
|
|
6028
|
+
details: { rejectedMutationId: refused.mutationId },
|
|
6029
|
+
cause: error
|
|
6030
|
+
});
|
|
6031
|
+
}
|
|
6032
|
+
/**
|
|
6033
|
+
* A refusal about what a write contains — a field the collection no longer
|
|
6034
|
+
* has, one the caller may not write, a value a constraint refuses — rather
|
|
6035
|
+
* than about the row or the caller as a whole.
|
|
6036
|
+
*/
|
|
6037
|
+
function isFieldRefusal(error) {
|
|
6038
|
+
if (!(error instanceof RebaseApiError)) return false;
|
|
6039
|
+
return error.status === 400 || error.status === 403 || error.status === 422;
|
|
6040
|
+
}
|
|
4975
6041
|
function generateOfflineStringId() {
|
|
4976
6042
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
4977
6043
|
return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -5025,6 +6091,29 @@ function optimisticRow(fields, id) {
|
|
|
5025
6091
|
id
|
|
5026
6092
|
};
|
|
5027
6093
|
}
|
|
6094
|
+
/**
|
|
6095
|
+
* Whether a query answers with something narrower than whole rows: `fields`
|
|
6096
|
+
* returns only the columns it names (and the key), and `distinct` collapses
|
|
6097
|
+
* rows into their distinct projections. Either way what comes back is not a
|
|
6098
|
+
* row of the collection, and must not be stored as one.
|
|
6099
|
+
*/
|
|
6100
|
+
function isProjection(params) {
|
|
6101
|
+
return (params?.fields?.length ?? 0) > 0 || params?.distinct === true;
|
|
6102
|
+
}
|
|
6103
|
+
/** `row` narrowed to `keys`: a local row in the shape a projection has. */
|
|
6104
|
+
function pick(row, keys) {
|
|
6105
|
+
const out = {};
|
|
6106
|
+
for (const key of keys) if (key in row) out[key] = row[key];
|
|
6107
|
+
return out;
|
|
6108
|
+
}
|
|
6109
|
+
/** A snapshot as read back from the store, its projected rows revived. */
|
|
6110
|
+
function readSnapshot(value) {
|
|
6111
|
+
const snapshot = value;
|
|
6112
|
+
return snapshot.rows ? {
|
|
6113
|
+
...snapshot,
|
|
6114
|
+
rows: snapshot.rows.map((row) => hydrateRow(row))
|
|
6115
|
+
} : snapshot;
|
|
6116
|
+
}
|
|
5028
6117
|
var MISSING = "\0missing";
|
|
5029
6118
|
/**
|
|
5030
6119
|
* Replays to spend on a mutation whose idempotency key the server is still
|
|
@@ -5037,6 +6126,8 @@ var MISSING = "\0missing";
|
|
|
5037
6126
|
* blocking the queue behind it indefinitely.
|
|
5038
6127
|
*/
|
|
5039
6128
|
var IN_PROGRESS_MIN_RETRIES = 12;
|
|
6129
|
+
/** The scope of whatever is cached and queued while nobody is signed in. */
|
|
6130
|
+
var SIGNED_OUT_SCOPE = "anon";
|
|
5040
6131
|
var OfflineManager = class {
|
|
5041
6132
|
store;
|
|
5042
6133
|
maxCachedQueries;
|
|
@@ -5046,7 +6137,27 @@ var OfflineManager = class {
|
|
|
5046
6137
|
createInner;
|
|
5047
6138
|
inners = /* @__PURE__ */ new Map();
|
|
5048
6139
|
connectivity;
|
|
5049
|
-
scope =
|
|
6140
|
+
scope = SIGNED_OUT_SCOPE;
|
|
6141
|
+
/**
|
|
6142
|
+
* Set while the manager does not know whose data it holds: from
|
|
6143
|
+
* {@link holdScope} until the next {@link setScope}. `scope` is not
|
|
6144
|
+
* consulted meanwhile — no operation gets a {@link ScopeTicket}, and
|
|
6145
|
+
* nothing reads or writes the store.
|
|
6146
|
+
*/
|
|
6147
|
+
scopeHold;
|
|
6148
|
+
/**
|
|
6149
|
+
* Bumped by every change of user, and by `clear()`.
|
|
6150
|
+
*
|
|
6151
|
+
* The load paths always re-checked the scope after their awaits; the write
|
|
6152
|
+
* paths did not. A `find` sent for user A that answered after B had signed
|
|
6153
|
+
* in wrote A's RLS-filtered rows into B's local database, under B's keys,
|
|
6154
|
+
* and showed them to B's observers; a replay acknowledged after the switch
|
|
6155
|
+
* dequeued a key under B's scope that did not exist, so A's write stayed
|
|
6156
|
+
* queued and was sent again when A came back. Every write now carries the
|
|
6157
|
+
* {@link ScopeTicket} its operation started under, and one that is stale
|
|
6158
|
+
* touches nothing that belongs to the new user.
|
|
6159
|
+
*/
|
|
6160
|
+
scopeEpoch = 0;
|
|
5050
6161
|
/** The local database: normalized rows and query snapshots per collection. */
|
|
5051
6162
|
collections = /* @__PURE__ */ new Map();
|
|
5052
6163
|
/** In-memory mirror of the current scope's queue, in replay order. */
|
|
@@ -5103,6 +6214,9 @@ var OfflineManager = class {
|
|
|
5103
6214
|
if (maxBackoffMs > 0) this.connectivity.onRetryDue = () => {
|
|
5104
6215
|
this.sync().catch(() => void 0);
|
|
5105
6216
|
};
|
|
6217
|
+
this.connectivity.onOnline = () => {
|
|
6218
|
+
this.sync().catch(() => void 0);
|
|
6219
|
+
};
|
|
5106
6220
|
this.connectivity.onChange((online) => {
|
|
5107
6221
|
this.patchStatus({ online });
|
|
5108
6222
|
if (online) this.revalidateAll();
|
|
@@ -5115,7 +6229,12 @@ var OfflineManager = class {
|
|
|
5115
6229
|
} catch {}
|
|
5116
6230
|
this.api = {
|
|
5117
6231
|
sync: () => this.sync(),
|
|
5118
|
-
pending: async () => {
|
|
6232
|
+
pending: async (options) => {
|
|
6233
|
+
if (options?.orphaned) {
|
|
6234
|
+
await this.scopeKnown();
|
|
6235
|
+
if (this.scope === SIGNED_OUT_SCOPE) return [];
|
|
6236
|
+
return await this.store.listQueue(`${SIGNED_OUT_SCOPE}|`);
|
|
6237
|
+
}
|
|
5119
6238
|
await this.ensureQueueLoaded();
|
|
5120
6239
|
return this.queue.map((m) => structuredClone(m));
|
|
5121
6240
|
},
|
|
@@ -5124,7 +6243,15 @@ var OfflineManager = class {
|
|
|
5124
6243
|
this.statusListeners.add(listener);
|
|
5125
6244
|
return () => this.statusListeners.delete(listener);
|
|
5126
6245
|
},
|
|
5127
|
-
clear: async () => {
|
|
6246
|
+
clear: async (options) => {
|
|
6247
|
+
await this.scopeKnown();
|
|
6248
|
+
if (options?.orphaned) {
|
|
6249
|
+
if (this.scope === SIGNED_OUT_SCOPE) return;
|
|
6250
|
+
await this.store.clear(`${SIGNED_OUT_SCOPE}|`);
|
|
6251
|
+
this.broadcast({ type: "queue" }, SIGNED_OUT_SCOPE);
|
|
6252
|
+
return;
|
|
6253
|
+
}
|
|
6254
|
+
this.scopeEpoch++;
|
|
5128
6255
|
await this.store.clear(`${this.scope}|`);
|
|
5129
6256
|
this.queue = [];
|
|
5130
6257
|
this.resetCollections();
|
|
@@ -5148,9 +6275,45 @@ var OfflineManager = class {
|
|
|
5148
6275
|
* across a sign-out/sign-in on a shared browser.
|
|
5149
6276
|
*/
|
|
5150
6277
|
setScope(uid) {
|
|
5151
|
-
const next = uid ||
|
|
5152
|
-
|
|
6278
|
+
const next = uid || SIGNED_OUT_SCOPE;
|
|
6279
|
+
const hold = this.scopeHold;
|
|
6280
|
+
if (!hold && next === this.scope) return;
|
|
5153
6281
|
this.scope = next;
|
|
6282
|
+
this.scopeHold = void 0;
|
|
6283
|
+
this.leaveScope();
|
|
6284
|
+
hold?.release();
|
|
6285
|
+
this.revalidateAll();
|
|
6286
|
+
this.sync().catch(() => void 0);
|
|
6287
|
+
}
|
|
6288
|
+
/**
|
|
6289
|
+
* Forget whose data this is until the next {@link setScope}.
|
|
6290
|
+
*
|
|
6291
|
+
* For the time before the client knows who is signed in — a session being
|
|
6292
|
+
* restored on load. Taking that time for "signed out" would put the user's
|
|
6293
|
+
* own writes in the signed-out queue, which no sign-in replays, and show
|
|
6294
|
+
* them the signed-out local database instead of theirs.
|
|
6295
|
+
*
|
|
6296
|
+
* Meanwhile the manager has no scope at all. Every operation — a read, a
|
|
6297
|
+
* write, a replay, `pending()`, `clear()` — waits for one and then runs
|
|
6298
|
+
* for whoever it turns out to be, and nothing reads or writes the store.
|
|
6299
|
+
* A realtime frame arriving in between is handed on as the server sent it
|
|
6300
|
+
* and kept nowhere.
|
|
6301
|
+
*/
|
|
6302
|
+
holdScope() {
|
|
6303
|
+
if (this.scopeHold) return;
|
|
6304
|
+
let release;
|
|
6305
|
+
const known = new Promise((resolve) => {
|
|
6306
|
+
release = resolve;
|
|
6307
|
+
});
|
|
6308
|
+
this.scopeHold = {
|
|
6309
|
+
known,
|
|
6310
|
+
release
|
|
6311
|
+
};
|
|
6312
|
+
this.leaveScope();
|
|
6313
|
+
}
|
|
6314
|
+
/** Let go of the current user's rows and queue: in memory, and on screen. */
|
|
6315
|
+
leaveScope() {
|
|
6316
|
+
this.scopeEpoch++;
|
|
5154
6317
|
this.queueLoad = void 0;
|
|
5155
6318
|
this.queue = [];
|
|
5156
6319
|
this.resetCollections();
|
|
@@ -5160,8 +6323,34 @@ var OfflineManager = class {
|
|
|
5160
6323
|
});
|
|
5161
6324
|
this.notifyQueue();
|
|
5162
6325
|
for (const slug of this.observers.keys()) this.notifyCollection(slug, false);
|
|
5163
|
-
|
|
5164
|
-
|
|
6326
|
+
}
|
|
6327
|
+
/** Settles once the manager knows whose data it holds — see {@link holdScope}. */
|
|
6328
|
+
async scopeKnown() {
|
|
6329
|
+
while (this.scopeHold) await this.scopeHold.known;
|
|
6330
|
+
}
|
|
6331
|
+
/**
|
|
6332
|
+
* The user an operation is running for — see {@link scopeEpoch} — or
|
|
6333
|
+
* nothing while that is not known ({@link holdScope}). An operation takes
|
|
6334
|
+
* it as `this.ticket() ?? await this.nextTicket()`, before its first await.
|
|
6335
|
+
*/
|
|
6336
|
+
ticket() {
|
|
6337
|
+
if (this.scopeHold) return void 0;
|
|
6338
|
+
return {
|
|
6339
|
+
scope: this.scope,
|
|
6340
|
+
epoch: this.scopeEpoch
|
|
6341
|
+
};
|
|
6342
|
+
}
|
|
6343
|
+
/** The ticket of whoever the held scope turns out to be. */
|
|
6344
|
+
async nextTicket() {
|
|
6345
|
+
await this.scopeKnown();
|
|
6346
|
+
return {
|
|
6347
|
+
scope: this.scope,
|
|
6348
|
+
epoch: this.scopeEpoch
|
|
6349
|
+
};
|
|
6350
|
+
}
|
|
6351
|
+
/** Whether an operation's user is still the signed-in one. */
|
|
6352
|
+
isCurrent(ticket) {
|
|
6353
|
+
return ticket.epoch === this.scopeEpoch;
|
|
5165
6354
|
}
|
|
5166
6355
|
/**
|
|
5167
6356
|
* Throw away every local row, for a scope change or an explicit clear.
|
|
@@ -5199,12 +6388,15 @@ var OfflineManager = class {
|
|
|
5199
6388
|
this.inners.set(slug, inner);
|
|
5200
6389
|
const wrapped = {
|
|
5201
6390
|
find: async (params) => {
|
|
6391
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5202
6392
|
const state = await this.ensureCollection(slug);
|
|
5203
6393
|
if (this.connectivity.shouldAttempt()) try {
|
|
5204
6394
|
const res = await inner.find(params);
|
|
5205
6395
|
this.connectivity.markSuccess();
|
|
5206
|
-
|
|
5207
|
-
|
|
6396
|
+
if (!this.isCurrent(ticket)) return res;
|
|
6397
|
+
await this.ingest(slug, res.data ?? [], ticket, isProjection(params));
|
|
6398
|
+
if (!this.isCurrent(ticket)) return res;
|
|
6399
|
+
const snapshot = this.recordSnapshot(slug, params, res, ticket);
|
|
5208
6400
|
const answer = this.answer(slug, params, snapshot);
|
|
5209
6401
|
this.notifyCollection(slug, false);
|
|
5210
6402
|
return {
|
|
@@ -5234,12 +6426,14 @@ var OfflineManager = class {
|
|
|
5234
6426
|
iterate: (params) => paginateFind((p) => wrapped.find(p), params, slug),
|
|
5235
6427
|
findAll: (params) => collectAllPages((p) => wrapped.find(p), params, slug),
|
|
5236
6428
|
findById: async (id) => {
|
|
6429
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5237
6430
|
await this.ensureCollection(slug);
|
|
5238
6431
|
if (this.connectivity.shouldAttempt()) try {
|
|
5239
6432
|
const row = await inner.findById(id);
|
|
5240
6433
|
this.connectivity.markSuccess();
|
|
5241
|
-
if (
|
|
5242
|
-
|
|
6434
|
+
if (!this.isCurrent(ticket)) return row;
|
|
6435
|
+
if (row !== void 0) await this.ingest(slug, [row], ticket);
|
|
6436
|
+
else if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
|
|
5243
6437
|
this.notifyCollection(slug, false);
|
|
5244
6438
|
return this.localRow(slug, id);
|
|
5245
6439
|
} catch (error) {
|
|
@@ -5259,18 +6453,25 @@ var OfflineManager = class {
|
|
|
5259
6453
|
});
|
|
5260
6454
|
return row;
|
|
5261
6455
|
},
|
|
5262
|
-
create: async (data, id) => {
|
|
6456
|
+
create: async (data, id, options) => {
|
|
6457
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5263
6458
|
await this.ensureCollection(slug);
|
|
6459
|
+
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
6460
|
+
let sent = false;
|
|
5264
6461
|
if (this.connectivity.shouldAttempt()) try {
|
|
5265
|
-
const row = await inner.create(data, id
|
|
6462
|
+
const row = await inner.create(data, id, {
|
|
6463
|
+
...options,
|
|
6464
|
+
idempotencyKey
|
|
6465
|
+
});
|
|
5266
6466
|
this.connectivity.markSuccess();
|
|
5267
|
-
await this.ingest(slug, [row]);
|
|
6467
|
+
await this.ingest(slug, [row], ticket);
|
|
5268
6468
|
this.notifyCollection(slug);
|
|
5269
6469
|
this.scheduleRefresh(slug);
|
|
5270
6470
|
return row;
|
|
5271
6471
|
} catch (error) {
|
|
5272
6472
|
if (!isNetworkError(error)) throw error;
|
|
5273
6473
|
this.connectivity.markFailure();
|
|
6474
|
+
sent = true;
|
|
5274
6475
|
}
|
|
5275
6476
|
const providedId = id ?? data.id;
|
|
5276
6477
|
const rowId = providedId ?? this.mintOfflineId(slug);
|
|
@@ -5281,27 +6482,40 @@ var OfflineManager = class {
|
|
|
5281
6482
|
id: rowId,
|
|
5282
6483
|
data: row,
|
|
5283
6484
|
generatedId: providedId === void 0,
|
|
6485
|
+
...sent ? { sent: {
|
|
6486
|
+
idempotencyKey,
|
|
6487
|
+
data: { ...data },
|
|
6488
|
+
id
|
|
6489
|
+
} } : {},
|
|
5284
6490
|
rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
|
|
5285
|
-
});
|
|
5286
|
-
this.setLocalRow(slug, rowId, row);
|
|
6491
|
+
}, ticket);
|
|
6492
|
+
this.setLocalRow(slug, rowId, row, ticket);
|
|
5287
6493
|
this.notifyCollection(slug);
|
|
5288
6494
|
return row;
|
|
5289
6495
|
},
|
|
5290
6496
|
createMany: async (data, options) => {
|
|
6497
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5291
6498
|
await this.ensureCollection(slug);
|
|
5292
6499
|
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
5293
6500
|
if (data.length === 0) return [];
|
|
6501
|
+
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
6502
|
+
let sent = false;
|
|
5294
6503
|
if (this.connectivity.shouldAttempt()) try {
|
|
5295
|
-
const rows = await inner.createMany(data,
|
|
6504
|
+
const rows = await inner.createMany(data, {
|
|
6505
|
+
...options,
|
|
6506
|
+
idempotencyKey
|
|
6507
|
+
});
|
|
5296
6508
|
this.connectivity.markSuccess();
|
|
5297
|
-
await this.ingest(slug, rows);
|
|
6509
|
+
await this.ingest(slug, rows, ticket);
|
|
5298
6510
|
this.notifyCollection(slug);
|
|
5299
6511
|
this.scheduleRefresh(slug);
|
|
5300
6512
|
return rows;
|
|
5301
6513
|
} catch (error) {
|
|
5302
6514
|
if (!isNetworkError(error)) throw error;
|
|
5303
6515
|
this.connectivity.markFailure();
|
|
6516
|
+
sent = true;
|
|
5304
6517
|
}
|
|
6518
|
+
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
6519
|
const rows = data.map((r) => optimisticRow(r, r.id ?? this.mintOfflineId(slug)));
|
|
5306
6520
|
const rollback = {};
|
|
5307
6521
|
for (const row of rows) {
|
|
@@ -5313,9 +6527,14 @@ var OfflineManager = class {
|
|
|
5313
6527
|
type: "createMany",
|
|
5314
6528
|
data: rows,
|
|
5315
6529
|
upsert: options?.upsert,
|
|
6530
|
+
...sent ? { sent: {
|
|
6531
|
+
idempotencyKey,
|
|
6532
|
+
data: data.map((r) => ({ ...r })),
|
|
6533
|
+
...options?.onConflict?.length ? { onConflict: [...options.onConflict] } : {}
|
|
6534
|
+
} } : {},
|
|
5316
6535
|
rollback: { rows: rollback }
|
|
5317
|
-
});
|
|
5318
|
-
for (const row of rows) this.setLocalRow(slug, row.id, row);
|
|
6536
|
+
}, ticket);
|
|
6537
|
+
for (const row of rows) this.setLocalRow(slug, row.id, row, ticket);
|
|
5319
6538
|
this.notifyCollection(slug);
|
|
5320
6539
|
return rows;
|
|
5321
6540
|
},
|
|
@@ -5331,11 +6550,12 @@ var OfflineManager = class {
|
|
|
5331
6550
|
* queue drains — the exact outcome upsert exists to prevent.
|
|
5332
6551
|
*/
|
|
5333
6552
|
upsert: async (data, options) => {
|
|
6553
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5334
6554
|
await this.ensureCollection(slug);
|
|
5335
6555
|
if (this.connectivity.shouldAttempt()) try {
|
|
5336
6556
|
const row = await inner.upsert(data, options);
|
|
5337
6557
|
this.connectivity.markSuccess();
|
|
5338
|
-
await this.ingest(slug, [row]);
|
|
6558
|
+
await this.ingest(slug, [row], ticket);
|
|
5339
6559
|
this.notifyCollection(slug);
|
|
5340
6560
|
this.scheduleRefresh(slug);
|
|
5341
6561
|
return row;
|
|
@@ -5352,12 +6572,13 @@ var OfflineManager = class {
|
|
|
5352
6572
|
data: [row],
|
|
5353
6573
|
upsert: true,
|
|
5354
6574
|
rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
|
|
5355
|
-
});
|
|
5356
|
-
this.setLocalRow(slug, rowId, row);
|
|
6575
|
+
}, ticket);
|
|
6576
|
+
this.setLocalRow(slug, rowId, row, ticket);
|
|
5357
6577
|
this.notifyCollection(slug);
|
|
5358
6578
|
return row;
|
|
5359
6579
|
},
|
|
5360
6580
|
updateMany: async (updates, options) => {
|
|
6581
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5361
6582
|
await this.ensureCollection(slug);
|
|
5362
6583
|
if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
5363
6584
|
if (updates.length === 0) return [];
|
|
@@ -5365,7 +6586,7 @@ var OfflineManager = class {
|
|
|
5365
6586
|
if (this.connectivity.shouldAttempt() && !anyPending) try {
|
|
5366
6587
|
const rows = await inner.updateMany(updates, options);
|
|
5367
6588
|
this.connectivity.markSuccess();
|
|
5368
|
-
await this.ingest(slug, rows);
|
|
6589
|
+
await this.ingest(slug, rows, ticket);
|
|
5369
6590
|
this.notifyCollection(slug);
|
|
5370
6591
|
return rows;
|
|
5371
6592
|
} catch (error) {
|
|
@@ -5390,12 +6611,13 @@ var OfflineManager = class {
|
|
|
5390
6611
|
data: u.data
|
|
5391
6612
|
})),
|
|
5392
6613
|
rollback: { rows: rollback }
|
|
5393
|
-
});
|
|
5394
|
-
for (const row of optimistic) this.setLocalRow(slug, row.id, row);
|
|
6614
|
+
}, ticket);
|
|
6615
|
+
for (const row of optimistic) this.setLocalRow(slug, row.id, row, ticket);
|
|
5395
6616
|
this.notifyCollection(slug);
|
|
5396
6617
|
return optimistic;
|
|
5397
6618
|
},
|
|
5398
6619
|
deleteMany: async (ids, options) => {
|
|
6620
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5399
6621
|
await this.ensureCollection(slug);
|
|
5400
6622
|
if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
|
|
5401
6623
|
if (ids.length === 0) return;
|
|
@@ -5403,7 +6625,7 @@ var OfflineManager = class {
|
|
|
5403
6625
|
if (this.connectivity.shouldAttempt() && !anyPending) try {
|
|
5404
6626
|
await inner.deleteMany(ids, options);
|
|
5405
6627
|
this.connectivity.markSuccess();
|
|
5406
|
-
for (const id of ids) this.removeLocalRow(slug, id, true);
|
|
6628
|
+
for (const id of ids) this.removeLocalRow(slug, id, ticket, true);
|
|
5407
6629
|
this.notifyCollection(slug);
|
|
5408
6630
|
this.scheduleRefresh(slug);
|
|
5409
6631
|
return;
|
|
@@ -5418,66 +6640,94 @@ var OfflineManager = class {
|
|
|
5418
6640
|
type: "deleteMany",
|
|
5419
6641
|
ids,
|
|
5420
6642
|
rollback: { rows: rollback }
|
|
5421
|
-
});
|
|
5422
|
-
for (const id of ids) this.removeLocalRow(slug, id, false);
|
|
6643
|
+
}, ticket);
|
|
6644
|
+
for (const id of ids) this.removeLocalRow(slug, id, ticket, false);
|
|
5423
6645
|
this.notifyCollection(slug);
|
|
5424
6646
|
},
|
|
5425
6647
|
update: async (id, data, options) => {
|
|
6648
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5426
6649
|
await this.ensureCollection(slug);
|
|
6650
|
+
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
6651
|
+
let sent = false;
|
|
6652
|
+
let sendFailure;
|
|
5427
6653
|
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
|
|
5428
|
-
const row = await inner.update(id, data,
|
|
6654
|
+
const row = await inner.update(id, data, {
|
|
6655
|
+
...options,
|
|
6656
|
+
idempotencyKey
|
|
6657
|
+
});
|
|
5429
6658
|
this.connectivity.markSuccess();
|
|
5430
|
-
await this.ingest(slug, [row]);
|
|
6659
|
+
await this.ingest(slug, [row], ticket);
|
|
5431
6660
|
this.notifyCollection(slug);
|
|
5432
6661
|
return row;
|
|
5433
6662
|
} catch (error) {
|
|
5434
6663
|
if (!isNetworkError(error)) throw error;
|
|
5435
6664
|
this.connectivity.markFailure();
|
|
6665
|
+
sent = true;
|
|
6666
|
+
sendFailure = error;
|
|
6667
|
+
}
|
|
6668
|
+
if (hasFieldOperation(data)) {
|
|
6669
|
+
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.`, {
|
|
6670
|
+
status: 0,
|
|
6671
|
+
code: "NETWORK_ERROR",
|
|
6672
|
+
details: { idempotencyKey },
|
|
6673
|
+
cause: sendFailure
|
|
6674
|
+
});
|
|
6675
|
+
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
6676
|
}
|
|
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
6677
|
const base = this.rawLocalRow(slug, id);
|
|
5439
6678
|
await this.enqueue({
|
|
5440
6679
|
collection: slug,
|
|
5441
6680
|
type: "update",
|
|
5442
6681
|
id,
|
|
5443
6682
|
data,
|
|
6683
|
+
...sent ? { sent: { idempotencyKey } } : {},
|
|
5444
6684
|
rollback: { rows: { [String(id)]: base ?? null } }
|
|
5445
|
-
});
|
|
6685
|
+
}, ticket);
|
|
5446
6686
|
const optimistic = optimisticRow({
|
|
5447
6687
|
...base ?? {},
|
|
5448
6688
|
...data
|
|
5449
6689
|
}, id);
|
|
5450
|
-
this.setLocalRow(slug, id, optimistic);
|
|
6690
|
+
this.setLocalRow(slug, id, optimistic, ticket);
|
|
5451
6691
|
this.notifyCollection(slug);
|
|
5452
6692
|
return optimistic;
|
|
5453
6693
|
},
|
|
5454
6694
|
delete: async (id, options) => {
|
|
6695
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5455
6696
|
await this.ensureCollection(slug);
|
|
6697
|
+
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
6698
|
+
let sent = false;
|
|
5456
6699
|
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
|
|
5457
|
-
await inner.delete(id,
|
|
6700
|
+
await inner.delete(id, {
|
|
6701
|
+
...options,
|
|
6702
|
+
idempotencyKey
|
|
6703
|
+
});
|
|
5458
6704
|
this.connectivity.markSuccess();
|
|
5459
|
-
this.removeLocalRow(slug, id, true);
|
|
6705
|
+
this.removeLocalRow(slug, id, ticket, true);
|
|
5460
6706
|
this.notifyCollection(slug);
|
|
5461
6707
|
this.scheduleRefresh(slug);
|
|
5462
6708
|
return;
|
|
5463
6709
|
} catch (error) {
|
|
5464
6710
|
if (!isNetworkError(error)) throw error;
|
|
5465
6711
|
this.connectivity.markFailure();
|
|
6712
|
+
sent = true;
|
|
5466
6713
|
}
|
|
5467
6714
|
await this.enqueue({
|
|
5468
6715
|
collection: slug,
|
|
5469
6716
|
type: "delete",
|
|
5470
6717
|
id,
|
|
6718
|
+
...sent ? { sent: { idempotencyKey } } : {},
|
|
5471
6719
|
rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
|
|
5472
|
-
});
|
|
5473
|
-
this.removeLocalRow(slug, id);
|
|
6720
|
+
}, ticket);
|
|
6721
|
+
this.removeLocalRow(slug, id, ticket);
|
|
5474
6722
|
this.notifyCollection(slug);
|
|
5475
6723
|
},
|
|
5476
6724
|
count: async (params) => {
|
|
6725
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5477
6726
|
await this.ensureCollection(slug);
|
|
5478
6727
|
if (this.connectivity.shouldAttempt()) try {
|
|
5479
6728
|
const n = await inner.count(params);
|
|
5480
6729
|
this.connectivity.markSuccess();
|
|
6730
|
+
if (!this.isCurrent(ticket)) return n;
|
|
5481
6731
|
this.writeCache(this.countKey(slug, params), n);
|
|
5482
6732
|
return Math.max(0, n + this.pendingDelta(slug, params));
|
|
5483
6733
|
} catch (error) {
|
|
@@ -5511,12 +6761,40 @@ var OfflineManager = class {
|
|
|
5511
6761
|
listenById: inner.listenById
|
|
5512
6762
|
};
|
|
5513
6763
|
if (!isUnsupported(inner.listen)) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
|
|
5514
|
-
|
|
5515
|
-
|
|
6764
|
+
const ticket = this.ticket();
|
|
6765
|
+
if (!ticket) {
|
|
6766
|
+
onUpdate(response);
|
|
6767
|
+
return;
|
|
6768
|
+
}
|
|
6769
|
+
this.ingest(slug, response.data ?? [], ticket, isProjection(params)).then(() => {
|
|
6770
|
+
if (!this.isCurrent(ticket)) return;
|
|
6771
|
+
const snapshot = this.recordSnapshot(slug, params, response, ticket);
|
|
6772
|
+
const answer = this.answer(slug, params, snapshot);
|
|
6773
|
+
this.notifyCollection(slug, false);
|
|
6774
|
+
onUpdate({
|
|
6775
|
+
...response,
|
|
6776
|
+
data: answer.data,
|
|
6777
|
+
meta: answer.meta
|
|
6778
|
+
});
|
|
6779
|
+
});
|
|
5516
6780
|
}, onError);
|
|
5517
6781
|
if (!isUnsupported(inner.listenById)) wrapped.listenById = (id, onUpdate, onError) => inner.listenById(id, (row) => {
|
|
5518
|
-
|
|
5519
|
-
|
|
6782
|
+
const ticket = this.ticket();
|
|
6783
|
+
if (!ticket) {
|
|
6784
|
+
onUpdate(row);
|
|
6785
|
+
return;
|
|
6786
|
+
}
|
|
6787
|
+
if (!row) {
|
|
6788
|
+
if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
|
|
6789
|
+
this.notifyCollection(slug, false);
|
|
6790
|
+
onUpdate(row);
|
|
6791
|
+
return;
|
|
6792
|
+
}
|
|
6793
|
+
this.ingest(slug, [row], ticket).then(() => {
|
|
6794
|
+
if (!this.isCurrent(ticket)) return;
|
|
6795
|
+
this.notifyCollection(slug, false);
|
|
6796
|
+
onUpdate(this.localRow(slug, id) ?? row);
|
|
6797
|
+
});
|
|
5520
6798
|
}, onError);
|
|
5521
6799
|
return wrapped;
|
|
5522
6800
|
}
|
|
@@ -5560,8 +6838,10 @@ var OfflineManager = class {
|
|
|
5560
6838
|
if (!closed) observer.emit();
|
|
5561
6839
|
})();
|
|
5562
6840
|
if (options?.realtime !== false && !isUnsupported(inner.listen)) unlisten = inner.listen(params, (response) => {
|
|
5563
|
-
|
|
5564
|
-
|
|
6841
|
+
const ticket = this.ticket();
|
|
6842
|
+
if (!ticket) return;
|
|
6843
|
+
this.ingest(slug, response.data ?? [], ticket, isProjection(params)).then(() => {
|
|
6844
|
+
this.recordSnapshot(slug, params, response, ticket);
|
|
5565
6845
|
this.notifyCollection(slug, false);
|
|
5566
6846
|
});
|
|
5567
6847
|
}, onError);
|
|
@@ -5612,12 +6892,14 @@ var OfflineManager = class {
|
|
|
5612
6892
|
if (!closed) observer.emit();
|
|
5613
6893
|
})();
|
|
5614
6894
|
if (options?.realtime !== false && !isUnsupported(inner.listenById)) unlisten = inner.listenById(id, (row) => {
|
|
6895
|
+
const ticket = this.ticket();
|
|
6896
|
+
if (!ticket) return;
|
|
5615
6897
|
if (!row) {
|
|
5616
|
-
if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, true);
|
|
6898
|
+
if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
|
|
5617
6899
|
this.notifyCollection(slug, false);
|
|
5618
6900
|
return;
|
|
5619
6901
|
}
|
|
5620
|
-
this.ingest(slug, [row]).then(() => this.notifyCollection(slug, false));
|
|
6902
|
+
this.ingest(slug, [row], ticket).then(() => this.notifyCollection(slug, false));
|
|
5621
6903
|
}, onError);
|
|
5622
6904
|
return () => {
|
|
5623
6905
|
closed = true;
|
|
@@ -5638,7 +6920,8 @@ var OfflineManager = class {
|
|
|
5638
6920
|
const state = this.collections.get(slug);
|
|
5639
6921
|
return `${total}|${rows.map((row) => {
|
|
5640
6922
|
const key = String(row.id);
|
|
5641
|
-
|
|
6923
|
+
const entry = state?.rows.get(key);
|
|
6924
|
+
return entry?.row === row ? `${key}:${entry.rev}` : `${key}=${JSON.stringify(row)}`;
|
|
5642
6925
|
}).join(",")}`;
|
|
5643
6926
|
}
|
|
5644
6927
|
notifyCollection(slug, broadcast = true) {
|
|
@@ -5672,6 +6955,7 @@ var OfflineManager = class {
|
|
|
5672
6955
|
return state;
|
|
5673
6956
|
}
|
|
5674
6957
|
ensureCollection(slug) {
|
|
6958
|
+
if (this.scopeHold) return this.scopeKnown().then(() => this.ensureCollection(slug));
|
|
5675
6959
|
const state = this.collectionState(slug);
|
|
5676
6960
|
if (!state.loaded) {
|
|
5677
6961
|
const scope = this.scope;
|
|
@@ -5694,7 +6978,7 @@ var OfflineManager = class {
|
|
|
5694
6978
|
}
|
|
5695
6979
|
for (const entry of snapshots) {
|
|
5696
6980
|
const key = entry.key.slice(`${scope}|q|${slug}|`.length);
|
|
5697
|
-
if (entry.value) state.snapshots.set(key, entry.value);
|
|
6981
|
+
if (entry.value) state.snapshots.set(key, readSnapshot(entry.value));
|
|
5698
6982
|
}
|
|
5699
6983
|
for (const entry of absent) state.absent.add(entry.key.slice(`${scope}|abs|${slug}|`.length));
|
|
5700
6984
|
})().catch(() => void 0).finally(() => {
|
|
@@ -5744,6 +7028,7 @@ var OfflineManager = class {
|
|
|
5744
7028
|
partial: true
|
|
5745
7029
|
};
|
|
5746
7030
|
}
|
|
7031
|
+
if (snapshot.rows) return this.projectedAnswer(slug, params, snapshot, snapshot.rows, fromCache);
|
|
5747
7032
|
const rows = [];
|
|
5748
7033
|
const seen = /* @__PURE__ */ new Set();
|
|
5749
7034
|
/** Rows the server counted that we know are no longer in the result. */
|
|
@@ -5786,6 +7071,71 @@ var OfflineManager = class {
|
|
|
5786
7071
|
partial: !exact || !orderIsLocal
|
|
5787
7072
|
};
|
|
5788
7073
|
}
|
|
7074
|
+
/**
|
|
7075
|
+
* Answer a projected query (`fields`, `distinct`) from the server's own
|
|
7076
|
+
* answer to it.
|
|
7077
|
+
*
|
|
7078
|
+
* The server's rows are the answer. The local database contributes only
|
|
7079
|
+
* what it knows better: a row with unsynced writes shows them, narrowed to
|
|
7080
|
+
* the columns the server sent, or drops out if it was deleted here or
|
|
7081
|
+
* edited out of a filter this side can evaluate; and rows created here
|
|
7082
|
+
* join the first page when there is somewhere to put them. A distinct
|
|
7083
|
+
* query, or an order on a column the projection does not carry, leaves
|
|
7084
|
+
* them out, and the result says it is partial.
|
|
7085
|
+
*/
|
|
7086
|
+
projectedAnswer(slug, params, snapshot, served, fromCache) {
|
|
7087
|
+
const state = this.collectionState(slug);
|
|
7088
|
+
const exact = isExactlyEvaluable(params);
|
|
7089
|
+
const rows = [];
|
|
7090
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7091
|
+
let removed = 0;
|
|
7092
|
+
for (const row of served) {
|
|
7093
|
+
const key = row.id === void 0 || row.id === null ? void 0 : String(row.id);
|
|
7094
|
+
if (key !== void 0) seen.add(key);
|
|
7095
|
+
if (key === void 0 || !this.hasPending(slug, key)) {
|
|
7096
|
+
rows.push(row);
|
|
7097
|
+
continue;
|
|
7098
|
+
}
|
|
7099
|
+
const local = state.rows.get(key)?.row;
|
|
7100
|
+
if (!local || exact && !matchesParams(local, params)) {
|
|
7101
|
+
removed++;
|
|
7102
|
+
continue;
|
|
7103
|
+
}
|
|
7104
|
+
rows.push(pick(local, Object.keys(row)));
|
|
7105
|
+
}
|
|
7106
|
+
let added = 0;
|
|
7107
|
+
let unplaced = false;
|
|
7108
|
+
const offset = snapshot.offset ?? 0;
|
|
7109
|
+
if (exact && offset === 0) {
|
|
7110
|
+
const shape = served.length > 0 ? Object.keys(served[0]) : ["id", ...params?.fields ?? []];
|
|
7111
|
+
const placeable = !params?.distinct && (normalizeOrderBy(params?.orderBy) ?? []).every(([field]) => shape.includes(field));
|
|
7112
|
+
for (const [key, entry] of state.rows) {
|
|
7113
|
+
if (seen.has(key) || !this.hasPending(slug, key)) continue;
|
|
7114
|
+
if (!this.isLocallyCreated(slug, key)) continue;
|
|
7115
|
+
if (!matchesParams(entry.row, params)) continue;
|
|
7116
|
+
if (!placeable) {
|
|
7117
|
+
unplaced = true;
|
|
7118
|
+
continue;
|
|
7119
|
+
}
|
|
7120
|
+
rows.push(pick(entry.row, shape));
|
|
7121
|
+
added++;
|
|
7122
|
+
}
|
|
7123
|
+
}
|
|
7124
|
+
const orderIsLocal = added === 0 || isLocallySortable(rows, params?.orderBy);
|
|
7125
|
+
if (added > 0 && params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
|
|
7126
|
+
return {
|
|
7127
|
+
data: rows,
|
|
7128
|
+
meta: {
|
|
7129
|
+
total: Math.max(rows.length, snapshot.total - removed + added),
|
|
7130
|
+
limit: snapshot.limit,
|
|
7131
|
+
offset,
|
|
7132
|
+
hasMore: snapshot.hasMore
|
|
7133
|
+
},
|
|
7134
|
+
fromCache,
|
|
7135
|
+
hasPendingWrites: rows.some((row) => row.id !== void 0 && row.id !== null && this.hasPending(slug, row.id)),
|
|
7136
|
+
partial: !exact || !orderIsLocal || unplaced
|
|
7137
|
+
};
|
|
7138
|
+
}
|
|
5789
7139
|
localFind(slug, params) {
|
|
5790
7140
|
const state = this.collections.get(slug);
|
|
5791
7141
|
const snapshot = this.snapshotFor(slug, params);
|
|
@@ -5826,7 +7176,8 @@ var OfflineManager = class {
|
|
|
5826
7176
|
localRow(slug, id) {
|
|
5827
7177
|
return this.collections.get(slug)?.rows.get(String(id))?.row;
|
|
5828
7178
|
}
|
|
5829
|
-
setLocalRow(slug, id, row) {
|
|
7179
|
+
setLocalRow(slug, id, row, ticket) {
|
|
7180
|
+
if (!this.isCurrent(ticket)) return;
|
|
5830
7181
|
const state = this.collectionState(slug);
|
|
5831
7182
|
const key = String(id);
|
|
5832
7183
|
const cachedAt = Date.now();
|
|
@@ -5846,7 +7197,8 @@ var OfflineManager = class {
|
|
|
5846
7197
|
* it, opening a deleted row while offline would report a missing local
|
|
5847
7198
|
* database instead of a missing row.
|
|
5848
7199
|
*/
|
|
5849
|
-
removeLocalRow(slug, id, known = false) {
|
|
7200
|
+
removeLocalRow(slug, id, ticket, known = false) {
|
|
7201
|
+
if (!this.isCurrent(ticket)) return;
|
|
5850
7202
|
const state = this.collectionState(slug);
|
|
5851
7203
|
const key = String(id);
|
|
5852
7204
|
const existed = state.rows.delete(key);
|
|
@@ -5869,25 +7221,37 @@ var OfflineManager = class {
|
|
|
5869
7221
|
* Rows that came back unchanged keep their identity and revision, so a
|
|
5870
7222
|
* refetch that changed nothing does not re-render every live query that
|
|
5871
7223
|
* touches them — or rewrite them all to disk.
|
|
7224
|
+
*
|
|
7225
|
+
* A `projection` (see {@link isProjection}) only refreshes the columns it
|
|
7226
|
+
* carries on rows already held, and never creates one. It is not a row:
|
|
7227
|
+
* stored as one, it replaced the full row every other query reads and
|
|
7228
|
+
* wrote the narrowed copy to disk, so a list lost its columns because a
|
|
7229
|
+
* dropdown elsewhere asked for titles.
|
|
5872
7230
|
*/
|
|
5873
|
-
async ingest(slug, rows) {
|
|
5874
|
-
if (rows.length === 0) return;
|
|
7231
|
+
async ingest(slug, rows, ticket, projection = false) {
|
|
7232
|
+
if (rows.length === 0 || !this.isCurrent(ticket)) return;
|
|
5875
7233
|
const state = await this.ensureCollection(slug);
|
|
7234
|
+
if (!this.isCurrent(ticket)) return;
|
|
5876
7235
|
const cachedAt = Date.now();
|
|
5877
7236
|
const writes = [];
|
|
5878
7237
|
const deletes = [];
|
|
5879
7238
|
for (const raw of rows) {
|
|
5880
7239
|
if (!raw || raw.id === void 0 || raw.id === null) continue;
|
|
5881
7240
|
const key = String(raw.id);
|
|
5882
|
-
const
|
|
7241
|
+
const existing = state.rows.get(key);
|
|
7242
|
+
if (projection && !existing) continue;
|
|
7243
|
+
const base = projection && existing ? {
|
|
7244
|
+
...existing.row,
|
|
7245
|
+
...raw
|
|
7246
|
+
} : { ...raw };
|
|
7247
|
+
const merged = this.hasPending(slug, key) ? this.applyPendingToRow(slug, key, base) : base;
|
|
5883
7248
|
if (merged === void 0) {
|
|
5884
7249
|
state.rows.delete(key);
|
|
5885
7250
|
deletes.push(this.rowKey(slug, key));
|
|
5886
7251
|
continue;
|
|
5887
7252
|
}
|
|
5888
7253
|
this.forgetTombstone(slug, key);
|
|
5889
|
-
state.freshRows.add(key);
|
|
5890
|
-
const existing = state.rows.get(key);
|
|
7254
|
+
if (!projection) state.freshRows.add(key);
|
|
5891
7255
|
if (existing && JSON.stringify(existing.row) === JSON.stringify(merged)) {
|
|
5892
7256
|
existing.cachedAt = cachedAt;
|
|
5893
7257
|
continue;
|
|
@@ -5940,7 +7304,7 @@ var OfflineManager = class {
|
|
|
5940
7304
|
}
|
|
5941
7305
|
return row;
|
|
5942
7306
|
}
|
|
5943
|
-
recordSnapshot(slug, params, result) {
|
|
7307
|
+
recordSnapshot(slug, params, result, ticket) {
|
|
5944
7308
|
const window = resolvePagination(params);
|
|
5945
7309
|
const meta = result.meta ?? {
|
|
5946
7310
|
total: result.data?.length ?? 0,
|
|
@@ -5952,13 +7316,18 @@ var OfflineManager = class {
|
|
|
5952
7316
|
total: meta.total ?? result.data?.length ?? 0,
|
|
5953
7317
|
limit: meta.limit ?? window.limit,
|
|
5954
7318
|
offset: meta.offset ?? window.offset,
|
|
5955
|
-
hasMore: meta.hasMore ?? false
|
|
7319
|
+
hasMore: meta.hasMore ?? false,
|
|
7320
|
+
...isProjection(params) ? { rows: (result.data ?? []).map((row) => ({ ...row })) } : {}
|
|
5956
7321
|
};
|
|
7322
|
+
if (!this.isCurrent(ticket)) return snapshot;
|
|
5957
7323
|
const state = this.collectionState(slug);
|
|
5958
7324
|
const key = buildQueryString(params);
|
|
5959
7325
|
state.snapshots.set(key, snapshot);
|
|
5960
7326
|
state.fresh.add(key);
|
|
5961
|
-
this.writeCache(`${this.scope}|q|${slug}|${key}`, snapshot
|
|
7327
|
+
this.writeCache(`${this.scope}|q|${slug}|${key}`, snapshot.rows ? {
|
|
7328
|
+
...snapshot,
|
|
7329
|
+
rows: snapshot.rows.map((row) => dehydrateRow(row))
|
|
7330
|
+
} : snapshot);
|
|
5962
7331
|
this.evictSnapshots(slug);
|
|
5963
7332
|
return snapshot;
|
|
5964
7333
|
}
|
|
@@ -6006,6 +7375,7 @@ var OfflineManager = class {
|
|
|
6006
7375
|
this.deleteCache(doomed.map((key) => `${this.scope}|q|${slug}|${key}`));
|
|
6007
7376
|
}
|
|
6008
7377
|
ensureQueueLoaded() {
|
|
7378
|
+
if (this.scopeHold) return this.scopeKnown().then(() => this.ensureQueueLoaded());
|
|
6009
7379
|
if (!this.queueLoad) {
|
|
6010
7380
|
const scope = this.scope;
|
|
6011
7381
|
this.queueLoad = this.store.listQueue(`${scope}|`).then((queue) => {
|
|
@@ -6017,12 +7387,22 @@ var OfflineManager = class {
|
|
|
6017
7387
|
}
|
|
6018
7388
|
return this.queueLoad;
|
|
6019
7389
|
}
|
|
6020
|
-
enqueue(mutation) {
|
|
7390
|
+
enqueue(mutation, ticket) {
|
|
6021
7391
|
const result = this.enqueueChain.then(async () => {
|
|
6022
7392
|
await this.ensureQueueLoaded();
|
|
7393
|
+
if (!this.isCurrent(ticket)) {
|
|
7394
|
+
const orphan = {
|
|
7395
|
+
...mutation,
|
|
7396
|
+
mutationId: createMutationId(),
|
|
7397
|
+
queuedAt: Date.now()
|
|
7398
|
+
};
|
|
7399
|
+
await this.store.enqueue(`${ticket.scope}|${orphan.mutationId}`, orphan);
|
|
7400
|
+
return;
|
|
7401
|
+
}
|
|
6023
7402
|
if (mutation.type === "update") {
|
|
6024
7403
|
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) {
|
|
7404
|
+
if (tail && tail.mutationId !== this.inFlightId && tail.sent === void 0 && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
|
|
7405
|
+
if (tail.type === "update") tail.parts = [...tail.parts ?? [{ ...tail.data }], { ...mutation.data }];
|
|
6026
7406
|
tail.data = {
|
|
6027
7407
|
...tail.data,
|
|
6028
7408
|
...mutation.data,
|
|
@@ -6033,7 +7413,7 @@ var OfflineManager = class {
|
|
|
6033
7413
|
}
|
|
6034
7414
|
}
|
|
6035
7415
|
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)) {
|
|
7416
|
+
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
7417
|
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
7418
|
for (const op of doomed) await this.store.dequeue(this.queueKey(op));
|
|
6039
7419
|
this.queue = this.queue.filter((m) => !doomed.includes(m));
|
|
@@ -6089,12 +7469,14 @@ var OfflineManager = class {
|
|
|
6089
7469
|
}
|
|
6090
7470
|
sync() {
|
|
6091
7471
|
if (this.flushPromise) return this.flushPromise;
|
|
6092
|
-
|
|
7472
|
+
const run = () => this.withLock(() => this.flush());
|
|
7473
|
+
this.flushPromise = (this.scopeHold ? this.scopeKnown().then(run) : run()).finally(() => {
|
|
6093
7474
|
this.flushPromise = void 0;
|
|
6094
7475
|
});
|
|
6095
7476
|
return this.flushPromise;
|
|
6096
7477
|
}
|
|
6097
7478
|
async flush() {
|
|
7479
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
6098
7480
|
await this.ensureQueueLoaded();
|
|
6099
7481
|
await this.reloadQueue();
|
|
6100
7482
|
if (this.queue.length === 0) return {
|
|
@@ -6106,13 +7488,13 @@ var OfflineManager = class {
|
|
|
6106
7488
|
const queuedAtStart = this.queue.length;
|
|
6107
7489
|
let flushed = 0;
|
|
6108
7490
|
try {
|
|
6109
|
-
while (this.queue.length > 0 && !this.disposed) {
|
|
7491
|
+
while (this.queue.length > 0 && !this.disposed && this.isCurrent(ticket)) {
|
|
6110
7492
|
const op = this.queue[0];
|
|
6111
7493
|
touched.add(op.collection);
|
|
6112
7494
|
this.inFlightId = op.mutationId;
|
|
6113
7495
|
try {
|
|
6114
7496
|
try {
|
|
6115
|
-
await this.replay(op);
|
|
7497
|
+
await this.replay(op, ticket);
|
|
6116
7498
|
} catch (error) {
|
|
6117
7499
|
if (isNetworkError(error)) {
|
|
6118
7500
|
this.connectivity.markFailure();
|
|
@@ -6122,16 +7504,20 @@ var OfflineManager = class {
|
|
|
6122
7504
|
op.lastError = error?.message ?? String(error);
|
|
6123
7505
|
const limit = isIdempotencyInProgressError(error) ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES) : this.maxRetries;
|
|
6124
7506
|
if (isRetryableError(error) && op.attempts < limit) {
|
|
6125
|
-
await this.store.enqueue(
|
|
7507
|
+
await this.store.enqueue(`${ticket.scope}|${op.mutationId}`, op).catch(() => void 0);
|
|
6126
7508
|
this.connectivity.deferRetry();
|
|
6127
7509
|
this.patchStatus({ lastError: op.lastError });
|
|
6128
7510
|
break;
|
|
6129
7511
|
}
|
|
6130
|
-
|
|
7512
|
+
if (isFieldRefusal(error) && (op.parts?.length ?? 0) > 1) {
|
|
7513
|
+
await this.splitCoalesced(op, ticket);
|
|
7514
|
+
continue;
|
|
7515
|
+
}
|
|
7516
|
+
await this.rejectMutation(op, error, ticket);
|
|
6131
7517
|
continue;
|
|
6132
7518
|
}
|
|
6133
7519
|
this.connectivity.markSuccess();
|
|
6134
|
-
await this.drop(op);
|
|
7520
|
+
await this.drop(op, ticket);
|
|
6135
7521
|
flushed++;
|
|
6136
7522
|
} finally {
|
|
6137
7523
|
this.inFlightId = null;
|
|
@@ -6140,6 +7526,10 @@ var OfflineManager = class {
|
|
|
6140
7526
|
} finally {
|
|
6141
7527
|
this.patchStatus({ syncing: false });
|
|
6142
7528
|
}
|
|
7529
|
+
if (!this.isCurrent(ticket)) return {
|
|
7530
|
+
flushed,
|
|
7531
|
+
remaining: this.queue.length
|
|
7532
|
+
};
|
|
6143
7533
|
if (this.queue.length !== queuedAtStart) {
|
|
6144
7534
|
for (const slug of touched) {
|
|
6145
7535
|
this.notifyCollection(slug);
|
|
@@ -6153,42 +7543,65 @@ var OfflineManager = class {
|
|
|
6153
7543
|
remaining: this.queue.length
|
|
6154
7544
|
};
|
|
6155
7545
|
}
|
|
6156
|
-
async replay(op) {
|
|
7546
|
+
async replay(op, ticket) {
|
|
6157
7547
|
const inner = this.innerFor(op.collection);
|
|
6158
7548
|
if (op.type === "create") {
|
|
6159
7549
|
let row;
|
|
6160
7550
|
try {
|
|
6161
|
-
row = await inner.create(op.data, void 0, { idempotencyKey:
|
|
7551
|
+
row = await this.sendQueuedWrite(op, (idempotencyKey) => inner.create(op.data, void 0, { idempotencyKey }), (sent) => inner.create(sent.data, sent.id, { idempotencyKey: sent.idempotencyKey }));
|
|
6162
7552
|
} catch (error) {
|
|
6163
7553
|
if (!(op.generatedId === true && isDuplicateKeyError(error))) throw error;
|
|
6164
|
-
row = await inner.findById(op.id)
|
|
6165
|
-
if (!row)
|
|
7554
|
+
row = await inner.findById(op.id);
|
|
7555
|
+
if (!row) throw error;
|
|
6166
7556
|
}
|
|
6167
|
-
await this.adoptServerRow(op, op.id, row);
|
|
7557
|
+
await this.adoptServerRow(op, op.id, row, ticket);
|
|
6168
7558
|
} else if (op.type === "createMany") {
|
|
6169
7559
|
const queued = op.data ?? [];
|
|
6170
|
-
const
|
|
7560
|
+
const asSent = (sent) => inner.createMany(sent.data, {
|
|
6171
7561
|
...op.upsert ? { upsert: true } : {},
|
|
6172
|
-
|
|
7562
|
+
...sent.onConflict?.length ? { onConflict: sent.onConflict } : {},
|
|
7563
|
+
idempotencyKey: sent.idempotencyKey
|
|
6173
7564
|
});
|
|
6174
|
-
|
|
7565
|
+
const rows = op.sent?.onConflict?.length ? await asSent(op.sent) : await this.sendQueuedWrite(op, (idempotencyKey) => inner.createMany(queued, {
|
|
7566
|
+
...op.upsert ? { upsert: true } : {},
|
|
7567
|
+
idempotencyKey
|
|
7568
|
+
}), asSent);
|
|
7569
|
+
for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i], ticket);
|
|
6175
7570
|
} else if (op.type === "updateMany") {
|
|
6176
7571
|
const queued = op.updates ?? [];
|
|
6177
7572
|
const rows = await inner.updateMany(queued.map((u) => ({
|
|
6178
7573
|
id: u.id,
|
|
6179
7574
|
data: u.data
|
|
6180
7575
|
})), { idempotencyKey: op.mutationId });
|
|
6181
|
-
for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
|
|
7576
|
+
for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i], ticket);
|
|
6182
7577
|
} else if (op.type === "update") {
|
|
6183
|
-
const row = await inner.update(op.id, op.data);
|
|
6184
|
-
await this.ingestReplaced(op, op.id, row);
|
|
7578
|
+
const row = await inner.update(op.id, op.data, op.sent ? { idempotencyKey: op.sent.idempotencyKey } : void 0);
|
|
7579
|
+
await this.ingestReplaced(op, op.id, row, ticket);
|
|
6185
7580
|
} else if (op.type === "deleteMany") {
|
|
6186
7581
|
const ids = op.ids ?? [];
|
|
6187
7582
|
await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
|
|
6188
|
-
for (const id of ids) this.removeLocalRow(op.collection, id, true);
|
|
7583
|
+
for (const id of ids) this.removeLocalRow(op.collection, id, ticket, true);
|
|
6189
7584
|
} else if (op.type === "delete") {
|
|
6190
|
-
await inner.delete(op.id);
|
|
6191
|
-
this.removeLocalRow(op.collection, op.id, true);
|
|
7585
|
+
await inner.delete(op.id, { idempotencyKey: op.sent?.idempotencyKey ?? op.mutationId });
|
|
7586
|
+
this.removeLocalRow(op.collection, op.id, ticket, true);
|
|
7587
|
+
}
|
|
7588
|
+
}
|
|
7589
|
+
/**
|
|
7590
|
+
* Send a queued write under its key.
|
|
7591
|
+
*
|
|
7592
|
+
* A write the queue sent once already, before it was queued, carries that
|
|
7593
|
+
* attempt's key and is sent under it again, as queued. If the server holds
|
|
7594
|
+
* the key for a different request, the difference is the ids minted here
|
|
7595
|
+
* — so the first attempt committed and only its answer was lost, and
|
|
7596
|
+
* sending that request again returns the stored answer instead of a
|
|
7597
|
+
* second copy of the rows.
|
|
7598
|
+
*/
|
|
7599
|
+
async sendQueuedWrite(op, asQueued, asSent) {
|
|
7600
|
+
try {
|
|
7601
|
+
return await asQueued(op.sent?.idempotencyKey ?? op.mutationId);
|
|
7602
|
+
} catch (error) {
|
|
7603
|
+
if (!op.sent || !isIdempotencyKeyReusedError(error)) throw error;
|
|
7604
|
+
return asSent(op.sent);
|
|
6192
7605
|
}
|
|
6193
7606
|
}
|
|
6194
7607
|
/**
|
|
@@ -6199,14 +7612,15 @@ var OfflineManager = class {
|
|
|
6199
7612
|
* has to move with it, including queued writes that were made against it
|
|
6200
7613
|
* before it was ever sent.
|
|
6201
7614
|
*/
|
|
6202
|
-
async adoptServerRow(op, localId, row) {
|
|
7615
|
+
async adoptServerRow(op, localId, row, ticket) {
|
|
6203
7616
|
if (!row) return;
|
|
6204
7617
|
const slug = op.collection;
|
|
6205
7618
|
const serverId = row.id;
|
|
6206
7619
|
if (localId !== void 0 && serverId !== void 0 && String(serverId) !== String(localId)) {
|
|
6207
7620
|
const oldKey = String(localId);
|
|
6208
|
-
this.removeLocalRow(slug, localId);
|
|
6209
|
-
|
|
7621
|
+
this.removeLocalRow(slug, localId, ticket);
|
|
7622
|
+
const queue = this.isCurrent(ticket) ? this.queue : await this.store.listQueue(`${ticket.scope}|`).catch(() => []);
|
|
7623
|
+
for (const queued of queue) {
|
|
6210
7624
|
if (queued.collection !== slug) continue;
|
|
6211
7625
|
let dirty = false;
|
|
6212
7626
|
if (queued.id !== void 0 && String(queued.id) === oldKey) {
|
|
@@ -6220,10 +7634,10 @@ var OfflineManager = class {
|
|
|
6220
7634
|
delete rollbackRows[oldKey];
|
|
6221
7635
|
dirty = true;
|
|
6222
7636
|
}
|
|
6223
|
-
if (dirty) await this.store.enqueue(
|
|
7637
|
+
if (dirty) await this.store.enqueue(`${ticket.scope}|${queued.mutationId}`, queued).catch(() => void 0);
|
|
6224
7638
|
}
|
|
6225
7639
|
}
|
|
6226
|
-
await this.ingestReplaced(op, serverId ?? localId, row);
|
|
7640
|
+
await this.ingestReplaced(op, serverId ?? localId, row, ticket);
|
|
6227
7641
|
}
|
|
6228
7642
|
/**
|
|
6229
7643
|
* Write a server row over the local one, ignoring the mutation that just
|
|
@@ -6233,13 +7647,15 @@ var OfflineManager = class {
|
|
|
6233
7647
|
* back to the server's version in front of the user, only to change again
|
|
6234
7648
|
* when they replay a moment later.
|
|
6235
7649
|
*/
|
|
6236
|
-
async ingestReplaced(op, id, row) {
|
|
7650
|
+
async ingestReplaced(op, id, row, ticket) {
|
|
7651
|
+
if (!this.isCurrent(ticket)) return;
|
|
6237
7652
|
const slug = op.collection;
|
|
6238
7653
|
const state = await this.ensureCollection(slug);
|
|
7654
|
+
if (!this.isCurrent(ticket)) return;
|
|
6239
7655
|
const key = String(id);
|
|
6240
7656
|
const merged = this.applyPendingToRow(slug, key, { ...row }, op.mutationId);
|
|
6241
7657
|
if (merged === void 0) {
|
|
6242
|
-
this.removeLocalRow(slug, key);
|
|
7658
|
+
this.removeLocalRow(slug, key, ticket);
|
|
6243
7659
|
return;
|
|
6244
7660
|
}
|
|
6245
7661
|
const cachedAt = Date.now();
|
|
@@ -6263,7 +7679,8 @@ var OfflineManager = class {
|
|
|
6263
7679
|
* of it, so both stand on their own and are kept — dropping them would
|
|
6264
7680
|
* silently lose writes the server would have accepted.
|
|
6265
7681
|
*/
|
|
6266
|
-
async rejectMutation(op, error) {
|
|
7682
|
+
async rejectMutation(op, error, ticket) {
|
|
7683
|
+
if (!this.isCurrent(ticket)) return;
|
|
6267
7684
|
const ids = new Set(Object.keys(op.rollback?.rows ?? {}));
|
|
6268
7685
|
if (op.id !== void 0) ids.add(String(op.id));
|
|
6269
7686
|
const doomed = [op];
|
|
@@ -6276,24 +7693,61 @@ var OfflineManager = class {
|
|
|
6276
7693
|
if (later.type === "update") doomed.push(later);
|
|
6277
7694
|
else for (const id of hit) orphaned.delete(id);
|
|
6278
7695
|
}
|
|
6279
|
-
for (const dropped of doomed) await this.drop(dropped);
|
|
7696
|
+
for (const dropped of doomed) await this.drop(dropped, ticket);
|
|
7697
|
+
if (!this.isCurrent(ticket)) return;
|
|
6280
7698
|
for (const [idKey, previous] of Object.entries(op.rollback?.rows ?? {})) {
|
|
6281
7699
|
const restored = this.applyPendingToRow(op.collection, idKey, previous ?? void 0);
|
|
6282
|
-
if (restored === void 0) this.removeLocalRow(op.collection, idKey);
|
|
6283
|
-
else this.setLocalRow(op.collection, idKey, restored);
|
|
7700
|
+
if (restored === void 0) this.removeLocalRow(op.collection, idKey, ticket);
|
|
7701
|
+
else this.setLocalRow(op.collection, idKey, restored, ticket);
|
|
6284
7702
|
}
|
|
6285
7703
|
this.patchStatus({ lastError: error.message });
|
|
6286
7704
|
this.notifyCollection(op.collection);
|
|
6287
7705
|
this.scheduleRefresh(op.collection);
|
|
6288
|
-
for (const dropped of doomed) this.onSyncError?.(error, dropped);
|
|
7706
|
+
for (const dropped of doomed) this.onSyncError?.(dropped === op ? error : dependencyRejected(op, error), dropped);
|
|
7707
|
+
}
|
|
7708
|
+
/**
|
|
7709
|
+
* Put the separate edits a coalesced `update` was made of back in the
|
|
7710
|
+
* queue, in its place and in their order, each with the row as it stood
|
|
7711
|
+
* before it as its rollback — so a refusal undoes that edit alone.
|
|
7712
|
+
*
|
|
7713
|
+
* Their ids extend the merged op's, which sorts them after it and before
|
|
7714
|
+
* whatever was queued next, so the queue's order survives a reload.
|
|
7715
|
+
*/
|
|
7716
|
+
async splitCoalesced(op, ticket) {
|
|
7717
|
+
const parts = op.parts ?? [];
|
|
7718
|
+
const idKey = String(op.id);
|
|
7719
|
+
let before = op.rollback?.rows?.[idKey] ?? null;
|
|
7720
|
+
const pieces = parts.map((part, index) => {
|
|
7721
|
+
const piece = {
|
|
7722
|
+
mutationId: `${op.mutationId}:${index}`,
|
|
7723
|
+
collection: op.collection,
|
|
7724
|
+
type: "update",
|
|
7725
|
+
id: op.id,
|
|
7726
|
+
data: { ...part },
|
|
7727
|
+
queuedAt: op.queuedAt,
|
|
7728
|
+
rollback: { rows: { [idKey]: before === null ? null : { ...before } } }
|
|
7729
|
+
};
|
|
7730
|
+
if (before !== null) before = {
|
|
7731
|
+
...before,
|
|
7732
|
+
...part
|
|
7733
|
+
};
|
|
7734
|
+
return piece;
|
|
7735
|
+
});
|
|
7736
|
+
for (const piece of pieces) await this.store.enqueue(`${ticket.scope}|${piece.mutationId}`, piece).catch(() => void 0);
|
|
7737
|
+
await this.store.dequeue(`${ticket.scope}|${op.mutationId}`).catch(() => void 0);
|
|
7738
|
+
if (!this.isCurrent(ticket)) return;
|
|
7739
|
+
const position = this.queue.indexOf(op);
|
|
7740
|
+
this.queue.splice(position < 0 ? 0 : position, position < 0 ? 0 : 1, ...pieces);
|
|
7741
|
+
this.afterQueueChange(false);
|
|
6289
7742
|
}
|
|
6290
7743
|
/** Every row id a mutation writes to. */
|
|
6291
7744
|
idsOf(op) {
|
|
6292
7745
|
if (op.type === "createMany") return (op.data ?? []).map((r) => String(r.id));
|
|
6293
7746
|
return op.id === void 0 ? [] : [String(op.id)];
|
|
6294
7747
|
}
|
|
6295
|
-
async drop(op) {
|
|
6296
|
-
await this.store.dequeue(
|
|
7748
|
+
async drop(op, ticket) {
|
|
7749
|
+
await this.store.dequeue(`${ticket.scope}|${op.mutationId}`).catch(() => void 0);
|
|
7750
|
+
if (!this.isCurrent(ticket)) return;
|
|
6297
7751
|
this.queue = this.queue.filter((m) => m.mutationId !== op.mutationId);
|
|
6298
7752
|
this.afterQueueChange(false);
|
|
6299
7753
|
}
|
|
@@ -6315,12 +7769,12 @@ var OfflineManager = class {
|
|
|
6315
7769
|
return fn();
|
|
6316
7770
|
}
|
|
6317
7771
|
}
|
|
6318
|
-
broadcast(message) {
|
|
7772
|
+
broadcast(message, scope = this.scope) {
|
|
6319
7773
|
if (!this.channel) return;
|
|
6320
7774
|
try {
|
|
6321
7775
|
this.channel.postMessage({
|
|
6322
7776
|
...message,
|
|
6323
|
-
scope
|
|
7777
|
+
scope,
|
|
6324
7778
|
sender: this.tabId
|
|
6325
7779
|
});
|
|
6326
7780
|
} catch {}
|
|
@@ -6328,14 +7782,14 @@ var OfflineManager = class {
|
|
|
6328
7782
|
onBroadcast(message) {
|
|
6329
7783
|
if (this.disposed || !message || typeof message !== "object") return;
|
|
6330
7784
|
const msg = message;
|
|
6331
|
-
if (msg.sender === this.tabId || msg.scope !== this.scope) return;
|
|
7785
|
+
if (this.scopeHold || msg.sender === this.tabId || msg.scope !== this.scope) return;
|
|
6332
7786
|
if (msg.type === "rows") for (const slug of msg.slugs ?? []) this.reloadCollection(slug);
|
|
6333
7787
|
else if (msg.type === "queue") this.reloadQueue();
|
|
6334
7788
|
}
|
|
6335
7789
|
/** Re-read one collection from the store, replacing what is in memory. */
|
|
6336
7790
|
async reloadCollection(slug) {
|
|
6337
7791
|
const state = this.collections.get(slug);
|
|
6338
|
-
if (!state?.loaded) return;
|
|
7792
|
+
if (!state?.loaded || this.scopeHold) return;
|
|
6339
7793
|
await this.reloadQueue();
|
|
6340
7794
|
const scope = this.scope;
|
|
6341
7795
|
const [rows, snapshots, absent] = await Promise.all([
|
|
@@ -6362,12 +7816,13 @@ var OfflineManager = class {
|
|
|
6362
7816
|
state.snapshots = /* @__PURE__ */ new Map();
|
|
6363
7817
|
for (const entry of snapshots) {
|
|
6364
7818
|
const key = entry.key.slice(`${scope}|q|${slug}|`.length);
|
|
6365
|
-
if (entry.value) state.snapshots.set(key, entry.value);
|
|
7819
|
+
if (entry.value) state.snapshots.set(key, readSnapshot(entry.value));
|
|
6366
7820
|
}
|
|
6367
7821
|
state.absent = new Set(absent.map((entry) => entry.key.slice(`${scope}|abs|${slug}|`.length)));
|
|
6368
7822
|
this.notifyCollection(slug, false);
|
|
6369
7823
|
}
|
|
6370
7824
|
async reloadQueue() {
|
|
7825
|
+
if (this.scopeHold) return;
|
|
6371
7826
|
const scope = this.scope;
|
|
6372
7827
|
const queue = await this.store.listQueue(`${scope}|`).catch(() => void 0);
|
|
6373
7828
|
if (!queue || this.scope !== scope) return;
|
|
@@ -6469,6 +7924,7 @@ function createRebaseClient(options) {
|
|
|
6469
7924
|
const cron = createCron(transport, options.cron);
|
|
6470
7925
|
const backups = createBackups(transport);
|
|
6471
7926
|
const apiKeys = createApiKeys(transport, options.apiKeys);
|
|
7927
|
+
const personalKeys = createPersonalKeys(transport, options.personalKeys);
|
|
6472
7928
|
const storage = createStorage(transport);
|
|
6473
7929
|
const functions = createFunctionsClient(transport);
|
|
6474
7930
|
const createStorageSource = (storageId) => storageId === DEFAULT_STORAGE_SOURCE_KEY ? storage : createStorage(transport, storageId);
|
|
@@ -6503,15 +7959,29 @@ function createRebaseClient(options) {
|
|
|
6503
7959
|
let session = auth.getSession();
|
|
6504
7960
|
if (session && session.expiresAt <= Date.now() + 1e4) try {
|
|
6505
7961
|
session = await auth.refreshSession();
|
|
6506
|
-
} catch (e) {
|
|
7962
|
+
} catch (e) {
|
|
7963
|
+
session = auth.getSession();
|
|
7964
|
+
}
|
|
6507
7965
|
return session?.accessToken || options.token || "";
|
|
6508
7966
|
},
|
|
6509
|
-
onUnauthorized: options.onUnauthorized || (() => auth.handleUnauthorized())
|
|
7967
|
+
onUnauthorized: options.onUnauthorized || (() => auth.handleUnauthorized()),
|
|
7968
|
+
impersonate: options.impersonate
|
|
6510
7969
|
});
|
|
7970
|
+
let realtimeUid = auth.getSession()?.user?.uid ?? null;
|
|
6511
7971
|
auth.onAuthStateChange((event, session) => {
|
|
6512
7972
|
if (!ws) return;
|
|
6513
|
-
if (event === "SIGNED_OUT")
|
|
6514
|
-
|
|
7973
|
+
if (event === "SIGNED_OUT") {
|
|
7974
|
+
realtimeUid = null;
|
|
7975
|
+
ws.disconnect();
|
|
7976
|
+
} else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
|
7977
|
+
const uid = session?.user?.uid ?? null;
|
|
7978
|
+
if (uid !== realtimeUid) {
|
|
7979
|
+
realtimeUid = uid;
|
|
7980
|
+
const hadSocket = ws.hasSocket;
|
|
7981
|
+
ws.disconnect();
|
|
7982
|
+
if (hadSocket) ws.ensureConnected();
|
|
7983
|
+
return;
|
|
7984
|
+
}
|
|
6515
7985
|
if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
|
|
6516
7986
|
}
|
|
6517
7987
|
});
|
|
@@ -6556,10 +8026,11 @@ function createRebaseClient(options) {
|
|
|
6556
8026
|
}
|
|
6557
8027
|
const offlineManager = options.offline ? new OfflineManager(typeof options.offline === "object" ? options.offline : {}, (slug) => createCollectionClient(transport, slug)) : void 0;
|
|
6558
8028
|
if (offlineManager) {
|
|
6559
|
-
offlineManager.
|
|
8029
|
+
offlineManager.holdScope();
|
|
6560
8030
|
auth.onAuthStateChange((event, session) => {
|
|
6561
8031
|
offlineManager.setScope(event === "SIGNED_OUT" ? void 0 : session?.user?.uid);
|
|
6562
8032
|
});
|
|
8033
|
+
auth.isInitialized().then(() => offlineManager.setScope(auth.getSession()?.user?.uid));
|
|
6563
8034
|
}
|
|
6564
8035
|
const collectionClients = /* @__PURE__ */ new Map();
|
|
6565
8036
|
let untypedWarned = false;
|
|
@@ -6602,6 +8073,7 @@ function createRebaseClient(options) {
|
|
|
6602
8073
|
cron,
|
|
6603
8074
|
backups,
|
|
6604
8075
|
apiKeys,
|
|
8076
|
+
personalKeys,
|
|
6605
8077
|
functions,
|
|
6606
8078
|
storage,
|
|
6607
8079
|
storageRegistry,
|
|
@@ -6651,21 +8123,30 @@ channel: (name, options) => {
|
|
|
6651
8123
|
const prefix = endpoint.startsWith("/") ? "" : "/";
|
|
6652
8124
|
return transport.request(`${prefix}${endpoint}`, {
|
|
6653
8125
|
method: "POST",
|
|
6654
|
-
body: payload ? JSON.stringify(payload) : void 0
|
|
8126
|
+
body: payload !== void 0 ? JSON.stringify(payload) : void 0
|
|
6655
8127
|
});
|
|
6656
8128
|
},
|
|
6657
|
-
batch: async (operations,
|
|
8129
|
+
batch: async (operations, batchOptions) => {
|
|
6658
8130
|
if (!Array.isArray(operations)) throw new TypeError("batch expects an array of operations.");
|
|
6659
8131
|
if (operations.length === 0) return {
|
|
6660
8132
|
data: [],
|
|
6661
8133
|
meta: { operations: 0 }
|
|
6662
8134
|
};
|
|
6663
8135
|
const headers = {};
|
|
6664
|
-
if (
|
|
6665
|
-
if (
|
|
8136
|
+
if (batchOptions?.idempotencyKey) headers["Idempotency-Key"] = batchOptions.idempotencyKey;
|
|
8137
|
+
if (batchOptions?.returning === false) headers.Prefer = "return=minimal";
|
|
8138
|
+
const dictionary = options.collections;
|
|
8139
|
+
const wire = dictionary ? operations.map((operation) => {
|
|
8140
|
+
if (!operation || typeof operation !== "object") return operation;
|
|
8141
|
+
const name = operation.collection;
|
|
8142
|
+
return typeof name === "string" && Object.hasOwn(dictionary, name) ? {
|
|
8143
|
+
...operation,
|
|
8144
|
+
collection: dictionary[name]
|
|
8145
|
+
} : operation;
|
|
8146
|
+
}) : operations;
|
|
6666
8147
|
return transport.request("/data/_batch", {
|
|
6667
8148
|
method: "POST",
|
|
6668
|
-
body: JSON.stringify({ operations }),
|
|
8149
|
+
body: JSON.stringify({ operations: wire }),
|
|
6669
8150
|
...Object.keys(headers).length > 0 ? { headers } : {}
|
|
6670
8151
|
});
|
|
6671
8152
|
},
|
|
@@ -6674,6 +8155,6 @@ channel: (name, options) => {
|
|
|
6674
8155
|
};
|
|
6675
8156
|
}
|
|
6676
8157
|
//#endregion
|
|
6677
|
-
export { MemoryOfflineStore, QueryBuilder, RebaseApiError, RebaseClientError, RebasePaginationError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, etagOf, isOfflineError, not, or };
|
|
8158
|
+
export { MemoryOfflineStore, QueryBuilder, RebaseApiError, RebaseClientError, RebasePaginationError, RebaseRealtimeChannel, RebaseWebSocketClient, STORAGE_REFERENCE_SCHEME, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, etagOf, isOfflineError, isStorageReference, not, or, parseStorageReference, resolveStorageReferences, storageReference };
|
|
6678
8159
|
|
|
6679
8160
|
//# sourceMappingURL=index.es.js.map
|