@rebasepro/client 0.22.0 → 0.24.0

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