@rebasepro/client 0.23.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 +7 -5
- package/dist/api-keys.d.ts +29 -11
- package/dist/auth.d.ts +41 -9
- 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 +36 -11
- package/dist/index.es.js +1097 -202
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +6 -0
- package/dist/offline-store.d.ts +8 -0
- package/dist/offline.d.ts +87 -3
- package/dist/query-contract.types.d.ts +23 -23
- package/dist/realtime-channel.d.ts +5 -0
- package/dist/sdk_query_builder.d.ts +2 -2
- package/dist/sql-script.d.ts +10 -0
- package/dist/transport.d.ts +15 -2
- package/dist/websocket.d.ts +110 -17
- package/package.json +4 -4
package/dist/index.es.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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";
|
|
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
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
|
|
@@ -165,6 +165,7 @@ function buildAggregateQueryString(params) {
|
|
|
165
165
|
parts.push(`select=${encodeURIComponent(select)}`);
|
|
166
166
|
if (params.groupBy && params.groupBy.length > 0) parts.push(`groupBy=${encodeURIComponent(params.groupBy.join(","))}`);
|
|
167
167
|
if (params.limit != null) parts.push(`limit=${params.limit}`);
|
|
168
|
+
if (params.offset != null) parts.push(`offset=${params.offset}`);
|
|
168
169
|
if (params.searchString) parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
|
|
169
170
|
if (params.logical) {
|
|
170
171
|
const root = params.logical;
|
|
@@ -175,6 +176,35 @@ function buildAggregateQueryString(params) {
|
|
|
175
176
|
return "?" + parts.join("&");
|
|
176
177
|
}
|
|
177
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
|
+
/**
|
|
178
208
|
* The base every request and every caller-built URL resolves against.
|
|
179
209
|
*
|
|
180
210
|
* `baseUrl` is optional because the common production shape is a Rebase
|
|
@@ -263,7 +293,8 @@ function createTransport(config, environment) {
|
|
|
263
293
|
}
|
|
264
294
|
const defaultHeaders = {
|
|
265
295
|
...config.headers ?? {},
|
|
266
|
-
...config.schemaVersion ? { [SCHEMA_VERSION_HEADER]: config.schemaVersion } : {}
|
|
296
|
+
...config.schemaVersion ? { [SCHEMA_VERSION_HEADER]: config.schemaVersion } : {},
|
|
297
|
+
...config.impersonate !== void 0 ? { [IMPERSONATE_HEADER]: config.impersonate } : {}
|
|
267
298
|
};
|
|
268
299
|
function getHeaders(activeToken, init) {
|
|
269
300
|
const headers = {
|
|
@@ -379,7 +410,7 @@ function createTransport(config, environment) {
|
|
|
379
410
|
} catch (e) {
|
|
380
411
|
unreadableBody = true;
|
|
381
412
|
}
|
|
382
|
-
if (res.status === 401 && onUnauthorizedHandler) {
|
|
413
|
+
if (res.status === 401 && onUnauthorizedHandler && isTokenRefusal(body)) {
|
|
383
414
|
if (await onUnauthorizedHandler()) {
|
|
384
415
|
let retryToken = token;
|
|
385
416
|
if (tokenGetter) try {
|
|
@@ -466,6 +497,7 @@ function mapRawUser(raw) {
|
|
|
466
497
|
providerId: raw.providerId ?? "password",
|
|
467
498
|
isAnonymous: raw.isAnonymous ?? false,
|
|
468
499
|
emailVerified: raw.emailVerified,
|
|
500
|
+
...raw.pendingEmail !== void 0 ? { pendingEmail: raw.pendingEmail } : {},
|
|
469
501
|
roles: raw.roles,
|
|
470
502
|
metadata: raw.metadata
|
|
471
503
|
};
|
|
@@ -547,13 +579,56 @@ function createAuth(transport, options) {
|
|
|
547
579
|
function getFetch() {
|
|
548
580
|
return transport.fetchFn || globalThis.fetch;
|
|
549
581
|
}
|
|
550
|
-
|
|
551
|
-
|
|
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, {
|
|
552
624
|
status,
|
|
553
625
|
code: body?.error?.code || body?.code,
|
|
554
626
|
details: body?.error?.details || body?.details
|
|
555
627
|
});
|
|
556
628
|
}
|
|
629
|
+
function throwApiError(status, body, statusText) {
|
|
630
|
+
throw apiError(status, body, statusText);
|
|
631
|
+
}
|
|
557
632
|
function emit(event, session) {
|
|
558
633
|
for (const fn of listeners) try {
|
|
559
634
|
fn(event, session);
|
|
@@ -663,6 +738,7 @@ function createAuth(transport, options) {
|
|
|
663
738
|
abandonSessionLocally();
|
|
664
739
|
return;
|
|
665
740
|
}
|
|
741
|
+
if (isMalformedRefreshError(err)) return;
|
|
666
742
|
if (attempt >= MAX_REFRESH_RETRIES) {
|
|
667
743
|
if (err instanceof RebaseApiError && (err.status === 401 || err.status === 403)) abandonSessionLocally();
|
|
668
744
|
return;
|
|
@@ -721,7 +797,7 @@ function createAuth(transport, options) {
|
|
|
721
797
|
const user = mapRawUser(data.user);
|
|
722
798
|
const session = {
|
|
723
799
|
accessToken: data.tokens.accessToken,
|
|
724
|
-
refreshToken: data.tokens.refreshToken
|
|
800
|
+
refreshToken: heldRefreshToken(data.tokens.refreshToken),
|
|
725
801
|
expiresAt: data.tokens.accessTokenExpiresAt,
|
|
726
802
|
user
|
|
727
803
|
};
|
|
@@ -766,8 +842,15 @@ function createAuth(transport, options) {
|
|
|
766
842
|
});
|
|
767
843
|
const body = await res.json().catch(() => ({}));
|
|
768
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
|
+
};
|
|
769
851
|
const session = handleAuthResponse(body, "SIGNED_IN");
|
|
770
852
|
return {
|
|
853
|
+
confirmationRequired: false,
|
|
771
854
|
user: session.user,
|
|
772
855
|
accessToken: session.accessToken,
|
|
773
856
|
refreshToken: session.refreshToken
|
|
@@ -905,7 +988,7 @@ function createAuth(transport, options) {
|
|
|
905
988
|
if (authFlowMode === "cookie" || currentSession?.refreshToken) await fetchFn(authUrl("/logout"), {
|
|
906
989
|
method: "POST",
|
|
907
990
|
headers: { "Content-Type": "application/json" },
|
|
908
|
-
body:
|
|
991
|
+
body: refreshTokenBody(),
|
|
909
992
|
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
910
993
|
});
|
|
911
994
|
} catch (e) {}
|
|
@@ -1028,12 +1111,16 @@ function createAuth(transport, options) {
|
|
|
1028
1111
|
const res = await getFetch()(authUrl("/refresh"), {
|
|
1029
1112
|
method: "POST",
|
|
1030
1113
|
headers: { "Content-Type": "application/json" },
|
|
1031
|
-
body:
|
|
1114
|
+
body: refreshTokenBody(),
|
|
1032
1115
|
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
1033
1116
|
});
|
|
1034
1117
|
const body = await res.json().catch(() => ({}));
|
|
1035
1118
|
if (epoch !== sessionEpoch) return supersededRefresh();
|
|
1036
|
-
if (!res.ok)
|
|
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
|
+
}
|
|
1037
1124
|
const accessToken = body.tokens.accessToken;
|
|
1038
1125
|
transport.setToken(accessToken);
|
|
1039
1126
|
let user = currentSession?.user;
|
|
@@ -1046,7 +1133,7 @@ function createAuth(transport, options) {
|
|
|
1046
1133
|
}
|
|
1047
1134
|
const session = {
|
|
1048
1135
|
accessToken,
|
|
1049
|
-
refreshToken: body.tokens.refreshToken
|
|
1136
|
+
refreshToken: heldRefreshToken(body.tokens.refreshToken),
|
|
1050
1137
|
expiresAt: body.tokens.accessTokenExpiresAt,
|
|
1051
1138
|
user: user ?? EMPTY_USER
|
|
1052
1139
|
};
|
|
@@ -1110,14 +1197,36 @@ function createAuth(transport, options) {
|
|
|
1110
1197
|
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
1111
1198
|
return body;
|
|
1112
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
|
+
*/
|
|
1113
1205
|
async function changePassword(oldPassword, newPassword) {
|
|
1114
|
-
|
|
1206
|
+
if (currentSession && currentSession.expiresAt <= Date.now() + 1e4) await refreshSession().catch(() => void 0);
|
|
1207
|
+
const res = await getFetch()(authUrl("/change-password"), {
|
|
1115
1208
|
method: "POST",
|
|
1209
|
+
headers: {
|
|
1210
|
+
"Content-Type": "application/json",
|
|
1211
|
+
...currentSession?.accessToken ? { Authorization: `Bearer ${currentSession.accessToken}` } : {}
|
|
1212
|
+
},
|
|
1116
1213
|
body: JSON.stringify({
|
|
1117
1214
|
oldPassword,
|
|
1118
1215
|
newPassword
|
|
1119
|
-
})
|
|
1216
|
+
}),
|
|
1217
|
+
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
1120
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
|
+
};
|
|
1121
1230
|
}
|
|
1122
1231
|
/**
|
|
1123
1232
|
* Link an OAuth provider to the **currently signed-in** account.
|
|
@@ -1146,14 +1255,113 @@ function createAuth(transport, options) {
|
|
|
1146
1255
|
async function sendVerificationEmail() {
|
|
1147
1256
|
return transport.request(authPath + "/send-verification", { method: "POST" });
|
|
1148
1257
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
|
1153
1281
|
});
|
|
1154
1282
|
const body = await res.json().catch(() => ({}));
|
|
1155
1283
|
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
1156
|
-
|
|
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
|
+
};
|
|
1157
1365
|
}
|
|
1158
1366
|
async function sendMagicLink(email) {
|
|
1159
1367
|
const res = await getFetch()(authUrl("/magic-link"), {
|
|
@@ -1289,8 +1497,11 @@ function createAuth(transport, options) {
|
|
|
1289
1497
|
* Start enrolling a TOTP factor.
|
|
1290
1498
|
*
|
|
1291
1499
|
* Returns the secret and an `otpauth://` URI to render as a QR code,
|
|
1292
|
-
* plus
|
|
1293
|
-
* 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.
|
|
1294
1505
|
*
|
|
1295
1506
|
* The factor is not usable until `verify` confirms the user's
|
|
1296
1507
|
* authenticator produced a correct code from it.
|
|
@@ -1319,6 +1530,14 @@ function createAuth(transport, options) {
|
|
|
1319
1530
|
* Remove a factor. Requires an `aal2` session — one that has already
|
|
1320
1531
|
* answered a challenge — so a stolen `aal1` token cannot turn MFA off.
|
|
1321
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
|
+
},
|
|
1322
1541
|
async unenroll(factorId) {
|
|
1323
1542
|
return transport.request(authPath + "/mfa/unenroll", {
|
|
1324
1543
|
method: "DELETE",
|
|
@@ -1436,7 +1655,7 @@ function createAuth(transport, options) {
|
|
|
1436
1655
|
transport.setToken(null);
|
|
1437
1656
|
} else {
|
|
1438
1657
|
transport.setToken(currentSession.accessToken);
|
|
1439
|
-
if (autoRefresh) scheduleRetry(0);
|
|
1658
|
+
if (autoRefresh && !isMalformedRefreshError(err)) scheduleRetry(0);
|
|
1440
1659
|
}
|
|
1441
1660
|
resolveInitialized();
|
|
1442
1661
|
});
|
|
@@ -1480,6 +1699,8 @@ function createAuth(transport, options) {
|
|
|
1480
1699
|
linkProvider,
|
|
1481
1700
|
sendVerificationEmail,
|
|
1482
1701
|
verifyEmail,
|
|
1702
|
+
changeEmail,
|
|
1703
|
+
confirmEmailChange,
|
|
1483
1704
|
sendMagicLink,
|
|
1484
1705
|
verifyMagicLink,
|
|
1485
1706
|
sendEmailOtp,
|
|
@@ -1563,6 +1784,10 @@ function createAdmin(transport, options) {
|
|
|
1563
1784
|
body: JSON.stringify(data)
|
|
1564
1785
|
});
|
|
1565
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
|
+
*/
|
|
1566
1791
|
async function updateUser(userId, data) {
|
|
1567
1792
|
return transport.request(adminPath + "/users/" + encodeURIComponent(userId), {
|
|
1568
1793
|
method: "PUT",
|
|
@@ -1572,6 +1797,14 @@ function createAdmin(transport, options) {
|
|
|
1572
1797
|
async function deleteUser(userId) {
|
|
1573
1798
|
return transport.request(adminPath + "/users/" + encodeURIComponent(userId), { method: "DELETE" });
|
|
1574
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
|
+
}
|
|
1575
1808
|
async function resetPassword(userId, options) {
|
|
1576
1809
|
return transport.request(adminPath + "/users/" + encodeURIComponent(userId) + "/reset-password", {
|
|
1577
1810
|
method: "POST",
|
|
@@ -1591,6 +1824,7 @@ function createAdmin(transport, options) {
|
|
|
1591
1824
|
createUser,
|
|
1592
1825
|
updateUser,
|
|
1593
1826
|
deleteUser,
|
|
1827
|
+
resetMfa,
|
|
1594
1828
|
resetPassword,
|
|
1595
1829
|
listRoles,
|
|
1596
1830
|
bootstrap
|
|
@@ -1665,7 +1899,8 @@ function createBackups(transport, options) {
|
|
|
1665
1899
|
//#endregion
|
|
1666
1900
|
//#region src/api-keys.ts
|
|
1667
1901
|
/**
|
|
1668
|
-
* Creates a client for
|
|
1902
|
+
* Creates a client for the project's service keys, via the admin routes.
|
|
1903
|
+
* Needs `keys:read` / `keys:write`.
|
|
1669
1904
|
*
|
|
1670
1905
|
* @param transport - The shared HTTP transport created by `createTransport`.
|
|
1671
1906
|
* @param options - Optional overrides (e.g. a custom base path).
|
|
@@ -1706,6 +1941,43 @@ function createApiKeys(transport, options) {
|
|
|
1706
1941
|
revokeKey
|
|
1707
1942
|
};
|
|
1708
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
|
+
}
|
|
1709
1981
|
//#endregion
|
|
1710
1982
|
//#region src/sdk_query_builder.ts
|
|
1711
1983
|
/**
|
|
@@ -2145,14 +2417,27 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
2145
2417
|
* One statement server-side (`INSERT ... ON CONFLICT DO UPDATE`), so
|
|
2146
2418
|
* unlike a `findById` followed by `create`-or-`update` it cannot lose
|
|
2147
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.
|
|
2148
2429
|
*/
|
|
2149
2430
|
async upsert(data, options) {
|
|
2150
|
-
const
|
|
2151
|
-
return await transport.request(`${basePath}${query}`, {
|
|
2431
|
+
const raw = await transport.request(`${basePath}/bulk`, {
|
|
2152
2432
|
method: "POST",
|
|
2153
|
-
body: JSON.stringify(
|
|
2433
|
+
body: JSON.stringify({
|
|
2434
|
+
rows: [data],
|
|
2435
|
+
upsert: true,
|
|
2436
|
+
...options?.onConflict?.length ? { onConflict: options.onConflict } : {}
|
|
2437
|
+
}),
|
|
2154
2438
|
...writeHeaders(options)
|
|
2155
2439
|
});
|
|
2440
|
+
return options?.returning === false ? void 0 : raw?.data?.[0];
|
|
2156
2441
|
},
|
|
2157
2442
|
async createMany(data, options) {
|
|
2158
2443
|
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
@@ -2346,7 +2631,13 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
2346
2631
|
*/
|
|
2347
2632
|
async aggregate(params) {
|
|
2348
2633
|
const qs = buildAggregateQueryString(params);
|
|
2349
|
-
|
|
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;
|
|
2350
2641
|
},
|
|
2351
2642
|
listen: unsupportedMethod(NO_SOCKET),
|
|
2352
2643
|
listenById: unsupportedMethod(NO_SOCKET)
|
|
@@ -2512,7 +2803,10 @@ function createStorage(transport, storageId) {
|
|
|
2512
2803
|
formData.append("property", context.property);
|
|
2513
2804
|
}
|
|
2514
2805
|
let effectiveKey = key;
|
|
2515
|
-
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
|
+
}
|
|
2516
2810
|
if (effectiveKey) formData.append("key", effectiveKey);
|
|
2517
2811
|
if (bucket) formData.append("bucket", bucket);
|
|
2518
2812
|
if (storageId) formData.append("storageId", storageId);
|
|
@@ -2532,9 +2826,7 @@ function createStorage(transport, storageId) {
|
|
|
2532
2826
|
if (!cachedEntry.expiresAt || cachedEntry.expiresAt > Date.now()) return cachedEntry.config;
|
|
2533
2827
|
urlsCache.delete(cacheKey);
|
|
2534
2828
|
}
|
|
2535
|
-
|
|
2536
|
-
if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://") || filePath.startsWith("gs://"))) filePath = filePath.substring(filePath.indexOf("://") + 3);
|
|
2537
|
-
if (bucket && filePath && !filePath.startsWith(bucket)) filePath = `${bucket}/${filePath}`;
|
|
2829
|
+
const filePath = storageObjectPath(keyOrUrl, bucket);
|
|
2538
2830
|
if (!filePath || filePath.trim() === "" || filePath === "/") return {
|
|
2539
2831
|
url: null,
|
|
2540
2832
|
fileNotFound: true
|
|
@@ -2585,9 +2877,7 @@ function createStorage(transport, storageId) {
|
|
|
2585
2877
|
return new File([blob], fileName, { type: blob.type });
|
|
2586
2878
|
}
|
|
2587
2879
|
async function deleteObject(key, bucket) {
|
|
2588
|
-
|
|
2589
|
-
if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://") || filePath.startsWith("gs://"))) filePath = filePath.substring(filePath.indexOf("://") + 3);
|
|
2590
|
-
if (bucket && filePath && !filePath.startsWith(bucket)) filePath = `${bucket}/${filePath}`;
|
|
2880
|
+
const filePath = storageObjectPath(key, bucket);
|
|
2591
2881
|
if (!filePath || filePath.trim() === "" || filePath === "/") return;
|
|
2592
2882
|
try {
|
|
2593
2883
|
await transport.request(withStorageId(`/storage/file/${encodeStorageKey(filePath)}`), { method: "DELETE" });
|
|
@@ -2670,6 +2960,87 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
|
|
|
2670
2960
|
}
|
|
2671
2961
|
};
|
|
2672
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
|
|
2673
3044
|
//#region src/websocket.ts
|
|
2674
3045
|
/**
|
|
2675
3046
|
* Extract error message and code from a WebSocket message payload.
|
|
@@ -2700,6 +3071,33 @@ function frameError(message) {
|
|
|
2700
3071
|
});
|
|
2701
3072
|
}
|
|
2702
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
|
+
/**
|
|
2703
3101
|
* Broadcast and presence frames.
|
|
2704
3102
|
*
|
|
2705
3103
|
* Fire-and-forget (the server sends no response envelope), and exempt from the
|
|
@@ -2794,16 +3192,56 @@ var RebaseWebSocketClient = class {
|
|
|
2794
3192
|
/** Set by `close()`. Blocks any later operation from silently redialling. */
|
|
2795
3193
|
closedByCaller = false;
|
|
2796
3194
|
/**
|
|
2797
|
-
* Set when the backoff budget ran out, cleared by
|
|
2798
|
-
* fresh one.
|
|
3195
|
+
* Set when the backoff budget ran out with nothing registered, cleared by
|
|
3196
|
+
* anything that earns a fresh one.
|
|
2799
3197
|
*
|
|
2800
|
-
*
|
|
2801
|
-
*
|
|
2802
|
-
*
|
|
2803
|
-
*
|
|
2804
|
-
* 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.
|
|
2805
3202
|
*/
|
|
2806
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
|
+
}
|
|
2807
3245
|
/**
|
|
2808
3246
|
* Whether a socket exists at all (open or still opening).
|
|
2809
3247
|
*
|
|
@@ -2830,6 +3268,18 @@ var RebaseWebSocketClient = class {
|
|
|
2830
3268
|
onReconnect(handler) {
|
|
2831
3269
|
return this.on("reconnect", handler);
|
|
2832
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();
|
|
2833
3283
|
on(event, cb) {
|
|
2834
3284
|
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
2835
3285
|
this.listeners.get(event).add(cb);
|
|
@@ -2844,8 +3294,19 @@ var RebaseWebSocketClient = class {
|
|
|
2844
3294
|
backendToEntityKey = /* @__PURE__ */ new Map();
|
|
2845
3295
|
pendingRequests = /* @__PURE__ */ new Map();
|
|
2846
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
|
+
*/
|
|
2847
3302
|
maxReconnectAttempts = 5;
|
|
2848
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
|
+
/**
|
|
2849
3310
|
* Whether a socket of this client has ever opened — what makes the next
|
|
2850
3311
|
* open a *reconnect*, whose server has forgotten every subscription and
|
|
2851
3312
|
* channel membership and must be told them again.
|
|
@@ -2866,10 +3327,12 @@ var RebaseWebSocketClient = class {
|
|
|
2866
3327
|
WebSocketConstructor;
|
|
2867
3328
|
onUnauthorized;
|
|
2868
3329
|
refreshInProgress = null;
|
|
3330
|
+
impersonate;
|
|
2869
3331
|
constructor(config) {
|
|
2870
3332
|
this.websocketUrl = config.websocketUrl;
|
|
2871
3333
|
this.getAuthToken = config.getAuthToken;
|
|
2872
3334
|
this.onUnauthorized = config.onUnauthorized;
|
|
3335
|
+
this.impersonate = config.impersonate;
|
|
2873
3336
|
this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : void 0);
|
|
2874
3337
|
}
|
|
2875
3338
|
/**
|
|
@@ -2888,29 +3351,58 @@ var RebaseWebSocketClient = class {
|
|
|
2888
3351
|
}
|
|
2889
3352
|
return;
|
|
2890
3353
|
}
|
|
2891
|
-
this.
|
|
3354
|
+
this.installNetworkListeners();
|
|
2892
3355
|
if (this.ws || this.reconnectTimeout) return;
|
|
2893
3356
|
if (this.gaveUp) {
|
|
2894
3357
|
this.gaveUp = false;
|
|
2895
3358
|
this.reconnectAttempts = 0;
|
|
3359
|
+
this.outageReported = false;
|
|
3360
|
+
}
|
|
3361
|
+
if (!this.outageReported) this.setState("connecting");
|
|
3362
|
+
this.initWebSocket();
|
|
3363
|
+
}
|
|
3364
|
+
/**
|
|
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.
|
|
3369
|
+
*/
|
|
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;
|
|
2896
3378
|
}
|
|
3379
|
+
this.gaveUp = false;
|
|
3380
|
+
this.reconnectAttempts = 0;
|
|
2897
3381
|
this.initWebSocket();
|
|
2898
3382
|
}
|
|
2899
3383
|
/**
|
|
2900
|
-
*
|
|
2901
|
-
*
|
|
2902
|
-
* that never subscribes, adds no listener.
|
|
3384
|
+
* `online` and `visibilitychange`. Registered lazily so a Node client, or a
|
|
3385
|
+
* page that never subscribes, adds no listener.
|
|
2903
3386
|
*/
|
|
2904
|
-
|
|
3387
|
+
installNetworkListeners() {
|
|
2905
3388
|
if (this.onlineListener || typeof window === "undefined" || typeof window.addEventListener !== "function") return;
|
|
2906
|
-
this.onlineListener = () =>
|
|
2907
|
-
if (this.closedByCaller || !this.gaveUp) return;
|
|
2908
|
-
console.debug("Network is back — retrying the realtime connection");
|
|
2909
|
-
this.ensureConnected();
|
|
2910
|
-
};
|
|
3389
|
+
this.onlineListener = () => this.retryNow("Network is back");
|
|
2911
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;
|
|
2912
3403
|
}
|
|
2913
3404
|
onlineListener = null;
|
|
3405
|
+
visibilityListener = null;
|
|
2914
3406
|
/**
|
|
2915
3407
|
* Authenticate the WebSocket connection
|
|
2916
3408
|
*/
|
|
@@ -2935,7 +3427,10 @@ var RebaseWebSocketClient = class {
|
|
|
2935
3427
|
const message = {
|
|
2936
3428
|
type: "AUTHENTICATE",
|
|
2937
3429
|
requestId,
|
|
2938
|
-
payload:
|
|
3430
|
+
payload: this.impersonate !== void 0 ? {
|
|
3431
|
+
token,
|
|
3432
|
+
impersonate: this.impersonate
|
|
3433
|
+
} : { token }
|
|
2939
3434
|
};
|
|
2940
3435
|
if (!this.isConnected || !this.ws) this.messageQueue.unshift(message);
|
|
2941
3436
|
else this.ws.send(JSON.stringify(message));
|
|
@@ -2968,16 +3463,17 @@ var RebaseWebSocketClient = class {
|
|
|
2968
3463
|
*/
|
|
2969
3464
|
disconnect(permanent = false) {
|
|
2970
3465
|
if (permanent) this.closedByCaller = true;
|
|
2971
|
-
if (permanent
|
|
2972
|
-
window.removeEventListener("online", this.onlineListener);
|
|
2973
|
-
this.onlineListener = null;
|
|
2974
|
-
}
|
|
3466
|
+
if (permanent) this.removeNetworkListeners();
|
|
2975
3467
|
this.isAuthenticated = false;
|
|
2976
3468
|
this.authPromise = null;
|
|
2977
3469
|
if (this.reconnectTimeout) {
|
|
2978
3470
|
clearTimeout(this.reconnectTimeout);
|
|
2979
3471
|
this.reconnectTimeout = null;
|
|
2980
3472
|
}
|
|
3473
|
+
this.reconnectAttempts = 0;
|
|
3474
|
+
this.outageReported = false;
|
|
3475
|
+
this.gaveUp = false;
|
|
3476
|
+
this.setState(permanent ? "closed" : "idle");
|
|
2981
3477
|
this.suspendSubscribeWatchdogs();
|
|
2982
3478
|
this.forgetCachedData();
|
|
2983
3479
|
if (this.ws) {
|
|
@@ -3006,6 +3502,9 @@ var RebaseWebSocketClient = class {
|
|
|
3006
3502
|
this.hadConnection = true;
|
|
3007
3503
|
this.isConnected = true;
|
|
3008
3504
|
this.reconnectAttempts = 0;
|
|
3505
|
+
this.outageReported = false;
|
|
3506
|
+
this.gaveUp = false;
|
|
3507
|
+
this.setState("connected");
|
|
3009
3508
|
if (this.getAuthToken && !this.isAuthenticated) try {
|
|
3010
3509
|
const token = await this.getAuthToken();
|
|
3011
3510
|
if (token) {
|
|
@@ -3037,14 +3536,11 @@ var RebaseWebSocketClient = class {
|
|
|
3037
3536
|
this.suspendSubscribeWatchdogs();
|
|
3038
3537
|
this.emit("disconnect");
|
|
3039
3538
|
for (const [reqId, request] of this.pendingRequests.entries()) {
|
|
3040
|
-
if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
|
|
3041
|
-
else if (request.message) {
|
|
3042
|
-
request.message._queuedResolve = request.resolve;
|
|
3043
|
-
request.message._queuedReject = request.reject;
|
|
3044
|
-
this.messageQueue.push(request.message);
|
|
3045
|
-
} else request.reject(new RebaseApiError$1("Connection closed"));
|
|
3046
3539
|
this.pendingRequests.delete(reqId);
|
|
3540
|
+
if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
|
|
3541
|
+
else request.reject(connectionLostError());
|
|
3047
3542
|
}
|
|
3543
|
+
this.setState(this.outageReported ? "disconnected" : "reconnecting");
|
|
3048
3544
|
this.attemptReconnect();
|
|
3049
3545
|
};
|
|
3050
3546
|
this.ws.onerror = (error) => {
|
|
@@ -3098,14 +3594,16 @@ var RebaseWebSocketClient = class {
|
|
|
3098
3594
|
return true;
|
|
3099
3595
|
}
|
|
3100
3596
|
attemptReconnect() {
|
|
3101
|
-
if (this.
|
|
3102
|
-
|
|
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.");
|
|
3103
3601
|
this.gaveUp = true;
|
|
3104
|
-
this.failAllPendingSubscriptions(new RebaseApiError$1("Connection lost", { code: "CONNECTION_LOST" }));
|
|
3105
3602
|
return;
|
|
3106
3603
|
}
|
|
3107
|
-
this.reconnectAttempts
|
|
3108
|
-
const
|
|
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);
|
|
3109
3607
|
console.debug(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
3110
3608
|
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
|
|
3111
3609
|
this.reconnectTimeout = setTimeout(() => {
|
|
@@ -3113,6 +3611,60 @@ var RebaseWebSocketClient = class {
|
|
|
3113
3611
|
this.initWebSocket();
|
|
3114
3612
|
}, delay);
|
|
3115
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
|
+
});
|
|
3667
|
+
}
|
|
3116
3668
|
isAuthError(message) {
|
|
3117
3669
|
if (message.type === "AUTH_ERROR") return true;
|
|
3118
3670
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
@@ -3359,20 +3911,62 @@ var RebaseWebSocketClient = class {
|
|
|
3359
3911
|
*/
|
|
3360
3912
|
sendMessage(message) {
|
|
3361
3913
|
const queuedMsg = message;
|
|
3362
|
-
if (queuedMsg._queuedResolve && queuedMsg._queuedReject)
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
queueable._queuedReject = reject;
|
|
3369
|
-
this.messageQueue.push(message);
|
|
3370
|
-
});
|
|
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);
|
|
3371
3920
|
}
|
|
3372
3921
|
return new Promise((resolve, reject) => {
|
|
3373
|
-
|
|
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);
|
|
3374
3956
|
});
|
|
3375
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
|
+
}
|
|
3376
3970
|
async doSendMessage(message, resolve, reject) {
|
|
3377
3971
|
const isSubscriptionFrame = SUBSCRIPTION_MESSAGE_TYPES.has(message.type);
|
|
3378
3972
|
if (message.type !== "AUTHENTICATE" && !CHANNEL_MESSAGE_TYPES.has(message.type) && this.getAuthToken && !this.isAuthenticated) try {
|
|
@@ -3383,28 +3977,23 @@ var RebaseWebSocketClient = class {
|
|
|
3383
3977
|
return;
|
|
3384
3978
|
}
|
|
3385
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();
|
|
3987
|
+
return;
|
|
3988
|
+
}
|
|
3386
3989
|
const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
3387
3990
|
message.requestId = requestId;
|
|
3388
|
-
const expectsResponse =
|
|
3389
|
-
if (expectsResponse && !this.pendingRequests.has(requestId)) {
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
}
|
|
3395
|
-
}, this.requestTimeoutMs);
|
|
3396
|
-
this.pendingRequests.set(requestId, {
|
|
3397
|
-
resolve: (value) => {
|
|
3398
|
-
clearTimeout(timeoutHandle);
|
|
3399
|
-
resolve(value);
|
|
3400
|
-
},
|
|
3401
|
-
reject: (error) => {
|
|
3402
|
-
clearTimeout(timeoutHandle);
|
|
3403
|
-
reject(error);
|
|
3404
|
-
},
|
|
3405
|
-
message
|
|
3406
|
-
});
|
|
3407
|
-
}
|
|
3991
|
+
const expectsResponse = this.expectsResponse(message);
|
|
3992
|
+
if (expectsResponse && !this.pendingRequests.has(requestId)) this.pendingRequests.set(requestId, {
|
|
3993
|
+
resolve,
|
|
3994
|
+
reject,
|
|
3995
|
+
message
|
|
3996
|
+
});
|
|
3408
3997
|
try {
|
|
3409
3998
|
this.ws.send(JSON.stringify(message));
|
|
3410
3999
|
if (!expectsResponse) resolve(void 0);
|
|
@@ -3446,6 +4035,25 @@ var RebaseWebSocketClient = class {
|
|
|
3446
4035
|
}
|
|
3447
4036
|
})).result || [];
|
|
3448
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
|
+
}
|
|
3449
4057
|
async fetchAvailableDatabases() {
|
|
3450
4058
|
return (await this.sendMessage({
|
|
3451
4059
|
type: "FETCH_DATABASES",
|
|
@@ -3627,6 +4235,7 @@ var RebaseWebSocketClient = class {
|
|
|
3627
4235
|
onUpdate,
|
|
3628
4236
|
onError
|
|
3629
4237
|
});
|
|
4238
|
+
this.reportOutageTo(onError);
|
|
3630
4239
|
if (existingSubscription.latestData !== void 0 && existingSubscription.isInitialDataReceived) try {
|
|
3631
4240
|
onUpdate(existingSubscription.latestData, existingSubscription.latestMeta);
|
|
3632
4241
|
} catch (error) {
|
|
@@ -3654,6 +4263,7 @@ var RebaseWebSocketClient = class {
|
|
|
3654
4263
|
onUpdate,
|
|
3655
4264
|
onError
|
|
3656
4265
|
});
|
|
4266
|
+
this.reportOutageTo(onError);
|
|
3657
4267
|
this.collectionSubscriptions.set(subscriptionKey, {
|
|
3658
4268
|
backendSubscriptionId,
|
|
3659
4269
|
callbacks: callbackMap,
|
|
@@ -3689,6 +4299,7 @@ var RebaseWebSocketClient = class {
|
|
|
3689
4299
|
onUpdate,
|
|
3690
4300
|
onError
|
|
3691
4301
|
});
|
|
4302
|
+
this.reportOutageTo(onError);
|
|
3692
4303
|
if (existingSubscription.latestData !== void 0 && existingSubscription.isInitialDataReceived) try {
|
|
3693
4304
|
onUpdate(existingSubscription.latestData);
|
|
3694
4305
|
} catch (error) {
|
|
@@ -3716,6 +4327,7 @@ var RebaseWebSocketClient = class {
|
|
|
3716
4327
|
onUpdate,
|
|
3717
4328
|
onError
|
|
3718
4329
|
});
|
|
4330
|
+
this.reportOutageTo(onError);
|
|
3719
4331
|
this.singleSubscriptions.set(subscriptionKey, {
|
|
3720
4332
|
backendSubscriptionId,
|
|
3721
4333
|
callbacks: callbackMap,
|
|
@@ -3899,20 +4511,6 @@ var RebaseWebSocketClient = class {
|
|
|
3899
4511
|
}, this.subscriptionTimeoutMs);
|
|
3900
4512
|
}
|
|
3901
4513
|
/**
|
|
3902
|
-
* Fail every subscription that never received data. Called when reconnection
|
|
3903
|
-
* is given up on, so views surface an error instead of spinning forever.
|
|
3904
|
-
*/
|
|
3905
|
-
failAllPendingSubscriptions(error) {
|
|
3906
|
-
for (const key of [...this.collectionSubscriptions.keys()]) {
|
|
3907
|
-
const sub = this.collectionSubscriptions.get(key);
|
|
3908
|
-
if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
|
|
3909
|
-
}
|
|
3910
|
-
for (const key of [...this.singleSubscriptions.keys()]) {
|
|
3911
|
-
const sub = this.singleSubscriptions.get(key);
|
|
3912
|
-
if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
|
|
3913
|
-
}
|
|
3914
|
-
}
|
|
3915
|
-
/**
|
|
3916
4514
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
3917
4515
|
* The server wipes subscription state when a client disconnects, so
|
|
3918
4516
|
* we need to re-register everything to resume receiving updates.
|
|
@@ -4100,6 +4698,8 @@ var RebaseRealtimeChannel = class {
|
|
|
4100
4698
|
this.unsubscribers.push(this.transport.onReconnect(() => {
|
|
4101
4699
|
this.rejoin();
|
|
4102
4700
|
}));
|
|
4701
|
+
const onConnectionLost = this.transport.onConnectionLost?.((error) => this.emitError(error));
|
|
4702
|
+
if (onConnectionLost) this.unsubscribers.push(onConnectionLost);
|
|
4103
4703
|
await this.send("join_channel");
|
|
4104
4704
|
await this.send("presence_state");
|
|
4105
4705
|
if (this.wantsHistory) await this.requestHistory();
|
|
@@ -4630,12 +5230,18 @@ var ConnectivityMonitor = class {
|
|
|
4630
5230
|
clearTimer;
|
|
4631
5231
|
/** Called when the backoff window expires, to drive an automatic retry. */
|
|
4632
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;
|
|
4633
5239
|
handleOnline = () => {
|
|
4634
5240
|
this.retryAt = 0;
|
|
4635
5241
|
this.backoffMs = this.initialBackoffMs;
|
|
4636
5242
|
this.clearPendingTimer();
|
|
4637
5243
|
this.setState("online");
|
|
4638
|
-
this.onRetryDue?.();
|
|
5244
|
+
(this.onOnline ?? this.onRetryDue)?.();
|
|
4639
5245
|
};
|
|
4640
5246
|
handleOffline = () => {
|
|
4641
5247
|
this.setState("offline");
|
|
@@ -4710,6 +5316,7 @@ var ConnectivityMonitor = class {
|
|
|
4710
5316
|
this.clearPendingTimer();
|
|
4711
5317
|
this.listeners.clear();
|
|
4712
5318
|
this.onRetryDue = void 0;
|
|
5319
|
+
this.onOnline = void 0;
|
|
4713
5320
|
}
|
|
4714
5321
|
scheduleRetry(delay) {
|
|
4715
5322
|
this.clearPendingTimer();
|
|
@@ -5110,12 +5717,44 @@ function matchesOperator(rowValue, op, filterValue) {
|
|
|
5110
5717
|
function isTuple(value) {
|
|
5111
5718
|
return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && toCanonicalOp(value[0]) !== void 0;
|
|
5112
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
|
+
}
|
|
5113
5753
|
/** Evaluate a `where` clause: every field, and every tuple on a field, AND-ed. */
|
|
5114
5754
|
function matchesWhere(row, where) {
|
|
5115
5755
|
if (!where) return true;
|
|
5116
5756
|
for (const [field, condition] of Object.entries(where)) {
|
|
5117
|
-
|
|
5118
|
-
const tuples = isTuple(condition) ? [condition] : Array.isArray(condition) ? condition.filter(isTuple) : [];
|
|
5757
|
+
const { tuples } = conditionTuples(condition);
|
|
5119
5758
|
for (const [rawOp, value] of tuples) {
|
|
5120
5759
|
const op = toCanonicalOp(rawOp) ?? rawOp;
|
|
5121
5760
|
if (!matchesOperator(row[field], op, value)) return false;
|
|
@@ -5229,10 +5868,18 @@ var ORDERING_OPS = /* @__PURE__ */ new Set([
|
|
|
5229
5868
|
">",
|
|
5230
5869
|
">="
|
|
5231
5870
|
]);
|
|
5232
|
-
/**
|
|
5233
|
-
|
|
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) {
|
|
5234
5877
|
if (!where) return false;
|
|
5235
|
-
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
|
+
}
|
|
5236
5883
|
return false;
|
|
5237
5884
|
}
|
|
5238
5885
|
/** The same question, through an `and(...)`/`or(...)` tree. */
|
|
@@ -5289,7 +5936,7 @@ function isExactlyEvaluable(params) {
|
|
|
5289
5936
|
if (hasAnyInclude(params.include)) return false;
|
|
5290
5937
|
if (params.searchString) return false;
|
|
5291
5938
|
if (params.vectorSearch) return false;
|
|
5292
|
-
if (
|
|
5939
|
+
if (whereNotExact(params.where)) return false;
|
|
5293
5940
|
if (logicalOrders(params.logical)) return false;
|
|
5294
5941
|
if (whereReachesThroughRelation(params.where)) return false;
|
|
5295
5942
|
if (logicalReachesThroughRelation(params.logical)) return false;
|
|
@@ -5370,6 +6017,27 @@ function offlineError(message) {
|
|
|
5370
6017
|
code: "OFFLINE"
|
|
5371
6018
|
});
|
|
5372
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
|
+
}
|
|
5373
6041
|
function generateOfflineStringId() {
|
|
5374
6042
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
5375
6043
|
return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -5458,6 +6126,8 @@ var MISSING = "\0missing";
|
|
|
5458
6126
|
* blocking the queue behind it indefinitely.
|
|
5459
6127
|
*/
|
|
5460
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";
|
|
5461
6131
|
var OfflineManager = class {
|
|
5462
6132
|
store;
|
|
5463
6133
|
maxCachedQueries;
|
|
@@ -5467,7 +6137,27 @@ var OfflineManager = class {
|
|
|
5467
6137
|
createInner;
|
|
5468
6138
|
inners = /* @__PURE__ */ new Map();
|
|
5469
6139
|
connectivity;
|
|
5470
|
-
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;
|
|
5471
6161
|
/** The local database: normalized rows and query snapshots per collection. */
|
|
5472
6162
|
collections = /* @__PURE__ */ new Map();
|
|
5473
6163
|
/** In-memory mirror of the current scope's queue, in replay order. */
|
|
@@ -5524,6 +6214,9 @@ var OfflineManager = class {
|
|
|
5524
6214
|
if (maxBackoffMs > 0) this.connectivity.onRetryDue = () => {
|
|
5525
6215
|
this.sync().catch(() => void 0);
|
|
5526
6216
|
};
|
|
6217
|
+
this.connectivity.onOnline = () => {
|
|
6218
|
+
this.sync().catch(() => void 0);
|
|
6219
|
+
};
|
|
5527
6220
|
this.connectivity.onChange((online) => {
|
|
5528
6221
|
this.patchStatus({ online });
|
|
5529
6222
|
if (online) this.revalidateAll();
|
|
@@ -5536,7 +6229,12 @@ var OfflineManager = class {
|
|
|
5536
6229
|
} catch {}
|
|
5537
6230
|
this.api = {
|
|
5538
6231
|
sync: () => this.sync(),
|
|
5539
|
-
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
|
+
}
|
|
5540
6238
|
await this.ensureQueueLoaded();
|
|
5541
6239
|
return this.queue.map((m) => structuredClone(m));
|
|
5542
6240
|
},
|
|
@@ -5545,7 +6243,15 @@ var OfflineManager = class {
|
|
|
5545
6243
|
this.statusListeners.add(listener);
|
|
5546
6244
|
return () => this.statusListeners.delete(listener);
|
|
5547
6245
|
},
|
|
5548
|
-
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++;
|
|
5549
6255
|
await this.store.clear(`${this.scope}|`);
|
|
5550
6256
|
this.queue = [];
|
|
5551
6257
|
this.resetCollections();
|
|
@@ -5569,9 +6275,45 @@ var OfflineManager = class {
|
|
|
5569
6275
|
* across a sign-out/sign-in on a shared browser.
|
|
5570
6276
|
*/
|
|
5571
6277
|
setScope(uid) {
|
|
5572
|
-
const next = uid ||
|
|
5573
|
-
|
|
6278
|
+
const next = uid || SIGNED_OUT_SCOPE;
|
|
6279
|
+
const hold = this.scopeHold;
|
|
6280
|
+
if (!hold && next === this.scope) return;
|
|
5574
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++;
|
|
5575
6317
|
this.queueLoad = void 0;
|
|
5576
6318
|
this.queue = [];
|
|
5577
6319
|
this.resetCollections();
|
|
@@ -5581,8 +6323,34 @@ var OfflineManager = class {
|
|
|
5581
6323
|
});
|
|
5582
6324
|
this.notifyQueue();
|
|
5583
6325
|
for (const slug of this.observers.keys()) this.notifyCollection(slug, false);
|
|
5584
|
-
|
|
5585
|
-
|
|
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;
|
|
5586
6354
|
}
|
|
5587
6355
|
/**
|
|
5588
6356
|
* Throw away every local row, for a scope change or an explicit clear.
|
|
@@ -5620,12 +6388,15 @@ var OfflineManager = class {
|
|
|
5620
6388
|
this.inners.set(slug, inner);
|
|
5621
6389
|
const wrapped = {
|
|
5622
6390
|
find: async (params) => {
|
|
6391
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5623
6392
|
const state = await this.ensureCollection(slug);
|
|
5624
6393
|
if (this.connectivity.shouldAttempt()) try {
|
|
5625
6394
|
const res = await inner.find(params);
|
|
5626
6395
|
this.connectivity.markSuccess();
|
|
5627
|
-
|
|
5628
|
-
|
|
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);
|
|
5629
6400
|
const answer = this.answer(slug, params, snapshot);
|
|
5630
6401
|
this.notifyCollection(slug, false);
|
|
5631
6402
|
return {
|
|
@@ -5655,12 +6426,14 @@ var OfflineManager = class {
|
|
|
5655
6426
|
iterate: (params) => paginateFind((p) => wrapped.find(p), params, slug),
|
|
5656
6427
|
findAll: (params) => collectAllPages((p) => wrapped.find(p), params, slug),
|
|
5657
6428
|
findById: async (id) => {
|
|
6429
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5658
6430
|
await this.ensureCollection(slug);
|
|
5659
6431
|
if (this.connectivity.shouldAttempt()) try {
|
|
5660
6432
|
const row = await inner.findById(id);
|
|
5661
6433
|
this.connectivity.markSuccess();
|
|
5662
|
-
if (
|
|
5663
|
-
|
|
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);
|
|
5664
6437
|
this.notifyCollection(slug, false);
|
|
5665
6438
|
return this.localRow(slug, id);
|
|
5666
6439
|
} catch (error) {
|
|
@@ -5681,6 +6454,7 @@ var OfflineManager = class {
|
|
|
5681
6454
|
return row;
|
|
5682
6455
|
},
|
|
5683
6456
|
create: async (data, id, options) => {
|
|
6457
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5684
6458
|
await this.ensureCollection(slug);
|
|
5685
6459
|
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
5686
6460
|
let sent = false;
|
|
@@ -5690,7 +6464,7 @@ var OfflineManager = class {
|
|
|
5690
6464
|
idempotencyKey
|
|
5691
6465
|
});
|
|
5692
6466
|
this.connectivity.markSuccess();
|
|
5693
|
-
await this.ingest(slug, [row]);
|
|
6467
|
+
await this.ingest(slug, [row], ticket);
|
|
5694
6468
|
this.notifyCollection(slug);
|
|
5695
6469
|
this.scheduleRefresh(slug);
|
|
5696
6470
|
return row;
|
|
@@ -5714,12 +6488,13 @@ var OfflineManager = class {
|
|
|
5714
6488
|
id
|
|
5715
6489
|
} } : {},
|
|
5716
6490
|
rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
|
|
5717
|
-
});
|
|
5718
|
-
this.setLocalRow(slug, rowId, row);
|
|
6491
|
+
}, ticket);
|
|
6492
|
+
this.setLocalRow(slug, rowId, row, ticket);
|
|
5719
6493
|
this.notifyCollection(slug);
|
|
5720
6494
|
return row;
|
|
5721
6495
|
},
|
|
5722
6496
|
createMany: async (data, options) => {
|
|
6497
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5723
6498
|
await this.ensureCollection(slug);
|
|
5724
6499
|
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
5725
6500
|
if (data.length === 0) return [];
|
|
@@ -5731,7 +6506,7 @@ var OfflineManager = class {
|
|
|
5731
6506
|
idempotencyKey
|
|
5732
6507
|
});
|
|
5733
6508
|
this.connectivity.markSuccess();
|
|
5734
|
-
await this.ingest(slug, rows);
|
|
6509
|
+
await this.ingest(slug, rows, ticket);
|
|
5735
6510
|
this.notifyCollection(slug);
|
|
5736
6511
|
this.scheduleRefresh(slug);
|
|
5737
6512
|
return rows;
|
|
@@ -5758,8 +6533,8 @@ var OfflineManager = class {
|
|
|
5758
6533
|
...options?.onConflict?.length ? { onConflict: [...options.onConflict] } : {}
|
|
5759
6534
|
} } : {},
|
|
5760
6535
|
rollback: { rows: rollback }
|
|
5761
|
-
});
|
|
5762
|
-
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);
|
|
5763
6538
|
this.notifyCollection(slug);
|
|
5764
6539
|
return rows;
|
|
5765
6540
|
},
|
|
@@ -5775,11 +6550,12 @@ var OfflineManager = class {
|
|
|
5775
6550
|
* queue drains — the exact outcome upsert exists to prevent.
|
|
5776
6551
|
*/
|
|
5777
6552
|
upsert: async (data, options) => {
|
|
6553
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5778
6554
|
await this.ensureCollection(slug);
|
|
5779
6555
|
if (this.connectivity.shouldAttempt()) try {
|
|
5780
6556
|
const row = await inner.upsert(data, options);
|
|
5781
6557
|
this.connectivity.markSuccess();
|
|
5782
|
-
await this.ingest(slug, [row]);
|
|
6558
|
+
await this.ingest(slug, [row], ticket);
|
|
5783
6559
|
this.notifyCollection(slug);
|
|
5784
6560
|
this.scheduleRefresh(slug);
|
|
5785
6561
|
return row;
|
|
@@ -5796,12 +6572,13 @@ var OfflineManager = class {
|
|
|
5796
6572
|
data: [row],
|
|
5797
6573
|
upsert: true,
|
|
5798
6574
|
rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
|
|
5799
|
-
});
|
|
5800
|
-
this.setLocalRow(slug, rowId, row);
|
|
6575
|
+
}, ticket);
|
|
6576
|
+
this.setLocalRow(slug, rowId, row, ticket);
|
|
5801
6577
|
this.notifyCollection(slug);
|
|
5802
6578
|
return row;
|
|
5803
6579
|
},
|
|
5804
6580
|
updateMany: async (updates, options) => {
|
|
6581
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5805
6582
|
await this.ensureCollection(slug);
|
|
5806
6583
|
if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
5807
6584
|
if (updates.length === 0) return [];
|
|
@@ -5809,7 +6586,7 @@ var OfflineManager = class {
|
|
|
5809
6586
|
if (this.connectivity.shouldAttempt() && !anyPending) try {
|
|
5810
6587
|
const rows = await inner.updateMany(updates, options);
|
|
5811
6588
|
this.connectivity.markSuccess();
|
|
5812
|
-
await this.ingest(slug, rows);
|
|
6589
|
+
await this.ingest(slug, rows, ticket);
|
|
5813
6590
|
this.notifyCollection(slug);
|
|
5814
6591
|
return rows;
|
|
5815
6592
|
} catch (error) {
|
|
@@ -5834,12 +6611,13 @@ var OfflineManager = class {
|
|
|
5834
6611
|
data: u.data
|
|
5835
6612
|
})),
|
|
5836
6613
|
rollback: { rows: rollback }
|
|
5837
|
-
});
|
|
5838
|
-
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);
|
|
5839
6616
|
this.notifyCollection(slug);
|
|
5840
6617
|
return optimistic;
|
|
5841
6618
|
},
|
|
5842
6619
|
deleteMany: async (ids, options) => {
|
|
6620
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5843
6621
|
await this.ensureCollection(slug);
|
|
5844
6622
|
if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
|
|
5845
6623
|
if (ids.length === 0) return;
|
|
@@ -5847,7 +6625,7 @@ var OfflineManager = class {
|
|
|
5847
6625
|
if (this.connectivity.shouldAttempt() && !anyPending) try {
|
|
5848
6626
|
await inner.deleteMany(ids, options);
|
|
5849
6627
|
this.connectivity.markSuccess();
|
|
5850
|
-
for (const id of ids) this.removeLocalRow(slug, id, true);
|
|
6628
|
+
for (const id of ids) this.removeLocalRow(slug, id, ticket, true);
|
|
5851
6629
|
this.notifyCollection(slug);
|
|
5852
6630
|
this.scheduleRefresh(slug);
|
|
5853
6631
|
return;
|
|
@@ -5862,11 +6640,12 @@ var OfflineManager = class {
|
|
|
5862
6640
|
type: "deleteMany",
|
|
5863
6641
|
ids,
|
|
5864
6642
|
rollback: { rows: rollback }
|
|
5865
|
-
});
|
|
5866
|
-
for (const id of ids) this.removeLocalRow(slug, id, false);
|
|
6643
|
+
}, ticket);
|
|
6644
|
+
for (const id of ids) this.removeLocalRow(slug, id, ticket, false);
|
|
5867
6645
|
this.notifyCollection(slug);
|
|
5868
6646
|
},
|
|
5869
6647
|
update: async (id, data, options) => {
|
|
6648
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5870
6649
|
await this.ensureCollection(slug);
|
|
5871
6650
|
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
5872
6651
|
let sent = false;
|
|
@@ -5877,7 +6656,7 @@ var OfflineManager = class {
|
|
|
5877
6656
|
idempotencyKey
|
|
5878
6657
|
});
|
|
5879
6658
|
this.connectivity.markSuccess();
|
|
5880
|
-
await this.ingest(slug, [row]);
|
|
6659
|
+
await this.ingest(slug, [row], ticket);
|
|
5881
6660
|
this.notifyCollection(slug);
|
|
5882
6661
|
return row;
|
|
5883
6662
|
} catch (error) {
|
|
@@ -5903,16 +6682,17 @@ var OfflineManager = class {
|
|
|
5903
6682
|
data,
|
|
5904
6683
|
...sent ? { sent: { idempotencyKey } } : {},
|
|
5905
6684
|
rollback: { rows: { [String(id)]: base ?? null } }
|
|
5906
|
-
});
|
|
6685
|
+
}, ticket);
|
|
5907
6686
|
const optimistic = optimisticRow({
|
|
5908
6687
|
...base ?? {},
|
|
5909
6688
|
...data
|
|
5910
6689
|
}, id);
|
|
5911
|
-
this.setLocalRow(slug, id, optimistic);
|
|
6690
|
+
this.setLocalRow(slug, id, optimistic, ticket);
|
|
5912
6691
|
this.notifyCollection(slug);
|
|
5913
6692
|
return optimistic;
|
|
5914
6693
|
},
|
|
5915
6694
|
delete: async (id, options) => {
|
|
6695
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5916
6696
|
await this.ensureCollection(slug);
|
|
5917
6697
|
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
5918
6698
|
let sent = false;
|
|
@@ -5922,7 +6702,7 @@ var OfflineManager = class {
|
|
|
5922
6702
|
idempotencyKey
|
|
5923
6703
|
});
|
|
5924
6704
|
this.connectivity.markSuccess();
|
|
5925
|
-
this.removeLocalRow(slug, id, true);
|
|
6705
|
+
this.removeLocalRow(slug, id, ticket, true);
|
|
5926
6706
|
this.notifyCollection(slug);
|
|
5927
6707
|
this.scheduleRefresh(slug);
|
|
5928
6708
|
return;
|
|
@@ -5937,15 +6717,17 @@ var OfflineManager = class {
|
|
|
5937
6717
|
id,
|
|
5938
6718
|
...sent ? { sent: { idempotencyKey } } : {},
|
|
5939
6719
|
rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
|
|
5940
|
-
});
|
|
5941
|
-
this.removeLocalRow(slug, id);
|
|
6720
|
+
}, ticket);
|
|
6721
|
+
this.removeLocalRow(slug, id, ticket);
|
|
5942
6722
|
this.notifyCollection(slug);
|
|
5943
6723
|
},
|
|
5944
6724
|
count: async (params) => {
|
|
6725
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
5945
6726
|
await this.ensureCollection(slug);
|
|
5946
6727
|
if (this.connectivity.shouldAttempt()) try {
|
|
5947
6728
|
const n = await inner.count(params);
|
|
5948
6729
|
this.connectivity.markSuccess();
|
|
6730
|
+
if (!this.isCurrent(ticket)) return n;
|
|
5949
6731
|
this.writeCache(this.countKey(slug, params), n);
|
|
5950
6732
|
return Math.max(0, n + this.pendingDelta(slug, params));
|
|
5951
6733
|
} catch (error) {
|
|
@@ -5979,12 +6761,40 @@ var OfflineManager = class {
|
|
|
5979
6761
|
listenById: inner.listenById
|
|
5980
6762
|
};
|
|
5981
6763
|
if (!isUnsupported(inner.listen)) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
|
|
5982
|
-
|
|
5983
|
-
|
|
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
|
+
});
|
|
5984
6780
|
}, onError);
|
|
5985
6781
|
if (!isUnsupported(inner.listenById)) wrapped.listenById = (id, onUpdate, onError) => inner.listenById(id, (row) => {
|
|
5986
|
-
|
|
5987
|
-
|
|
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
|
+
});
|
|
5988
6798
|
}, onError);
|
|
5989
6799
|
return wrapped;
|
|
5990
6800
|
}
|
|
@@ -6028,8 +6838,10 @@ var OfflineManager = class {
|
|
|
6028
6838
|
if (!closed) observer.emit();
|
|
6029
6839
|
})();
|
|
6030
6840
|
if (options?.realtime !== false && !isUnsupported(inner.listen)) unlisten = inner.listen(params, (response) => {
|
|
6031
|
-
|
|
6032
|
-
|
|
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);
|
|
6033
6845
|
this.notifyCollection(slug, false);
|
|
6034
6846
|
});
|
|
6035
6847
|
}, onError);
|
|
@@ -6080,12 +6892,14 @@ var OfflineManager = class {
|
|
|
6080
6892
|
if (!closed) observer.emit();
|
|
6081
6893
|
})();
|
|
6082
6894
|
if (options?.realtime !== false && !isUnsupported(inner.listenById)) unlisten = inner.listenById(id, (row) => {
|
|
6895
|
+
const ticket = this.ticket();
|
|
6896
|
+
if (!ticket) return;
|
|
6083
6897
|
if (!row) {
|
|
6084
|
-
if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, true);
|
|
6898
|
+
if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
|
|
6085
6899
|
this.notifyCollection(slug, false);
|
|
6086
6900
|
return;
|
|
6087
6901
|
}
|
|
6088
|
-
this.ingest(slug, [row]).then(() => this.notifyCollection(slug, false));
|
|
6902
|
+
this.ingest(slug, [row], ticket).then(() => this.notifyCollection(slug, false));
|
|
6089
6903
|
}, onError);
|
|
6090
6904
|
return () => {
|
|
6091
6905
|
closed = true;
|
|
@@ -6141,6 +6955,7 @@ var OfflineManager = class {
|
|
|
6141
6955
|
return state;
|
|
6142
6956
|
}
|
|
6143
6957
|
ensureCollection(slug) {
|
|
6958
|
+
if (this.scopeHold) return this.scopeKnown().then(() => this.ensureCollection(slug));
|
|
6144
6959
|
const state = this.collectionState(slug);
|
|
6145
6960
|
if (!state.loaded) {
|
|
6146
6961
|
const scope = this.scope;
|
|
@@ -6361,7 +7176,8 @@ var OfflineManager = class {
|
|
|
6361
7176
|
localRow(slug, id) {
|
|
6362
7177
|
return this.collections.get(slug)?.rows.get(String(id))?.row;
|
|
6363
7178
|
}
|
|
6364
|
-
setLocalRow(slug, id, row) {
|
|
7179
|
+
setLocalRow(slug, id, row, ticket) {
|
|
7180
|
+
if (!this.isCurrent(ticket)) return;
|
|
6365
7181
|
const state = this.collectionState(slug);
|
|
6366
7182
|
const key = String(id);
|
|
6367
7183
|
const cachedAt = Date.now();
|
|
@@ -6381,7 +7197,8 @@ var OfflineManager = class {
|
|
|
6381
7197
|
* it, opening a deleted row while offline would report a missing local
|
|
6382
7198
|
* database instead of a missing row.
|
|
6383
7199
|
*/
|
|
6384
|
-
removeLocalRow(slug, id, known = false) {
|
|
7200
|
+
removeLocalRow(slug, id, ticket, known = false) {
|
|
7201
|
+
if (!this.isCurrent(ticket)) return;
|
|
6385
7202
|
const state = this.collectionState(slug);
|
|
6386
7203
|
const key = String(id);
|
|
6387
7204
|
const existed = state.rows.delete(key);
|
|
@@ -6411,9 +7228,10 @@ var OfflineManager = class {
|
|
|
6411
7228
|
* wrote the narrowed copy to disk, so a list lost its columns because a
|
|
6412
7229
|
* dropdown elsewhere asked for titles.
|
|
6413
7230
|
*/
|
|
6414
|
-
async ingest(slug, rows, projection = false) {
|
|
6415
|
-
if (rows.length === 0) return;
|
|
7231
|
+
async ingest(slug, rows, ticket, projection = false) {
|
|
7232
|
+
if (rows.length === 0 || !this.isCurrent(ticket)) return;
|
|
6416
7233
|
const state = await this.ensureCollection(slug);
|
|
7234
|
+
if (!this.isCurrent(ticket)) return;
|
|
6417
7235
|
const cachedAt = Date.now();
|
|
6418
7236
|
const writes = [];
|
|
6419
7237
|
const deletes = [];
|
|
@@ -6486,7 +7304,7 @@ var OfflineManager = class {
|
|
|
6486
7304
|
}
|
|
6487
7305
|
return row;
|
|
6488
7306
|
}
|
|
6489
|
-
recordSnapshot(slug, params, result) {
|
|
7307
|
+
recordSnapshot(slug, params, result, ticket) {
|
|
6490
7308
|
const window = resolvePagination(params);
|
|
6491
7309
|
const meta = result.meta ?? {
|
|
6492
7310
|
total: result.data?.length ?? 0,
|
|
@@ -6501,6 +7319,7 @@ var OfflineManager = class {
|
|
|
6501
7319
|
hasMore: meta.hasMore ?? false,
|
|
6502
7320
|
...isProjection(params) ? { rows: (result.data ?? []).map((row) => ({ ...row })) } : {}
|
|
6503
7321
|
};
|
|
7322
|
+
if (!this.isCurrent(ticket)) return snapshot;
|
|
6504
7323
|
const state = this.collectionState(slug);
|
|
6505
7324
|
const key = buildQueryString(params);
|
|
6506
7325
|
state.snapshots.set(key, snapshot);
|
|
@@ -6556,6 +7375,7 @@ var OfflineManager = class {
|
|
|
6556
7375
|
this.deleteCache(doomed.map((key) => `${this.scope}|q|${slug}|${key}`));
|
|
6557
7376
|
}
|
|
6558
7377
|
ensureQueueLoaded() {
|
|
7378
|
+
if (this.scopeHold) return this.scopeKnown().then(() => this.ensureQueueLoaded());
|
|
6559
7379
|
if (!this.queueLoad) {
|
|
6560
7380
|
const scope = this.scope;
|
|
6561
7381
|
this.queueLoad = this.store.listQueue(`${scope}|`).then((queue) => {
|
|
@@ -6567,12 +7387,22 @@ var OfflineManager = class {
|
|
|
6567
7387
|
}
|
|
6568
7388
|
return this.queueLoad;
|
|
6569
7389
|
}
|
|
6570
|
-
enqueue(mutation) {
|
|
7390
|
+
enqueue(mutation, ticket) {
|
|
6571
7391
|
const result = this.enqueueChain.then(async () => {
|
|
6572
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
|
+
}
|
|
6573
7402
|
if (mutation.type === "update") {
|
|
6574
7403
|
const tail = this.queue[this.queue.length - 1];
|
|
6575
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 }];
|
|
6576
7406
|
tail.data = {
|
|
6577
7407
|
...tail.data,
|
|
6578
7408
|
...mutation.data,
|
|
@@ -6639,12 +7469,14 @@ var OfflineManager = class {
|
|
|
6639
7469
|
}
|
|
6640
7470
|
sync() {
|
|
6641
7471
|
if (this.flushPromise) return this.flushPromise;
|
|
6642
|
-
|
|
7472
|
+
const run = () => this.withLock(() => this.flush());
|
|
7473
|
+
this.flushPromise = (this.scopeHold ? this.scopeKnown().then(run) : run()).finally(() => {
|
|
6643
7474
|
this.flushPromise = void 0;
|
|
6644
7475
|
});
|
|
6645
7476
|
return this.flushPromise;
|
|
6646
7477
|
}
|
|
6647
7478
|
async flush() {
|
|
7479
|
+
const ticket = this.ticket() ?? await this.nextTicket();
|
|
6648
7480
|
await this.ensureQueueLoaded();
|
|
6649
7481
|
await this.reloadQueue();
|
|
6650
7482
|
if (this.queue.length === 0) return {
|
|
@@ -6656,13 +7488,13 @@ var OfflineManager = class {
|
|
|
6656
7488
|
const queuedAtStart = this.queue.length;
|
|
6657
7489
|
let flushed = 0;
|
|
6658
7490
|
try {
|
|
6659
|
-
while (this.queue.length > 0 && !this.disposed) {
|
|
7491
|
+
while (this.queue.length > 0 && !this.disposed && this.isCurrent(ticket)) {
|
|
6660
7492
|
const op = this.queue[0];
|
|
6661
7493
|
touched.add(op.collection);
|
|
6662
7494
|
this.inFlightId = op.mutationId;
|
|
6663
7495
|
try {
|
|
6664
7496
|
try {
|
|
6665
|
-
await this.replay(op);
|
|
7497
|
+
await this.replay(op, ticket);
|
|
6666
7498
|
} catch (error) {
|
|
6667
7499
|
if (isNetworkError(error)) {
|
|
6668
7500
|
this.connectivity.markFailure();
|
|
@@ -6672,16 +7504,20 @@ var OfflineManager = class {
|
|
|
6672
7504
|
op.lastError = error?.message ?? String(error);
|
|
6673
7505
|
const limit = isIdempotencyInProgressError(error) ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES) : this.maxRetries;
|
|
6674
7506
|
if (isRetryableError(error) && op.attempts < limit) {
|
|
6675
|
-
await this.store.enqueue(
|
|
7507
|
+
await this.store.enqueue(`${ticket.scope}|${op.mutationId}`, op).catch(() => void 0);
|
|
6676
7508
|
this.connectivity.deferRetry();
|
|
6677
7509
|
this.patchStatus({ lastError: op.lastError });
|
|
6678
7510
|
break;
|
|
6679
7511
|
}
|
|
6680
|
-
|
|
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);
|
|
6681
7517
|
continue;
|
|
6682
7518
|
}
|
|
6683
7519
|
this.connectivity.markSuccess();
|
|
6684
|
-
await this.drop(op);
|
|
7520
|
+
await this.drop(op, ticket);
|
|
6685
7521
|
flushed++;
|
|
6686
7522
|
} finally {
|
|
6687
7523
|
this.inFlightId = null;
|
|
@@ -6690,6 +7526,10 @@ var OfflineManager = class {
|
|
|
6690
7526
|
} finally {
|
|
6691
7527
|
this.patchStatus({ syncing: false });
|
|
6692
7528
|
}
|
|
7529
|
+
if (!this.isCurrent(ticket)) return {
|
|
7530
|
+
flushed,
|
|
7531
|
+
remaining: this.queue.length
|
|
7532
|
+
};
|
|
6693
7533
|
if (this.queue.length !== queuedAtStart) {
|
|
6694
7534
|
for (const slug of touched) {
|
|
6695
7535
|
this.notifyCollection(slug);
|
|
@@ -6703,7 +7543,7 @@ var OfflineManager = class {
|
|
|
6703
7543
|
remaining: this.queue.length
|
|
6704
7544
|
};
|
|
6705
7545
|
}
|
|
6706
|
-
async replay(op) {
|
|
7546
|
+
async replay(op, ticket) {
|
|
6707
7547
|
const inner = this.innerFor(op.collection);
|
|
6708
7548
|
if (op.type === "create") {
|
|
6709
7549
|
let row;
|
|
@@ -6711,10 +7551,10 @@ var OfflineManager = class {
|
|
|
6711
7551
|
row = await this.sendQueuedWrite(op, (idempotencyKey) => inner.create(op.data, void 0, { idempotencyKey }), (sent) => inner.create(sent.data, sent.id, { idempotencyKey: sent.idempotencyKey }));
|
|
6712
7552
|
} catch (error) {
|
|
6713
7553
|
if (!(op.generatedId === true && isDuplicateKeyError(error))) throw error;
|
|
6714
|
-
row = await inner.findById(op.id)
|
|
6715
|
-
if (!row)
|
|
7554
|
+
row = await inner.findById(op.id);
|
|
7555
|
+
if (!row) throw error;
|
|
6716
7556
|
}
|
|
6717
|
-
await this.adoptServerRow(op, op.id, row);
|
|
7557
|
+
await this.adoptServerRow(op, op.id, row, ticket);
|
|
6718
7558
|
} else if (op.type === "createMany") {
|
|
6719
7559
|
const queued = op.data ?? [];
|
|
6720
7560
|
const asSent = (sent) => inner.createMany(sent.data, {
|
|
@@ -6726,24 +7566,24 @@ var OfflineManager = class {
|
|
|
6726
7566
|
...op.upsert ? { upsert: true } : {},
|
|
6727
7567
|
idempotencyKey
|
|
6728
7568
|
}), asSent);
|
|
6729
|
-
for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
|
|
7569
|
+
for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i], ticket);
|
|
6730
7570
|
} else if (op.type === "updateMany") {
|
|
6731
7571
|
const queued = op.updates ?? [];
|
|
6732
7572
|
const rows = await inner.updateMany(queued.map((u) => ({
|
|
6733
7573
|
id: u.id,
|
|
6734
7574
|
data: u.data
|
|
6735
7575
|
})), { idempotencyKey: op.mutationId });
|
|
6736
|
-
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);
|
|
6737
7577
|
} else if (op.type === "update") {
|
|
6738
7578
|
const row = await inner.update(op.id, op.data, op.sent ? { idempotencyKey: op.sent.idempotencyKey } : void 0);
|
|
6739
|
-
await this.ingestReplaced(op, op.id, row);
|
|
7579
|
+
await this.ingestReplaced(op, op.id, row, ticket);
|
|
6740
7580
|
} else if (op.type === "deleteMany") {
|
|
6741
7581
|
const ids = op.ids ?? [];
|
|
6742
7582
|
await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
|
|
6743
|
-
for (const id of ids) this.removeLocalRow(op.collection, id, true);
|
|
7583
|
+
for (const id of ids) this.removeLocalRow(op.collection, id, ticket, true);
|
|
6744
7584
|
} else if (op.type === "delete") {
|
|
6745
7585
|
await inner.delete(op.id, { idempotencyKey: op.sent?.idempotencyKey ?? op.mutationId });
|
|
6746
|
-
this.removeLocalRow(op.collection, op.id, true);
|
|
7586
|
+
this.removeLocalRow(op.collection, op.id, ticket, true);
|
|
6747
7587
|
}
|
|
6748
7588
|
}
|
|
6749
7589
|
/**
|
|
@@ -6772,14 +7612,15 @@ var OfflineManager = class {
|
|
|
6772
7612
|
* has to move with it, including queued writes that were made against it
|
|
6773
7613
|
* before it was ever sent.
|
|
6774
7614
|
*/
|
|
6775
|
-
async adoptServerRow(op, localId, row) {
|
|
7615
|
+
async adoptServerRow(op, localId, row, ticket) {
|
|
6776
7616
|
if (!row) return;
|
|
6777
7617
|
const slug = op.collection;
|
|
6778
7618
|
const serverId = row.id;
|
|
6779
7619
|
if (localId !== void 0 && serverId !== void 0 && String(serverId) !== String(localId)) {
|
|
6780
7620
|
const oldKey = String(localId);
|
|
6781
|
-
this.removeLocalRow(slug, localId);
|
|
6782
|
-
|
|
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) {
|
|
6783
7624
|
if (queued.collection !== slug) continue;
|
|
6784
7625
|
let dirty = false;
|
|
6785
7626
|
if (queued.id !== void 0 && String(queued.id) === oldKey) {
|
|
@@ -6793,10 +7634,10 @@ var OfflineManager = class {
|
|
|
6793
7634
|
delete rollbackRows[oldKey];
|
|
6794
7635
|
dirty = true;
|
|
6795
7636
|
}
|
|
6796
|
-
if (dirty) await this.store.enqueue(
|
|
7637
|
+
if (dirty) await this.store.enqueue(`${ticket.scope}|${queued.mutationId}`, queued).catch(() => void 0);
|
|
6797
7638
|
}
|
|
6798
7639
|
}
|
|
6799
|
-
await this.ingestReplaced(op, serverId ?? localId, row);
|
|
7640
|
+
await this.ingestReplaced(op, serverId ?? localId, row, ticket);
|
|
6800
7641
|
}
|
|
6801
7642
|
/**
|
|
6802
7643
|
* Write a server row over the local one, ignoring the mutation that just
|
|
@@ -6806,13 +7647,15 @@ var OfflineManager = class {
|
|
|
6806
7647
|
* back to the server's version in front of the user, only to change again
|
|
6807
7648
|
* when they replay a moment later.
|
|
6808
7649
|
*/
|
|
6809
|
-
async ingestReplaced(op, id, row) {
|
|
7650
|
+
async ingestReplaced(op, id, row, ticket) {
|
|
7651
|
+
if (!this.isCurrent(ticket)) return;
|
|
6810
7652
|
const slug = op.collection;
|
|
6811
7653
|
const state = await this.ensureCollection(slug);
|
|
7654
|
+
if (!this.isCurrent(ticket)) return;
|
|
6812
7655
|
const key = String(id);
|
|
6813
7656
|
const merged = this.applyPendingToRow(slug, key, { ...row }, op.mutationId);
|
|
6814
7657
|
if (merged === void 0) {
|
|
6815
|
-
this.removeLocalRow(slug, key);
|
|
7658
|
+
this.removeLocalRow(slug, key, ticket);
|
|
6816
7659
|
return;
|
|
6817
7660
|
}
|
|
6818
7661
|
const cachedAt = Date.now();
|
|
@@ -6836,7 +7679,8 @@ var OfflineManager = class {
|
|
|
6836
7679
|
* of it, so both stand on their own and are kept — dropping them would
|
|
6837
7680
|
* silently lose writes the server would have accepted.
|
|
6838
7681
|
*/
|
|
6839
|
-
async rejectMutation(op, error) {
|
|
7682
|
+
async rejectMutation(op, error, ticket) {
|
|
7683
|
+
if (!this.isCurrent(ticket)) return;
|
|
6840
7684
|
const ids = new Set(Object.keys(op.rollback?.rows ?? {}));
|
|
6841
7685
|
if (op.id !== void 0) ids.add(String(op.id));
|
|
6842
7686
|
const doomed = [op];
|
|
@@ -6849,24 +7693,61 @@ var OfflineManager = class {
|
|
|
6849
7693
|
if (later.type === "update") doomed.push(later);
|
|
6850
7694
|
else for (const id of hit) orphaned.delete(id);
|
|
6851
7695
|
}
|
|
6852
|
-
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;
|
|
6853
7698
|
for (const [idKey, previous] of Object.entries(op.rollback?.rows ?? {})) {
|
|
6854
7699
|
const restored = this.applyPendingToRow(op.collection, idKey, previous ?? void 0);
|
|
6855
|
-
if (restored === void 0) this.removeLocalRow(op.collection, idKey);
|
|
6856
|
-
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);
|
|
6857
7702
|
}
|
|
6858
7703
|
this.patchStatus({ lastError: error.message });
|
|
6859
7704
|
this.notifyCollection(op.collection);
|
|
6860
7705
|
this.scheduleRefresh(op.collection);
|
|
6861
|
-
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);
|
|
6862
7742
|
}
|
|
6863
7743
|
/** Every row id a mutation writes to. */
|
|
6864
7744
|
idsOf(op) {
|
|
6865
7745
|
if (op.type === "createMany") return (op.data ?? []).map((r) => String(r.id));
|
|
6866
7746
|
return op.id === void 0 ? [] : [String(op.id)];
|
|
6867
7747
|
}
|
|
6868
|
-
async drop(op) {
|
|
6869
|
-
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;
|
|
6870
7751
|
this.queue = this.queue.filter((m) => m.mutationId !== op.mutationId);
|
|
6871
7752
|
this.afterQueueChange(false);
|
|
6872
7753
|
}
|
|
@@ -6888,12 +7769,12 @@ var OfflineManager = class {
|
|
|
6888
7769
|
return fn();
|
|
6889
7770
|
}
|
|
6890
7771
|
}
|
|
6891
|
-
broadcast(message) {
|
|
7772
|
+
broadcast(message, scope = this.scope) {
|
|
6892
7773
|
if (!this.channel) return;
|
|
6893
7774
|
try {
|
|
6894
7775
|
this.channel.postMessage({
|
|
6895
7776
|
...message,
|
|
6896
|
-
scope
|
|
7777
|
+
scope,
|
|
6897
7778
|
sender: this.tabId
|
|
6898
7779
|
});
|
|
6899
7780
|
} catch {}
|
|
@@ -6901,14 +7782,14 @@ var OfflineManager = class {
|
|
|
6901
7782
|
onBroadcast(message) {
|
|
6902
7783
|
if (this.disposed || !message || typeof message !== "object") return;
|
|
6903
7784
|
const msg = message;
|
|
6904
|
-
if (msg.sender === this.tabId || msg.scope !== this.scope) return;
|
|
7785
|
+
if (this.scopeHold || msg.sender === this.tabId || msg.scope !== this.scope) return;
|
|
6905
7786
|
if (msg.type === "rows") for (const slug of msg.slugs ?? []) this.reloadCollection(slug);
|
|
6906
7787
|
else if (msg.type === "queue") this.reloadQueue();
|
|
6907
7788
|
}
|
|
6908
7789
|
/** Re-read one collection from the store, replacing what is in memory. */
|
|
6909
7790
|
async reloadCollection(slug) {
|
|
6910
7791
|
const state = this.collections.get(slug);
|
|
6911
|
-
if (!state?.loaded) return;
|
|
7792
|
+
if (!state?.loaded || this.scopeHold) return;
|
|
6912
7793
|
await this.reloadQueue();
|
|
6913
7794
|
const scope = this.scope;
|
|
6914
7795
|
const [rows, snapshots, absent] = await Promise.all([
|
|
@@ -6941,6 +7822,7 @@ var OfflineManager = class {
|
|
|
6941
7822
|
this.notifyCollection(slug, false);
|
|
6942
7823
|
}
|
|
6943
7824
|
async reloadQueue() {
|
|
7825
|
+
if (this.scopeHold) return;
|
|
6944
7826
|
const scope = this.scope;
|
|
6945
7827
|
const queue = await this.store.listQueue(`${scope}|`).catch(() => void 0);
|
|
6946
7828
|
if (!queue || this.scope !== scope) return;
|
|
@@ -7042,6 +7924,7 @@ function createRebaseClient(options) {
|
|
|
7042
7924
|
const cron = createCron(transport, options.cron);
|
|
7043
7925
|
const backups = createBackups(transport);
|
|
7044
7926
|
const apiKeys = createApiKeys(transport, options.apiKeys);
|
|
7927
|
+
const personalKeys = createPersonalKeys(transport, options.personalKeys);
|
|
7045
7928
|
const storage = createStorage(transport);
|
|
7046
7929
|
const functions = createFunctionsClient(transport);
|
|
7047
7930
|
const createStorageSource = (storageId) => storageId === DEFAULT_STORAGE_SOURCE_KEY ? storage : createStorage(transport, storageId);
|
|
@@ -7081,7 +7964,8 @@ function createRebaseClient(options) {
|
|
|
7081
7964
|
}
|
|
7082
7965
|
return session?.accessToken || options.token || "";
|
|
7083
7966
|
},
|
|
7084
|
-
onUnauthorized: options.onUnauthorized || (() => auth.handleUnauthorized())
|
|
7967
|
+
onUnauthorized: options.onUnauthorized || (() => auth.handleUnauthorized()),
|
|
7968
|
+
impersonate: options.impersonate
|
|
7085
7969
|
});
|
|
7086
7970
|
let realtimeUid = auth.getSession()?.user?.uid ?? null;
|
|
7087
7971
|
auth.onAuthStateChange((event, session) => {
|
|
@@ -7142,10 +8026,11 @@ function createRebaseClient(options) {
|
|
|
7142
8026
|
}
|
|
7143
8027
|
const offlineManager = options.offline ? new OfflineManager(typeof options.offline === "object" ? options.offline : {}, (slug) => createCollectionClient(transport, slug)) : void 0;
|
|
7144
8028
|
if (offlineManager) {
|
|
7145
|
-
offlineManager.
|
|
8029
|
+
offlineManager.holdScope();
|
|
7146
8030
|
auth.onAuthStateChange((event, session) => {
|
|
7147
8031
|
offlineManager.setScope(event === "SIGNED_OUT" ? void 0 : session?.user?.uid);
|
|
7148
8032
|
});
|
|
8033
|
+
auth.isInitialized().then(() => offlineManager.setScope(auth.getSession()?.user?.uid));
|
|
7149
8034
|
}
|
|
7150
8035
|
const collectionClients = /* @__PURE__ */ new Map();
|
|
7151
8036
|
let untypedWarned = false;
|
|
@@ -7188,6 +8073,7 @@ function createRebaseClient(options) {
|
|
|
7188
8073
|
cron,
|
|
7189
8074
|
backups,
|
|
7190
8075
|
apiKeys,
|
|
8076
|
+
personalKeys,
|
|
7191
8077
|
functions,
|
|
7192
8078
|
storage,
|
|
7193
8079
|
storageRegistry,
|
|
@@ -7240,18 +8126,27 @@ channel: (name, options) => {
|
|
|
7240
8126
|
body: payload !== void 0 ? JSON.stringify(payload) : void 0
|
|
7241
8127
|
});
|
|
7242
8128
|
},
|
|
7243
|
-
batch: async (operations,
|
|
8129
|
+
batch: async (operations, batchOptions) => {
|
|
7244
8130
|
if (!Array.isArray(operations)) throw new TypeError("batch expects an array of operations.");
|
|
7245
8131
|
if (operations.length === 0) return {
|
|
7246
8132
|
data: [],
|
|
7247
8133
|
meta: { operations: 0 }
|
|
7248
8134
|
};
|
|
7249
8135
|
const headers = {};
|
|
7250
|
-
if (
|
|
7251
|
-
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;
|
|
7252
8147
|
return transport.request("/data/_batch", {
|
|
7253
8148
|
method: "POST",
|
|
7254
|
-
body: JSON.stringify({ operations }),
|
|
8149
|
+
body: JSON.stringify({ operations: wire }),
|
|
7255
8150
|
...Object.keys(headers).length > 0 ? { headers } : {}
|
|
7256
8151
|
});
|
|
7257
8152
|
},
|
|
@@ -7260,6 +8155,6 @@ channel: (name, options) => {
|
|
|
7260
8155
|
};
|
|
7261
8156
|
}
|
|
7262
8157
|
//#endregion
|
|
7263
|
-
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 };
|
|
7264
8159
|
|
|
7265
8160
|
//# sourceMappingURL=index.es.js.map
|