@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/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
- function throwApiError(status, body, statusText) {
551
- throw new RebaseApiError(body?.error?.message || body?.message || statusText, {
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 || currentSession?.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: JSON.stringify({ refreshToken: currentSession?.refreshToken }),
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: JSON.stringify({ refreshToken: currentSession?.refreshToken }),
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) throwApiError(res.status, body, res.statusText);
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 || currentSession?.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
- return transport.request(authPath + "/change-password", {
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
- async function verifyEmail(token) {
1150
- const res = await getFetch()(authUrl("/verify-email?token=" + encodeURIComponent(token)), {
1151
- method: "GET",
1152
- headers: { "Content-Type": "application/json" }
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
- return body;
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 ten single-use recovery codes. **Show the recovery codes once
1293
- * and never again** — only their hashes are stored.
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 managing API keys via the admin routes.
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 query = options?.onConflict?.length ? `?on_conflict=${encodeURIComponent(options.onConflict.join(","))}` : "";
2151
- return await transport.request(`${basePath}${query}`, {
2431
+ const raw = await transport.request(`${basePath}/bulk`, {
2152
2432
  method: "POST",
2153
- body: JSON.stringify(data),
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
- return (await transport.request(`${basePath}/aggregate${qs}`, { method: "GET" })).data || [];
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 && effectiveKey && !isPublicStoragePath(effectiveKey)) effectiveKey = `${PUBLIC_STORAGE_PREFIX}${effectiveKey.replace(/^\/+/, "")}`;
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
- let filePath = keyOrUrl;
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
- let filePath = key;
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 anything that earns a
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
- * Unlike {@link closedByCaller} this is not final — nobody *asked* for the
2801
- * socket to stay down. Five attempts with exponential backoff is about a
2802
- * minute, which a laptop lid, a wifi handover or a backend rollout all
2803
- * exceed routinely; treating that as permanent meant realtime silently
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.installOnlineListener();
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
- * The browser says the network is back — the usual reason the budget ran
2901
- * out in the first place. Registered lazily so a Node client, or a page
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
- installOnlineListener() {
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: { token }
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 && this.onlineListener && typeof window !== "undefined") {
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.reconnectAttempts >= this.maxReconnectAttempts) {
3102
- console.error("Max reconnection attempts reached");
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 delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), 3e4);
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) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
3363
- if (!this.isConnected || !this.ws) {
3364
- this.ensureConnected();
3365
- return new Promise((resolve, reject) => {
3366
- const queueable = message;
3367
- queueable._queuedResolve = resolve;
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
- this.doSendMessage(message, resolve, reject);
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 = !(isSubscriptionFrame || CHANNEL_MESSAGE_TYPES.has(message.type));
3389
- if (expectsResponse && !this.pendingRequests.has(requestId)) {
3390
- const timeoutHandle = setTimeout(() => {
3391
- if (this.pendingRequests.has(requestId)) {
3392
- this.pendingRequests.delete(requestId);
3393
- reject(new RebaseApiError$1("Request timed out"));
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
- if (condition === void 0) continue;
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
- /** Does any condition in this `where` clause order its operands? */
5233
- function whereOrders(where) {
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)) if ((isTuple(condition) ? [condition] : condition.filter(isTuple)).some(([op]) => ORDERING_OPS.has(op))) return true;
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 (whereOrders(params.where)) return false;
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 = "anon";
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 || "anon";
5573
- if (next === this.scope) return;
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
- this.revalidateAll();
5585
- this.sync().catch(() => void 0);
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
- await this.ingest(slug, res.data ?? [], isProjection(params));
5628
- const snapshot = this.recordSnapshot(slug, params, res);
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 (row !== void 0) await this.ingest(slug, [row]);
5663
- else if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, true);
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
- this.ingest(slug, response.data ?? [], isProjection(params)).then(() => this.notifyCollection(slug, false));
5983
- onUpdate(response);
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
- if (row) this.ingest(slug, [row]).then(() => this.notifyCollection(slug, false));
5987
- onUpdate(row);
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
- this.ingest(slug, response.data ?? [], isProjection(params)).then(() => {
6032
- this.recordSnapshot(slug, params, response);
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
- this.flushPromise = this.withLock(() => this.flush()).finally(() => {
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(this.queueKey(op), op).catch(() => void 0);
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
- await this.rejectMutation(op, error);
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).catch(() => void 0);
6715
- if (!row) return;
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
- for (const queued of this.queue) {
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(this.queueKey(queued), queued).catch(() => void 0);
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(this.queueKey(op)).catch(() => void 0);
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: this.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.setScope(auth.getSession()?.user?.uid);
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, options) => {
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 (options?.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
7251
- if (options?.returning === false) headers.Prefer = "return=minimal";
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