@rebasepro/client 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, RebaseClientError as RebaseClientError$1, SCHEMA_VERSION_HEADER, Vector, hasFieldOperation, isPublicStoragePath, isUnsupported, parseRelationAggregateSort, sortKeyToString, toCanonicalOp, unsupportedMethod } from "@rebasepro/types";
2
- import { COMPOSITE_ID_SEPARATOR, QueryBuilder, RebasePaginationError, and, buildCompositeId, collectAllPages, cond, mergeIncludeSpecs, normalizeOrderBy, not, or, paginateFind, resolveFindWindow, serializeFilter, serializeInclude, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
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`.
@@ -151,12 +171,7 @@ function buildAggregateQueryString(params) {
151
171
  const serialized = (root.conditions ?? []).map(serializeLogicalCondition).join(",");
152
172
  parts.push(`${root.type}=${encodeURIComponent(`(${serialized})`)}`);
153
173
  }
154
- if (params.where) {
155
- assertNoUndefinedFilterValues(params.where);
156
- const serialized = serializeFilter(params.where);
157
- for (const [field, value] of Object.entries(serialized)) if (Array.isArray(value)) for (const v of value) parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(v)}`);
158
- else parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(value)}`);
159
- }
174
+ if (params.where) appendWhere(parts, params.where);
160
175
  return "?" + parts.join("&");
161
176
  }
162
177
  /**
@@ -251,12 +266,14 @@ function createTransport(config, environment) {
251
266
  ...config.schemaVersion ? { [SCHEMA_VERSION_HEADER]: config.schemaVersion } : {}
252
267
  };
253
268
  function getHeaders(activeToken, init) {
254
- return {
269
+ const headers = {
255
270
  "Content-Type": "application/json",
256
271
  ...defaultHeaders,
257
272
  ...activeToken ? { Authorization: `Bearer ${activeToken}` } : {},
258
273
  ...init?.headers || {}
259
274
  };
275
+ if (init?.body instanceof FormData) delete headers["Content-Type"];
276
+ return headers;
260
277
  }
261
278
  /**
262
279
  * The refusal for a success status carrying a body this client cannot read.
@@ -330,7 +347,6 @@ function createTransport(config, environment) {
330
347
  } catch (e) {}
331
348
  warnIfAnonymousServerClient(activeToken);
332
349
  const headers = getHeaders(activeToken, init);
333
- if (init?.body instanceof FormData) delete headers["Content-Type"];
334
350
  const res = await fetchFn(url, {
335
351
  ...init,
336
352
  headers
@@ -505,9 +521,22 @@ function createAuth(transport, options) {
505
521
  const REFRESH_RETRY_BASE_MS = 1e3;
506
522
  const REFRESH_RETRY_MAX_MS = 3e4;
507
523
  let currentSession = null;
524
+ /**
525
+ * Which sign-in `currentSession` belongs to. Bumped whenever the session
526
+ * is replaced by anything other than its own refresh — a sign-in, a
527
+ * sign-out, a revocation, a local abandon — so a refresh already in flight
528
+ * can tell, when its answer lands, that the session it was refreshing has
529
+ * ended. Its answer is then dropped: adopting it put a signed-out session
530
+ * back in memory, in storage and on the transport, announced
531
+ * TOKEN_REFRESHED after SIGNED_OUT, and overwrote whoever had signed in
532
+ * since.
533
+ */
534
+ let sessionEpoch = 0;
508
535
  const listeners = /* @__PURE__ */ new Set();
509
536
  let refreshTimeout = null;
510
537
  let inFlightRefresh = null;
538
+ /** The `sessionEpoch` the in-flight refresh was started for. */
539
+ let inFlightEpoch = -1;
511
540
  let resolveInitialized;
512
541
  const isInitialized = new Promise((resolve) => {
513
542
  resolveInitialized = resolve;
@@ -569,10 +598,25 @@ function createAuth(transport, options) {
569
598
  * may be stale precisely because a sibling tab holds a live one, and
570
599
  * logging out on its behalf would turn one tab's bad luck into everybody
571
600
  * being signed out — the exact failure this work exists to remove.
601
+ *
602
+ * Storage is left alone when it no longer holds the session being
603
+ * abandoned. It is shared with every other tab of the app, so a different
604
+ * session there is a sibling's live one — a rotation this tab missed, or
605
+ * another sign-in — and deleting it signed that tab out on its next load.
572
606
  */
573
607
  function abandonSessionLocally() {
608
+ endSession(storageHoldsCurrentSession());
609
+ }
610
+ /** Whether storage holds this client's session — or no session at all. */
611
+ function storageHoldsCurrentSession() {
612
+ const persisted = loadStoredSession();
613
+ return !persisted || !currentSession || persisted.refreshToken === currentSession.refreshToken;
614
+ }
615
+ /** Drop the session from memory, storage and the transport, and say so. */
616
+ function endSession(clearStorage = true) {
617
+ sessionEpoch++;
574
618
  currentSession = null;
575
- clearStoredSession();
619
+ if (clearStorage) clearStoredSession();
576
620
  if (refreshTimeout) {
577
621
  clearTimeout(refreshTimeout);
578
622
  refreshTimeout = null;
@@ -600,32 +644,39 @@ function createAuth(transport, options) {
600
644
  abandonSessionLocally();
601
645
  return false;
602
646
  }
647
+ const epoch = sessionEpoch;
603
648
  try {
604
649
  await refreshSession();
605
- return true;
650
+ return epoch === sessionEpoch;
606
651
  } catch (err) {
607
- if (isFatalRefreshError(err)) abandonSessionLocally();
652
+ if (epoch === sessionEpoch && isFatalRefreshError(err)) abandonSessionLocally();
608
653
  return false;
609
654
  }
610
655
  }
611
656
  async function attemptScheduledRefresh(attempt) {
657
+ const epoch = sessionEpoch;
612
658
  try {
613
659
  await refreshSession();
614
660
  } catch (err) {
661
+ if (epoch !== sessionEpoch) return;
615
662
  if (isFatalRefreshError(err)) {
616
663
  abandonSessionLocally();
617
664
  return;
618
665
  }
619
666
  if (attempt >= MAX_REFRESH_RETRIES) {
620
- abandonSessionLocally();
667
+ if (err instanceof RebaseApiError && (err.status === 401 || err.status === 403)) abandonSessionLocally();
621
668
  return;
622
669
  }
623
- const backoff = Math.min(REFRESH_RETRY_BASE_MS * 2 ** attempt, REFRESH_RETRY_MAX_MS);
624
- refreshTimeout = setTimeout(() => {
625
- attemptScheduledRefresh(attempt + 1);
626
- }, backoff);
670
+ scheduleRetry(attempt);
627
671
  }
628
672
  }
673
+ /** Try the scheduled refresh again after a backoff: 1s, 2s, 4s, … capped. */
674
+ function scheduleRetry(attempt) {
675
+ const backoff = Math.min(REFRESH_RETRY_BASE_MS * 2 ** attempt, REFRESH_RETRY_MAX_MS);
676
+ refreshTimeout = setTimeout(() => {
677
+ attemptScheduledRefresh(attempt + 1);
678
+ }, backoff);
679
+ }
629
680
  function scheduleRefresh(expiresAt) {
630
681
  if (refreshTimeout) clearTimeout(refreshTimeout);
631
682
  if (!autoRefresh) return;
@@ -666,6 +717,7 @@ function createAuth(transport, options) {
666
717
  }
667
718
  }
668
719
  function handleAuthResponse(data, event) {
720
+ sessionEpoch++;
669
721
  const user = mapRawUser(data.user);
670
722
  const session = {
671
723
  accessToken: data.tokens.accessToken,
@@ -847,6 +899,7 @@ function createAuth(transport, options) {
847
899
  });
848
900
  }
849
901
  async function signOut() {
902
+ sessionEpoch++;
850
903
  const fetchFn = getFetch();
851
904
  try {
852
905
  if (authFlowMode === "cookie" || currentSession?.refreshToken) await fetchFn(authUrl("/logout"), {
@@ -856,14 +909,7 @@ function createAuth(transport, options) {
856
909
  credentials: authFlowMode === "cookie" ? "include" : void 0
857
910
  });
858
911
  } catch (e) {}
859
- currentSession = null;
860
- clearStoredSession();
861
- if (refreshTimeout) {
862
- clearTimeout(refreshTimeout);
863
- refreshTimeout = null;
864
- }
865
- transport.setToken(null);
866
- emit("SIGNED_OUT", null);
912
+ endSession();
867
913
  }
868
914
  /**
869
915
  * Serialise refreshes across TABS, not just within one.
@@ -899,13 +945,85 @@ function createAuth(transport, options) {
899
945
  }
900
946
  }
901
947
  function refreshSession() {
902
- if (inFlightRefresh) return inFlightRefresh;
903
- inFlightRefresh = withRefreshLock(() => doRefreshSession()).finally(() => {
904
- inFlightRefresh = null;
948
+ if (inFlightRefresh && inFlightEpoch === sessionEpoch) return inFlightRefresh;
949
+ const refresh = withRefreshLock(() => doRefreshSession()).finally(() => {
950
+ if (inFlightRefresh === refresh) inFlightRefresh = null;
905
951
  });
906
- return inFlightRefresh;
952
+ inFlightRefresh = refresh;
953
+ inFlightEpoch = sessionEpoch;
954
+ return refresh;
955
+ }
956
+ /**
957
+ * What a refresh answers when its session ended while it was in flight.
958
+ *
959
+ * The tokens it received belong to a session this client no longer holds,
960
+ * so they are dropped, and so is any error it hit: a refusal of the old
961
+ * session's token says nothing about the new one, and acting on it signed
962
+ * out whoever had signed in since. The caller gets the session there is
963
+ * now — or, if there is none, is told so.
964
+ */
965
+ function supersededRefresh() {
966
+ if (currentSession) return currentSession;
967
+ throw new RebaseClientError$1("The session was signed out while it was being refreshed.", { code: "NOT_SIGNED_IN" });
968
+ }
969
+ /**
970
+ * The session a sibling tab has persisted since this one last looked, when
971
+ * it continues this one.
972
+ *
973
+ * Tabs of one app share `localStorage` but each holds its session in
974
+ * memory, and a refresh rotates the refresh token. Once one tab has
975
+ * refreshed, every other tab is holding a token the server has already
976
+ * superseded: presented after the server's reuse window it is refused
977
+ * with TOKEN_ALREADY_USED, again on every retry, until the tab gives up
978
+ * and signs out. Its replacement was in storage all along.
979
+ *
980
+ * Only a session of the same user counts. A different user in storage
981
+ * means another tab signed someone else in, and that is not this tab's
982
+ * session to take over.
983
+ */
984
+ function siblingSession() {
985
+ if (!persistSession || authFlowMode === "cookie" || !currentSession) return null;
986
+ const persisted = loadStoredSession();
987
+ if (!persisted?.accessToken || !persisted.refreshToken) return null;
988
+ if (persisted.refreshToken === currentSession.refreshToken) return null;
989
+ if (!persisted.user?.uid || persisted.user.uid !== currentSession.user?.uid) return null;
990
+ return persisted;
991
+ }
992
+ /**
993
+ * Take over a sibling's session. Its access token is used as it is while
994
+ * it has time left — exactly as a session restored on load would be — and
995
+ * otherwise this answers `null` and the caller refreshes, now with the
996
+ * sibling's refresh token rather than the superseded one.
997
+ */
998
+ function adoptSibling(sibling) {
999
+ currentSession = sibling;
1000
+ if (sibling.expiresAt - REFRESH_BUFFER_MS <= Date.now()) return null;
1001
+ transport.setToken(sibling.accessToken);
1002
+ scheduleRefresh(sibling.expiresAt);
1003
+ emit("TOKEN_REFRESHED", sibling);
1004
+ return sibling;
907
1005
  }
908
1006
  async function doRefreshSession() {
1007
+ const epoch = sessionEpoch;
1008
+ try {
1009
+ const sibling = siblingSession();
1010
+ if (sibling) {
1011
+ const adopted = adoptSibling(sibling);
1012
+ if (adopted) return adopted;
1013
+ }
1014
+ try {
1015
+ return await requestRefresh(epoch);
1016
+ } catch (err) {
1017
+ const late = epoch === sessionEpoch && err instanceof RebaseApiError && err.code === "TOKEN_ALREADY_USED" ? siblingSession() : null;
1018
+ if (!late) throw err;
1019
+ return adoptSibling(late) ?? await requestRefresh(epoch);
1020
+ }
1021
+ } catch (err) {
1022
+ if (epoch !== sessionEpoch) return supersededRefresh();
1023
+ throw err;
1024
+ }
1025
+ }
1026
+ async function requestRefresh(epoch) {
909
1027
  if (authFlowMode !== "cookie" && !currentSession?.refreshToken) throw new RebaseClientError$1("No active session to refresh. Sign in first, or set `authFlowMode: \"cookie\"` if the refresh token is held in an HttpOnly cookie.", { code: "NOT_SIGNED_IN" });
910
1028
  const res = await getFetch()(authUrl("/refresh"), {
911
1029
  method: "POST",
@@ -914,14 +1032,18 @@ function createAuth(transport, options) {
914
1032
  credentials: authFlowMode === "cookie" ? "include" : void 0
915
1033
  });
916
1034
  const body = await res.json().catch(() => ({}));
1035
+ if (epoch !== sessionEpoch) return supersededRefresh();
917
1036
  if (!res.ok) throwApiError(res.status, body, res.statusText);
918
1037
  const accessToken = body.tokens.accessToken;
919
1038
  transport.setToken(accessToken);
920
1039
  let user = currentSession?.user;
921
1040
  if (body.user && typeof body.user.uid === "string") user = mapRawUser(body.user);
922
- else if (!user || !user.uid) try {
923
- user = await getUser();
924
- } catch {}
1041
+ else if (!user || !user.uid) {
1042
+ try {
1043
+ user = await getUser();
1044
+ } catch {}
1045
+ if (epoch !== sessionEpoch) return supersededRefresh();
1046
+ }
925
1047
  const session = {
926
1048
  accessToken,
927
1049
  refreshToken: body.tokens.refreshToken || currentSession?.refreshToken || "",
@@ -1203,27 +1325,38 @@ function createAuth(transport, options) {
1203
1325
  body: JSON.stringify({ factorId })
1204
1326
  });
1205
1327
  },
1206
- /** Open a challenge against a verified factor. Expires in five minutes. */
1207
- async challenge(factorId) {
1328
+ /**
1329
+ * Open a challenge against a verified factor. Expires in five minutes.
1330
+ *
1331
+ * Pass `mfaToken` to finish a sign-in that was refused with
1332
+ * `MFA_REQUIRED`: it is the `details.mfaToken` of that refusal, and the
1333
+ * challenge is opened with it as this one request's credential. Without
1334
+ * it, the challenge steps up the session this client already holds.
1335
+ */
1336
+ async challenge(factorId, options) {
1337
+ const payload = JSON.stringify({ factorId });
1338
+ if (options?.mfaToken) return withPendingToken("/mfa/challenge", payload, options.mfaToken);
1208
1339
  return transport.request(authPath + "/mfa/challenge", {
1209
1340
  method: "POST",
1210
- body: JSON.stringify({ factorId })
1341
+ body: payload
1211
1342
  });
1212
1343
  },
1213
1344
  /**
1214
1345
  * Answer a challenge with a TOTP code or a recovery code.
1215
1346
  *
1216
1347
  * This is where an MFA-enrolled account's real session is minted, at
1217
- * `aal2` — so the returned tokens replace the ones the sign-in handed
1218
- * back, and this client adopts them.
1348
+ * `aal2` — so this client adopts the returned tokens and announces
1349
+ * `SIGNED_IN`, exactly as any other sign-in does. Pass the same
1350
+ * `mfaToken` the challenge was opened with when finishing a sign-in.
1219
1351
  */
1220
- async verifyChallenge(challengeId, code) {
1221
- const session = handleAuthResponse(await transport.request(authPath + "/mfa/challenge/verify", {
1352
+ async verifyChallenge(challengeId, code, options) {
1353
+ const payload = JSON.stringify({
1354
+ challengeId,
1355
+ code
1356
+ });
1357
+ const session = handleAuthResponse(options?.mfaToken ? await withPendingToken("/mfa/challenge/verify", payload, options.mfaToken) : await transport.request(authPath + "/mfa/challenge/verify", {
1222
1358
  method: "POST",
1223
- body: JSON.stringify({
1224
- challengeId,
1225
- code
1226
- })
1359
+ body: payload
1227
1360
  }), "SIGNED_IN");
1228
1361
  return {
1229
1362
  user: session.user,
@@ -1232,6 +1365,31 @@ function createAuth(transport, options) {
1232
1365
  };
1233
1366
  }
1234
1367
  };
1368
+ /**
1369
+ * POST to a challenge route with a sign-in's pending token as the credential.
1370
+ *
1371
+ * Straight to `fetch`, like the sign-in routes, and never through the
1372
+ * transport: the token is a credential for these two requests only. Set on
1373
+ * the transport it would go out on every request the app made while the
1374
+ * code step was open — and stay there if the step were abandoned. It would
1375
+ * also displace a session the client already holds, and a refused code
1376
+ * (401 `INVALID_CODE`) would be taken for an expired session and refreshed
1377
+ * and re-sent, spending a second of the challenge's attempts on it.
1378
+ */
1379
+ async function withPendingToken(endpoint, payload, mfaToken) {
1380
+ const res = await getFetch()(authUrl(endpoint), {
1381
+ method: "POST",
1382
+ headers: {
1383
+ "Content-Type": "application/json",
1384
+ Authorization: `Bearer ${mfaToken}`
1385
+ },
1386
+ body: payload,
1387
+ credentials: authFlowMode === "cookie" ? "include" : void 0
1388
+ });
1389
+ const body = await res.json().catch(() => ({}));
1390
+ if (!res.ok) throwApiError(res.status, body, res.statusText);
1391
+ return body;
1392
+ }
1235
1393
  async function getSessions() {
1236
1394
  return (await transport.request(authPath + "/sessions", { method: "GET" })).sessions;
1237
1395
  }
@@ -1240,14 +1398,7 @@ function createAuth(transport, options) {
1240
1398
  }
1241
1399
  async function revokeAllSessions() {
1242
1400
  const result = await transport.request(authPath + "/sessions", { method: "DELETE" });
1243
- currentSession = null;
1244
- clearStoredSession();
1245
- if (refreshTimeout) {
1246
- clearTimeout(refreshTimeout);
1247
- refreshTimeout = null;
1248
- }
1249
- transport.setToken(null);
1250
- emit("SIGNED_OUT", null);
1401
+ endSession();
1251
1402
  return result;
1252
1403
  }
1253
1404
  async function getAuthConfig() {
@@ -1275,12 +1426,18 @@ function createAuth(transport, options) {
1275
1426
  resolveInitialized();
1276
1427
  } else if (authFlowMode === "cookie" || stored.refreshToken) {
1277
1428
  currentSession = stored;
1429
+ const epoch = sessionEpoch;
1278
1430
  refreshSession().then(() => {
1279
1431
  resolveInitialized();
1280
- }).catch(() => {
1281
- currentSession = null;
1282
- clearStoredSession();
1283
- transport.setToken(null);
1432
+ }).catch((err) => {
1433
+ if (epoch === sessionEpoch && currentSession) if (isFatalRefreshError(err)) {
1434
+ if (storageHoldsCurrentSession()) clearStoredSession();
1435
+ currentSession = null;
1436
+ transport.setToken(null);
1437
+ } else {
1438
+ transport.setToken(currentSession.accessToken);
1439
+ if (autoRefresh) scheduleRetry(0);
1440
+ }
1284
1441
  resolveInitialized();
1285
1442
  });
1286
1443
  } else resolveInitialized();
@@ -1378,8 +1535,14 @@ function createCookieStorage(options = {}) {
1378
1535
  //#region src/admin.ts
1379
1536
  function createAdmin(transport, options) {
1380
1537
  const adminPath = (options || {}).adminPath || "/admin";
1381
- async function listUsers() {
1382
- return transport.request(adminPath + "/users", { method: "GET" });
1538
+ /**
1539
+ * One page of users — the route's default is 25 — with the `total` that
1540
+ * says whether there are more. Takes the same window as
1541
+ * {@link listUsersPaginated}; without one, it used to hand back the first
1542
+ * page typed as the whole list.
1543
+ */
1544
+ async function listUsers(options) {
1545
+ return listUsersPaginated(options);
1383
1546
  }
1384
1547
  async function listUsersPaginated(options) {
1385
1548
  const params = new URLSearchParams();
@@ -1474,17 +1637,24 @@ function createBackups(transport, options) {
1474
1637
  return transport.request(backupsPath, { method: "GET" });
1475
1638
  }
1476
1639
  /**
1477
- * Download a backup's bytes. Uses an authenticated fetch (not the JSON
1478
- * transport) so the octet-stream response comes back as a Blob.
1640
+ * Download a backup's bytes. Not through the JSON `request()`, since the
1641
+ * answer is an octet-stream to hand back as a Blob — but through the
1642
+ * transport's own `fetch` and headers all the same. It used the global
1643
+ * `fetch` and a bare `Authorization` header, so a client configured with
1644
+ * its own `fetch` or with default headers (a gateway key, the schema
1645
+ * version) had every call but this one go where it was told.
1479
1646
  */
1480
1647
  async function download(key) {
1481
- const token = await transport.resolveToken();
1482
1648
  const url = `${transport.baseUrl}${transport.apiPath}${backupsPath}/download?key=${encodeURIComponent(key)}`;
1483
- const res = await fetch(url, {
1649
+ const token = await transport.resolveToken();
1650
+ const headers = transport.getHeaders();
1651
+ delete headers["Content-Type"];
1652
+ if (token) headers.Authorization = `Bearer ${token}`;
1653
+ const res = await transport.fetchFn(url, {
1484
1654
  method: "GET",
1485
- headers: token ? { Authorization: `Bearer ${token}` } : {}
1655
+ headers
1486
1656
  });
1487
- if (!res.ok) throw new Error(`Failed to download backup (${res.status})`);
1657
+ if (!res.ok) throw new RebaseApiError$1(`Failed to download backup (${res.status})`, { status: res.status });
1488
1658
  return res.blob();
1489
1659
  }
1490
1660
  return {
@@ -1816,6 +1986,28 @@ var SDKQueryBuilder = class {
1816
1986
  */
1817
1987
  var NO_SOCKET = "Listen is only available when RebaseClient is configured with a websocketUrl, and not when it was created with realtime: false.";
1818
1988
  /**
1989
+ * Why a live query cannot be opened for these parameters, or `undefined` when
1990
+ * it can.
1991
+ *
1992
+ * A `listen()` is the same query as the `find()` beside it, so what `find()`
1993
+ * refuses it refuses too. The socket would not: a frame is JSON, where an
1994
+ * `undefined` filter value becomes `null`, so `["!=", uid]` with `uid` unset
1995
+ * subscribed to every row whose column is NOT NULL — an ownership filter
1996
+ * widened instead of refused. And `after` names a place in one run of the
1997
+ * query, which a subscription re-runs on every write; the server keeps no
1998
+ * cursor for one, so it was dropped, and page one streamed to a caller who had
1999
+ * asked for page two.
2000
+ */
2001
+ function listenRefusal(params) {
2002
+ if (params?.where) try {
2003
+ assertNoUndefinedFilterValues(params.where);
2004
+ } catch (error) {
2005
+ if (error instanceof RebaseClientError$1) return error;
2006
+ throw error;
2007
+ }
2008
+ if (params?.after) return new RebaseClientError$1("listen() cannot continue from a cursor: a subscription re-runs its query on every write, and `after` names a place in one run of it. Use `offset` or `page` for a live window, or `find({ after })` to read the page once.", { code: "CURSOR_NOT_LIVE" });
2009
+ }
2010
+ /**
1819
2011
  * Counts currently in flight, **per client**, keyed by the exact request they
1820
2012
  * issue. Entries live only for the duration of the request — see `count()` for
1821
2013
  * why they exist at all.
@@ -2067,10 +2259,20 @@ function createCollectionClient(transport, slug, ws) {
2067
2259
  partial: false
2068
2260
  });
2069
2261
  };
2262
+ const wantsLive = options?.realtime !== false && !isUnsupported(client.listen);
2263
+ const refusal = wantsLive ? listenRefusal(params) : void 0;
2264
+ if (refusal) {
2265
+ queueMicrotask(() => {
2266
+ if (!closed) onError?.(refusal);
2267
+ });
2268
+ return () => {
2269
+ closed = true;
2270
+ };
2271
+ }
2070
2272
  client.find(params).then((result) => deliver(result, false)).catch((error) => {
2071
2273
  if (!closed) onError?.(error);
2072
2274
  });
2073
- const live = options?.realtime !== false && !isUnsupported(client.listen) ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
2275
+ const live = wantsLive ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
2074
2276
  return () => {
2075
2277
  closed = true;
2076
2278
  live?.();
@@ -2152,6 +2354,15 @@ function createCollectionClient(transport, slug, ws) {
2152
2354
  if (ws) {
2153
2355
  client.listen = (params, onUpdate, onError) => {
2154
2356
  let active = true;
2357
+ const refusal = listenRefusal(params);
2358
+ if (refusal) {
2359
+ queueMicrotask(() => {
2360
+ if (active) onError?.(refusal);
2361
+ });
2362
+ return () => {
2363
+ active = false;
2364
+ };
2365
+ }
2155
2366
  let lastUpdateId = 0;
2156
2367
  let lastKnownTotal;
2157
2368
  const window = resolveFindWindow(params);
@@ -2504,6 +2715,26 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
2504
2715
  "channel_history"
2505
2716
  ]);
2506
2717
  /**
2718
+ * Subscription frames. Like channel frames they get no response envelope, and
2719
+ * like them they go out without an account when there is none: whether an
2720
+ * anonymous visitor may subscribe is the server's `requireAuth` to decide, and
2721
+ * a server that refuses answers the frame, which reaches the listener's
2722
+ * `onError`.
2723
+ */
2724
+ var SUBSCRIPTION_MESSAGE_TYPES = /* @__PURE__ */ new Set([
2725
+ "subscribe_collection",
2726
+ "subscribe_one",
2727
+ "unsubscribe"
2728
+ ]);
2729
+ /**
2730
+ * The token getter's "there is no account" — an empty token, or its own
2731
+ * "not logged in". Distinct from a getter that failed, which says nothing about
2732
+ * whether there is one.
2733
+ */
2734
+ function isNotSignedIn(error) {
2735
+ return (error instanceof Error ? error.message : String(error)).includes("not logged in");
2736
+ }
2737
+ /**
2507
2738
  * The other direction: the frame types the *server* addresses by channel.
2508
2739
  *
2509
2740
  * Not the mirror of {@link CHANNEL_MESSAGE_TYPES} — a request and its answer
@@ -2614,6 +2845,17 @@ var RebaseWebSocketClient = class {
2614
2845
  pendingRequests = /* @__PURE__ */ new Map();
2615
2846
  reconnectAttempts = 0;
2616
2847
  maxReconnectAttempts = 5;
2848
+ /**
2849
+ * Whether a socket of this client has ever opened — what makes the next
2850
+ * open a *reconnect*, whose server has forgotten every subscription and
2851
+ * channel membership and must be told them again.
2852
+ *
2853
+ * Not `reconnectAttempts > 0`: that counter is a backoff budget, and a
2854
+ * fresh one (after giving up, or after a sign-out dropped the socket)
2855
+ * starts at zero, so the socket that finally came back looked like a first
2856
+ * connect and nothing was re-sent.
2857
+ */
2858
+ hadConnection = false;
2617
2859
  isConnected = false;
2618
2860
  messageQueue = [];
2619
2861
  requestTimeoutMs = 3e4;
@@ -2737,6 +2979,7 @@ var RebaseWebSocketClient = class {
2737
2979
  this.reconnectTimeout = null;
2738
2980
  }
2739
2981
  this.suspendSubscribeWatchdogs();
2982
+ this.forgetCachedData();
2740
2983
  if (this.ws) {
2741
2984
  this.ws.onclose = null;
2742
2985
  this.ws.onerror = null;
@@ -2759,7 +3002,8 @@ var RebaseWebSocketClient = class {
2759
3002
  this.ws = socket;
2760
3003
  this.ws.onopen = async () => {
2761
3004
  console.debug("Connected to PostgreSQL backend");
2762
- const wasReconnect = this.reconnectAttempts > 0;
3005
+ const wasReconnect = this.hadConnection;
3006
+ this.hadConnection = true;
2763
3007
  this.isConnected = true;
2764
3008
  this.reconnectAttempts = 0;
2765
3009
  if (this.getAuthToken && !this.isAuthenticated) try {
@@ -2772,7 +3016,7 @@ var RebaseWebSocketClient = class {
2772
3016
  console.debug("WebSocket connected without auth:", error?.message || error);
2773
3017
  }
2774
3018
  this.emit(wasReconnect ? "reconnect" : "connect");
2775
- this.processMessageQueue();
3019
+ this.processMessageQueue(wasReconnect);
2776
3020
  if (wasReconnect) this.resubscribeAll();
2777
3021
  this.armPendingSubscribeWatchdogs();
2778
3022
  };
@@ -2813,11 +3057,45 @@ var RebaseWebSocketClient = class {
2813
3057
  this.attemptReconnect();
2814
3058
  }
2815
3059
  }
2816
- processMessageQueue() {
3060
+ processMessageQueue(isReconnect) {
2817
3061
  while (this.messageQueue.length > 0 && this.isConnected) {
2818
3062
  const message = this.messageQueue.shift();
2819
- if (message) this.sendMessage(message);
3063
+ if (!message) continue;
3064
+ if (!this.shouldSendQueued(message, isReconnect)) {
3065
+ const resolveQueued = message._queuedResolve;
3066
+ if (typeof resolveQueued === "function") resolveQueued(void 0);
3067
+ continue;
3068
+ }
3069
+ this.sendMessage(message);
3070
+ }
3071
+ }
3072
+ /**
3073
+ * Whether a frame that waited in the queue should go out on the socket
3074
+ * that just opened. Only a subscribe can be refused, and one is exactly
3075
+ * when sending it would give the server a subscription this side will not
3076
+ * read:
3077
+ *
3078
+ * - Its registration is gone, or has been given a new id, while it waited.
3079
+ * A listener that unmounted before the socket opened (a StrictMode double
3080
+ * mount does exactly this) sends no `unsubscribe`, because there was no
3081
+ * socket to send it on, so the server would keep that one forever.
3082
+ * - This open is a reconnect. `resubscribeAll` sends every registration
3083
+ * under a fresh id right after the queue drains, so the queued copy would
3084
+ * be the second of two. It is handed back to that pass instead.
3085
+ */
3086
+ shouldSendQueued(message, isReconnect) {
3087
+ if (message.type !== "subscribe_collection" && message.type !== "subscribe_one") return true;
3088
+ const payload = message.payload;
3089
+ if (!payload || typeof payload !== "object" || !("subscriptionId" in payload)) return true;
3090
+ const subscriptionId = payload.subscriptionId;
3091
+ if (typeof subscriptionId !== "string") return true;
3092
+ const registration = message.type === "subscribe_collection" ? this.collectionSubscriptions.get(this.backendToCollectionKey.get(subscriptionId) ?? "") : this.singleSubscriptions.get(this.backendToEntityKey.get(subscriptionId) ?? "");
3093
+ if (!registration || registration.backendSubscriptionId !== subscriptionId) return false;
3094
+ if (isReconnect) {
3095
+ registration.subscribeInFlight = false;
3096
+ return false;
2820
3097
  }
3098
+ return true;
2821
3099
  }
2822
3100
  attemptReconnect() {
2823
3101
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
@@ -2977,8 +3255,9 @@ var RebaseWebSocketClient = class {
2977
3255
  }
2978
3256
  }
2979
3257
  }
2980
- if (subscriptionId && (type === "ERROR" || message.error)) {
2981
- const collectionKey = this.backendToCollectionKey.get(subscriptionId);
3258
+ const erroredSubscriptionId = subscriptionId ?? requestId;
3259
+ if (erroredSubscriptionId && (type === "ERROR" || message.error)) {
3260
+ const collectionKey = this.backendToCollectionKey.get(erroredSubscriptionId);
2982
3261
  if (collectionKey) {
2983
3262
  const collectionSub = this.collectionSubscriptions.get(collectionKey);
2984
3263
  if (collectionSub) {
@@ -2996,7 +3275,7 @@ var RebaseWebSocketClient = class {
2996
3275
  return;
2997
3276
  }
2998
3277
  }
2999
- const entityKey = this.backendToEntityKey.get(subscriptionId);
3278
+ const entityKey = this.backendToEntityKey.get(erroredSubscriptionId);
3000
3279
  if (entityKey) {
3001
3280
  const entitySub = this.singleSubscriptions.get(entityKey);
3002
3281
  if (entitySub) {
@@ -3095,15 +3374,18 @@ var RebaseWebSocketClient = class {
3095
3374
  });
3096
3375
  }
3097
3376
  async doSendMessage(message, resolve, reject) {
3377
+ const isSubscriptionFrame = SUBSCRIPTION_MESSAGE_TYPES.has(message.type);
3098
3378
  if (message.type !== "AUTHENTICATE" && !CHANNEL_MESSAGE_TYPES.has(message.type) && this.getAuthToken && !this.isAuthenticated) try {
3099
3379
  await this.ensureAuthenticated();
3100
3380
  } catch (error) {
3101
- reject(new RebaseApiError$1(error instanceof Error ? error.message : "Authentication required"));
3102
- return;
3381
+ if (!(isSubscriptionFrame && isNotSignedIn(error))) {
3382
+ reject(new RebaseApiError$1(error instanceof Error ? error.message : "Authentication required"));
3383
+ return;
3384
+ }
3103
3385
  }
3104
3386
  const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
3105
3387
  message.requestId = requestId;
3106
- const expectsResponse = !(message.type === "subscribe_collection" || message.type === "subscribe_one" || message.type === "unsubscribe" || CHANNEL_MESSAGE_TYPES.has(message.type));
3388
+ const expectsResponse = !(isSubscriptionFrame || CHANNEL_MESSAGE_TYPES.has(message.type));
3107
3389
  if (expectsResponse && !this.pendingRequests.has(requestId)) {
3108
3390
  const timeoutHandle = setTimeout(() => {
3109
3391
  if (this.pendingRequests.has(requestId)) {
@@ -3476,6 +3758,7 @@ var RebaseWebSocketClient = class {
3476
3758
  if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
3477
3759
  this.sendMessage({
3478
3760
  type: "subscribe_collection",
3761
+ requestId: backendSubscriptionId,
3479
3762
  payload: {
3480
3763
  ...subscription.props,
3481
3764
  subscriptionId: backendSubscriptionId
@@ -3497,6 +3780,7 @@ var RebaseWebSocketClient = class {
3497
3780
  if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
3498
3781
  this.sendMessage({
3499
3782
  type: "subscribe_one",
3783
+ requestId: backendSubscriptionId,
3500
3784
  payload: {
3501
3785
  ...subscription.props,
3502
3786
  subscriptionId: backendSubscriptionId
@@ -3564,6 +3848,26 @@ var RebaseWebSocketClient = class {
3564
3848
  }
3565
3849
  }
3566
3850
  /**
3851
+ * Drop every registration's cached rows, keeping the registrations.
3852
+ *
3853
+ * A registration without rows counts as not loaded, so the next listener
3854
+ * to attach asks the server (or waits for the resubscribe) instead of
3855
+ * being handed what is cached.
3856
+ */
3857
+ forgetCachedData() {
3858
+ for (const sub of this.collectionSubscriptions.values()) {
3859
+ sub.latestData = void 0;
3860
+ sub.latestMeta = void 0;
3861
+ sub.lastUpdated = void 0;
3862
+ sub.isInitialDataReceived = false;
3863
+ }
3864
+ for (const sub of this.singleSubscriptions.values()) {
3865
+ sub.latestData = void 0;
3866
+ sub.lastUpdated = void 0;
3867
+ sub.isInitialDataReceived = false;
3868
+ }
3869
+ }
3870
+ /**
3567
3871
  * Arm watchdogs for subscribes that were requested while offline and have
3568
3872
  * just been flushed to the socket. Their timers were deliberately not set at
3569
3873
  * request time, so without this they would have no timeout at all.
@@ -3616,6 +3920,7 @@ var RebaseWebSocketClient = class {
3616
3920
  resubscribeAll() {
3617
3921
  console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.singleSubscriptions.size} row(ies)`);
3618
3922
  for (const [key, sub] of this.collectionSubscriptions.entries()) {
3923
+ if (sub.subscribeInFlight) continue;
3619
3924
  const oldBackendId = sub.backendSubscriptionId;
3620
3925
  const newBackendId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
3621
3926
  sub.backendSubscriptionId = newBackendId;
@@ -3624,6 +3929,7 @@ var RebaseWebSocketClient = class {
3624
3929
  this.sendCollectionSubscribe(key);
3625
3930
  }
3626
3931
  for (const [key, sub] of this.singleSubscriptions.entries()) {
3932
+ if (sub.subscribeInFlight) continue;
3627
3933
  const oldBackendId = sub.backendSubscriptionId;
3628
3934
  const newBackendId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
3629
3935
  sub.backendSubscriptionId = newBackendId;
@@ -3702,6 +4008,27 @@ var RebaseRealtimeChannel = class {
3702
4008
  pendingLive = [];
3703
4009
  catchUpInFlight = false;
3704
4010
  /**
4011
+ * Whether {@link lastSeq} is a position this client reached, rather than
4012
+ * the zero of a handle that has not caught up yet.
4013
+ *
4014
+ * A client joining for the first time asks for whatever is retained, so
4015
+ * history pruned before it ever joined is not something it missed. Only a
4016
+ * client with a position can have a hole in what it saw.
4017
+ */
4018
+ positioned = false;
4019
+ /** The page size the catch-up in flight asked for, reused for its later pages. */
4020
+ catchUpLimit;
4021
+ /**
4022
+ * `latestSeq` of an empty page that left this client behind, while a
4023
+ * second request confirms it.
4024
+ *
4025
+ * The server reads the retained messages and then the cursor, so a message
4026
+ * committed between the two shows up in `latestSeq` and not in the page.
4027
+ * The second request returns it if it exists; only a second empty page
4028
+ * means the messages up to this seq are gone.
4029
+ */
4030
+ emptyTailSeq = null;
4031
+ /**
3705
4032
  * Deadline for a catch-up response.
3706
4033
  *
3707
4034
  * Buffering live messages is only safe because the wait is bounded. A
@@ -3793,6 +4120,7 @@ var RebaseRealtimeChannel = class {
3793
4120
  */
3794
4121
  async requestHistory(limit) {
3795
4122
  this.catchUpInFlight = true;
4123
+ this.catchUpLimit = limit;
3796
4124
  if (this.catchUpTimeout) clearTimeout(this.catchUpTimeout);
3797
4125
  this.catchUpTimeout = setTimeout(() => this.abandonCatchUp(), CATCH_UP_TIMEOUT_MS);
3798
4126
  unref(this.catchUpTimeout);
@@ -3820,6 +4148,7 @@ var RebaseRealtimeChannel = class {
3820
4148
  }
3821
4149
  if (!this.catchUpInFlight) return;
3822
4150
  this.catchUpInFlight = false;
4151
+ this.emptyTailSeq = null;
3823
4152
  for (const resolve of this.historyWaiters.splice(0)) resolve({
3824
4153
  messages: [],
3825
4154
  retained: false
@@ -3886,6 +4215,12 @@ var RebaseRealtimeChannel = class {
3886
4215
  * retention cannot be served; `CHANNEL_BUS_PAYLOAD_TOO_LARGE` when a
3887
4216
  * broadcast on an ephemeral channel is too big to cross the bus.
3888
4217
  *
4218
+ * `CHANNEL_HISTORY_GAP` is raised by the client: a catch-up found that the
4219
+ * server no longer retains messages this client never received, so its
4220
+ * state has a hole a replay cannot fill. `details` is `{ from, to }`, the
4221
+ * inclusive range of sequence numbers missed. Resync the channel's state
4222
+ * from its source of truth.
4223
+ *
3889
4224
  * These used to be dropped on the floor — there is no promise to reject on
3890
4225
  * a fire-and-forget frame, so a forbidden broadcast looked exactly like a
3891
4226
  * delivered one. With no handler attached they are logged as a warning,
@@ -3927,7 +4262,10 @@ var RebaseRealtimeChannel = class {
3927
4262
  */
3928
4263
  async history(options = {}) {
3929
4264
  await this.join();
3930
- if (options.sinceSeq !== void 0) this.lastSeq = options.sinceSeq;
4265
+ if (options.sinceSeq !== void 0) {
4266
+ this.lastSeq = options.sinceSeq;
4267
+ this.positioned = options.sinceSeq > 0;
4268
+ }
3931
4269
  const result = new Promise((resolve) => {
3932
4270
  this.historyWaiters.push(resolve);
3933
4271
  });
@@ -3942,6 +4280,8 @@ var RebaseRealtimeChannel = class {
3942
4280
  this.presenceHandlers.clear();
3943
4281
  this.broadcastHandlers.clear();
3944
4282
  this.lastSeq = 0;
4283
+ this.positioned = false;
4284
+ this.emptyTailSeq = null;
3945
4285
  this.pendingLive = [];
3946
4286
  this.catchUpInFlight = false;
3947
4287
  if (this.catchUpTimeout) {
@@ -3999,11 +4339,11 @@ var RebaseRealtimeChannel = class {
3999
4339
  }
4000
4340
  if (seq <= this.lastSeq) break;
4001
4341
  this.lastSeq = seq;
4342
+ this.positioned = true;
4002
4343
  this.deliver(event);
4003
4344
  break;
4004
4345
  }
4005
4346
  case "channel_history": {
4006
- this.catchUpInFlight = false;
4007
4347
  if (this.catchUpTimeout) {
4008
4348
  clearTimeout(this.catchUpTimeout);
4009
4349
  this.catchUpTimeout = null;
@@ -4014,6 +4354,9 @@ var RebaseRealtimeChannel = class {
4014
4354
  retained,
4015
4355
  latestSeq
4016
4356
  });
4357
+ const wasPositioned = this.positioned;
4358
+ const firstNew = entries.find((entry) => entry.seq > this.lastSeq);
4359
+ if (firstNew && wasPositioned && firstNew.seq > this.lastSeq + 1) this.reportHistoryGap(this.lastSeq + 1, firstNew.seq - 1);
4017
4360
  for (const entry of entries) {
4018
4361
  if (entry.seq <= this.lastSeq) continue;
4019
4362
  this.lastSeq = entry.seq;
@@ -4024,6 +4367,28 @@ var RebaseRealtimeChannel = class {
4024
4367
  replayed: true
4025
4368
  });
4026
4369
  }
4370
+ if (retained) this.positioned = true;
4371
+ const behind = retained && latestSeq !== void 0 && latestSeq > this.lastSeq;
4372
+ if (behind && firstNew) {
4373
+ this.emptyTailSeq = null;
4374
+ this.requestHistory(this.catchUpLimit);
4375
+ break;
4376
+ }
4377
+ if (behind && wasPositioned) {
4378
+ if (this.emptyTailSeq === null) {
4379
+ this.emptyTailSeq = latestSeq;
4380
+ this.requestHistory(this.catchUpLimit);
4381
+ break;
4382
+ }
4383
+ const firstLive = this.pendingLive.reduce((min, event) => event.seq !== void 0 && (min === void 0 || event.seq < min) ? event.seq : min, void 0);
4384
+ const end = firstLive !== void 0 ? Math.min(this.emptyTailSeq, firstLive - 1) : this.emptyTailSeq;
4385
+ if (end > this.lastSeq) {
4386
+ this.reportHistoryGap(this.lastSeq + 1, end);
4387
+ this.lastSeq = end;
4388
+ }
4389
+ }
4390
+ this.emptyTailSeq = null;
4391
+ this.catchUpInFlight = false;
4027
4392
  this.flushPendingLive();
4028
4393
  break;
4029
4394
  }
@@ -4032,20 +4397,32 @@ var RebaseRealtimeChannel = class {
4032
4397
  const raw = message.payload?.error;
4033
4398
  const asObject = typeof raw === "object" ? raw : void 0;
4034
4399
  const text = asObject?.message ?? (typeof raw === "string" ? raw : void 0) ?? message.error ?? "The server refused a channel operation.";
4035
- 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
- }
4400
+ this.emitError(new RebaseApiError$1(text, { ...asObject?.code ? { code: asObject.code } : {} }));
4045
4401
  break;
4046
4402
  }
4047
4403
  }
4048
4404
  }
4405
+ /** Tell the app that retention dropped messages it never received. */
4406
+ reportHistoryGap(from, to) {
4407
+ this.emitError(new RebaseApiError$1(`Channel "${this.name}" missed messages ${from}–${to}: the server no longer retains them. Resync this channel's state.`, {
4408
+ code: "CHANNEL_HISTORY_GAP",
4409
+ details: {
4410
+ from,
4411
+ to
4412
+ }
4413
+ }));
4414
+ }
4415
+ emitError(error) {
4416
+ if (this.errorHandlers.size === 0) {
4417
+ console.warn(`[Rebase] Channel "${this.name}" error${error.code ? ` (${error.code})` : ""}: ${error.message}. Attach \`channel.onError(...)\` to handle it.`);
4418
+ return;
4419
+ }
4420
+ for (const handler of [...this.errorHandlers]) try {
4421
+ handler(error);
4422
+ } catch (e) {
4423
+ console.error("Error in channel error handler:", e);
4424
+ }
4425
+ }
4049
4426
  /** Deliver everything held back during a catch-up, in sequence order. */
4050
4427
  flushPendingLive() {
4051
4428
  if (this.pendingLive.length === 0) return;
@@ -4056,6 +4433,7 @@ var RebaseRealtimeChannel = class {
4056
4433
  if (seq !== void 0) {
4057
4434
  if (seq <= this.lastSeq) continue;
4058
4435
  this.lastSeq = seq;
4436
+ this.positioned = true;
4059
4437
  }
4060
4438
  this.deliver(event);
4061
4439
  }
@@ -4200,6 +4578,16 @@ var IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
4200
4578
  function isIdempotencyInProgressError(error) {
4201
4579
  return error instanceof RebaseApiError && error.status === 409 && error.code === IDEMPOTENCY_IN_PROGRESS;
4202
4580
  }
4581
+ /**
4582
+ * Is this key held for a different request than the one just sent with it?
4583
+ *
4584
+ * For a create the queue already sent once, that is an answer rather than a
4585
+ * failure: the key is live only because the first attempt got through and
4586
+ * committed, and only its response went missing.
4587
+ */
4588
+ function isIdempotencyKeyReusedError(error) {
4589
+ return error instanceof RebaseApiError && error.status === 422 && error.code === "IDEMPOTENCY_KEY_REUSED";
4590
+ }
4203
4591
  /** Is this failure worth another attempt later? */
4204
4592
  function isRetryableError(error) {
4205
4593
  if (isNetworkError(error)) return true;
@@ -4550,9 +4938,10 @@ var IndexedDBOfflineStore = class {
4550
4938
  * would have included them.
4551
4939
  * - `ORDER BY` puts nulls last ascending and first descending, which is the
4552
4940
  * Postgres default.
4553
- * - 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.
4941
+ * - A filter value may arrive as a string (off a URL, say), so a string is
4942
+ * compared numerically against a number and as an instant against a date.
4943
+ * `["==", "3"]` matches the number `3`, as it does server-side. Two strings
4944
+ * compare as text: `"01234"` is not `"1234"`.
4556
4945
  *
4557
4946
  * Two things it deliberately approximates, both flagged by
4558
4947
  * {@link isExactlyEvaluable}: `searchString` becomes a case-insensitive
@@ -4592,9 +4981,11 @@ function compareValues(a, b) {
4592
4981
  const right = toComparable(b);
4593
4982
  if (isNullish(left) || isNullish(right)) return void 0;
4594
4983
  if (typeof left === "boolean" || typeof right === "boolean") return (left === true || left === "true" || left === 1 ? 1 : 0) - (right === true || right === "true" || right === 1 ? 1 : 0);
4595
- 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;
4984
+ if (isNumeric(left) || isNumeric(right)) {
4985
+ const leftNum = numericOrNaN(left);
4986
+ const rightNum = numericOrNaN(right);
4987
+ if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) return leftNum < rightNum ? -1 : leftNum > rightNum ? 1 : 0;
4988
+ }
4598
4989
  if (typeof left === "number" || typeof right === "number") {
4599
4990
  const leftTime = toTime(left);
4600
4991
  const rightTime = toTime(right);
@@ -4605,6 +4996,9 @@ function compareValues(a, b) {
4605
4996
  if (collator) return collator.compare(leftStr, rightStr);
4606
4997
  return leftStr < rightStr ? -1 : leftStr > rightStr ? 1 : 0;
4607
4998
  }
4999
+ function isNumeric(value) {
5000
+ return typeof value === "number" || typeof value === "bigint";
5001
+ }
4608
5002
  function numericOrNaN(value) {
4609
5003
  if (typeof value === "number") return value;
4610
5004
  if (typeof value === "string" && value.trim() !== "") {
@@ -4621,12 +5015,17 @@ function toTime(value) {
4621
5015
  return Number.isNaN(t) ? void 0 : t;
4622
5016
  }
4623
5017
  }
4624
- /** Equality with the wire's type erasure allowed for, but never across NULL. */
5018
+ /**
5019
+ * Equality with a string filter value allowed to name a number, a boolean or
5020
+ * an instant, but never across NULL. Two strings are equal only when they are
5021
+ * the same string, as text is in Postgres.
5022
+ */
4625
5023
  function looseEquals(a, b) {
4626
5024
  const left = toComparable(a);
4627
5025
  const right = toComparable(b);
4628
5026
  if (isNullish(left) || isNullish(right)) return isNullish(left) && isNullish(right);
4629
5027
  if (left === right) return true;
5028
+ if (typeof left === "string" && typeof right === "string") return false;
4630
5029
  return compareValues(left, right) === 0;
4631
5030
  }
4632
5031
  /**
@@ -4911,14 +5310,14 @@ function logicalReachesThroughRelation(condition, depth = 0) {
4911
5310
  *
4912
5311
  * Asked of the rows rather than of the query, because unlike a filter this one
4913
5312
  * *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
5313
+ * collator only when neither operand is a number, and `toComparable` has
5314
+ * already turned dates and relations into numbers and ids by then. If every
4916
5315
  * value on the sort column normalises to a number, the collator is unreachable
4917
5316
  * and the local order is the server's order.
4918
5317
  *
4919
5318
  * 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.
5319
+ * the two cannot be made to agree — including one holding only digits, which
5320
+ * Postgres orders as text ("10", "100", "9").
4922
5321
  *
4923
5322
  * Nulls are fine either way: they are ordered by an explicit rule (last
4924
5323
  * ascending, first descending) that matches Postgres and never reaches the
@@ -4933,7 +5332,6 @@ function isLocallySortable(rows, orderBy) {
4933
5332
  if (isNullish(value)) return true;
4934
5333
  if (typeof value === "number" || typeof value === "boolean") return true;
4935
5334
  if (typeof value === "bigint") return true;
4936
- if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) return true;
4937
5335
  return false;
4938
5336
  }));
4939
5337
  }
@@ -5025,6 +5423,29 @@ function optimisticRow(fields, id) {
5025
5423
  id
5026
5424
  };
5027
5425
  }
5426
+ /**
5427
+ * Whether a query answers with something narrower than whole rows: `fields`
5428
+ * returns only the columns it names (and the key), and `distinct` collapses
5429
+ * rows into their distinct projections. Either way what comes back is not a
5430
+ * row of the collection, and must not be stored as one.
5431
+ */
5432
+ function isProjection(params) {
5433
+ return (params?.fields?.length ?? 0) > 0 || params?.distinct === true;
5434
+ }
5435
+ /** `row` narrowed to `keys`: a local row in the shape a projection has. */
5436
+ function pick(row, keys) {
5437
+ const out = {};
5438
+ for (const key of keys) if (key in row) out[key] = row[key];
5439
+ return out;
5440
+ }
5441
+ /** A snapshot as read back from the store, its projected rows revived. */
5442
+ function readSnapshot(value) {
5443
+ const snapshot = value;
5444
+ return snapshot.rows ? {
5445
+ ...snapshot,
5446
+ rows: snapshot.rows.map((row) => hydrateRow(row))
5447
+ } : snapshot;
5448
+ }
5028
5449
  var MISSING = "\0missing";
5029
5450
  /**
5030
5451
  * Replays to spend on a mutation whose idempotency key the server is still
@@ -5203,7 +5624,7 @@ var OfflineManager = class {
5203
5624
  if (this.connectivity.shouldAttempt()) try {
5204
5625
  const res = await inner.find(params);
5205
5626
  this.connectivity.markSuccess();
5206
- await this.ingest(slug, res.data ?? []);
5627
+ await this.ingest(slug, res.data ?? [], isProjection(params));
5207
5628
  const snapshot = this.recordSnapshot(slug, params, res);
5208
5629
  const answer = this.answer(slug, params, snapshot);
5209
5630
  this.notifyCollection(slug, false);
@@ -5259,10 +5680,15 @@ var OfflineManager = class {
5259
5680
  });
5260
5681
  return row;
5261
5682
  },
5262
- create: async (data, id) => {
5683
+ create: async (data, id, options) => {
5263
5684
  await this.ensureCollection(slug);
5685
+ const idempotencyKey = options?.idempotencyKey ?? createMutationId();
5686
+ let sent = false;
5264
5687
  if (this.connectivity.shouldAttempt()) try {
5265
- const row = await inner.create(data, id);
5688
+ const row = await inner.create(data, id, {
5689
+ ...options,
5690
+ idempotencyKey
5691
+ });
5266
5692
  this.connectivity.markSuccess();
5267
5693
  await this.ingest(slug, [row]);
5268
5694
  this.notifyCollection(slug);
@@ -5271,6 +5697,7 @@ var OfflineManager = class {
5271
5697
  } catch (error) {
5272
5698
  if (!isNetworkError(error)) throw error;
5273
5699
  this.connectivity.markFailure();
5700
+ sent = true;
5274
5701
  }
5275
5702
  const providedId = id ?? data.id;
5276
5703
  const rowId = providedId ?? this.mintOfflineId(slug);
@@ -5281,6 +5708,11 @@ var OfflineManager = class {
5281
5708
  id: rowId,
5282
5709
  data: row,
5283
5710
  generatedId: providedId === void 0,
5711
+ ...sent ? { sent: {
5712
+ idempotencyKey,
5713
+ data: { ...data },
5714
+ id
5715
+ } } : {},
5284
5716
  rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
5285
5717
  });
5286
5718
  this.setLocalRow(slug, rowId, row);
@@ -5291,8 +5723,13 @@ var OfflineManager = class {
5291
5723
  await this.ensureCollection(slug);
5292
5724
  if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
5293
5725
  if (data.length === 0) return [];
5726
+ const idempotencyKey = options?.idempotencyKey ?? createMutationId();
5727
+ let sent = false;
5294
5728
  if (this.connectivity.shouldAttempt()) try {
5295
- const rows = await inner.createMany(data, options);
5729
+ const rows = await inner.createMany(data, {
5730
+ ...options,
5731
+ idempotencyKey
5732
+ });
5296
5733
  this.connectivity.markSuccess();
5297
5734
  await this.ingest(slug, rows);
5298
5735
  this.notifyCollection(slug);
@@ -5301,7 +5738,9 @@ var OfflineManager = class {
5301
5738
  } catch (error) {
5302
5739
  if (!isNetworkError(error)) throw error;
5303
5740
  this.connectivity.markFailure();
5741
+ sent = true;
5304
5742
  }
5743
+ if (!sent && options?.onConflict?.length) throw new RebaseClientError(`Cannot upsert into "${slug}" while offline: the rows it would replace can only be found by the server. Upserting on the primary key, with the key in each row, is queued; upserting on a natural key is not.`, { code: "OFFLINE_UPSERT_UNSUPPORTED" });
5305
5744
  const rows = data.map((r) => optimisticRow(r, r.id ?? this.mintOfflineId(slug)));
5306
5745
  const rollback = {};
5307
5746
  for (const row of rows) {
@@ -5313,6 +5752,11 @@ var OfflineManager = class {
5313
5752
  type: "createMany",
5314
5753
  data: rows,
5315
5754
  upsert: options?.upsert,
5755
+ ...sent ? { sent: {
5756
+ idempotencyKey,
5757
+ data: data.map((r) => ({ ...r })),
5758
+ ...options?.onConflict?.length ? { onConflict: [...options.onConflict] } : {}
5759
+ } } : {},
5316
5760
  rollback: { rows: rollback }
5317
5761
  });
5318
5762
  for (const row of rows) this.setLocalRow(slug, row.id, row);
@@ -5424,8 +5868,14 @@ var OfflineManager = class {
5424
5868
  },
5425
5869
  update: async (id, data, options) => {
5426
5870
  await this.ensureCollection(slug);
5871
+ const idempotencyKey = options?.idempotencyKey ?? createMutationId();
5872
+ let sent = false;
5873
+ let sendFailure;
5427
5874
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
5428
- const row = await inner.update(id, data, options);
5875
+ const row = await inner.update(id, data, {
5876
+ ...options,
5877
+ idempotencyKey
5878
+ });
5429
5879
  this.connectivity.markSuccess();
5430
5880
  await this.ingest(slug, [row]);
5431
5881
  this.notifyCollection(slug);
@@ -5433,14 +5883,25 @@ var OfflineManager = class {
5433
5883
  } catch (error) {
5434
5884
  if (!isNetworkError(error)) throw error;
5435
5885
  this.connectivity.markFailure();
5886
+ sent = true;
5887
+ sendFailure = error;
5888
+ }
5889
+ if (hasFieldOperation(data)) {
5890
+ if (sent) throw new RebaseApiError(`The connection failed after a field operation on "${slug}" was sent, so it may or may not have been applied. Retry it with the idempotencyKey in \`details\` to apply it at most once.`, {
5891
+ status: 0,
5892
+ code: "NETWORK_ERROR",
5893
+ details: { idempotencyKey },
5894
+ cause: sendFailure
5895
+ });
5896
+ throw new RebaseClientError(`Cannot apply a field operation to "${slug}" while offline: it is evaluated against the stored value, which this device does not have a current copy of. Send the value itself, or retry when the connection is back.`, { code: "OFFLINE_FIELD_OP_UNSUPPORTED" });
5436
5897
  }
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
5898
  const base = this.rawLocalRow(slug, id);
5439
5899
  await this.enqueue({
5440
5900
  collection: slug,
5441
5901
  type: "update",
5442
5902
  id,
5443
5903
  data,
5904
+ ...sent ? { sent: { idempotencyKey } } : {},
5444
5905
  rollback: { rows: { [String(id)]: base ?? null } }
5445
5906
  });
5446
5907
  const optimistic = optimisticRow({
@@ -5453,8 +5914,13 @@ var OfflineManager = class {
5453
5914
  },
5454
5915
  delete: async (id, options) => {
5455
5916
  await this.ensureCollection(slug);
5917
+ const idempotencyKey = options?.idempotencyKey ?? createMutationId();
5918
+ let sent = false;
5456
5919
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
5457
- await inner.delete(id, options);
5920
+ await inner.delete(id, {
5921
+ ...options,
5922
+ idempotencyKey
5923
+ });
5458
5924
  this.connectivity.markSuccess();
5459
5925
  this.removeLocalRow(slug, id, true);
5460
5926
  this.notifyCollection(slug);
@@ -5463,11 +5929,13 @@ var OfflineManager = class {
5463
5929
  } catch (error) {
5464
5930
  if (!isNetworkError(error)) throw error;
5465
5931
  this.connectivity.markFailure();
5932
+ sent = true;
5466
5933
  }
5467
5934
  await this.enqueue({
5468
5935
  collection: slug,
5469
5936
  type: "delete",
5470
5937
  id,
5938
+ ...sent ? { sent: { idempotencyKey } } : {},
5471
5939
  rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
5472
5940
  });
5473
5941
  this.removeLocalRow(slug, id);
@@ -5511,7 +5979,7 @@ var OfflineManager = class {
5511
5979
  listenById: inner.listenById
5512
5980
  };
5513
5981
  if (!isUnsupported(inner.listen)) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
5514
- this.ingest(slug, response.data ?? []).then(() => this.notifyCollection(slug, false));
5982
+ this.ingest(slug, response.data ?? [], isProjection(params)).then(() => this.notifyCollection(slug, false));
5515
5983
  onUpdate(response);
5516
5984
  }, onError);
5517
5985
  if (!isUnsupported(inner.listenById)) wrapped.listenById = (id, onUpdate, onError) => inner.listenById(id, (row) => {
@@ -5560,7 +6028,7 @@ var OfflineManager = class {
5560
6028
  if (!closed) observer.emit();
5561
6029
  })();
5562
6030
  if (options?.realtime !== false && !isUnsupported(inner.listen)) unlisten = inner.listen(params, (response) => {
5563
- this.ingest(slug, response.data ?? []).then(() => {
6031
+ this.ingest(slug, response.data ?? [], isProjection(params)).then(() => {
5564
6032
  this.recordSnapshot(slug, params, response);
5565
6033
  this.notifyCollection(slug, false);
5566
6034
  });
@@ -5638,7 +6106,8 @@ var OfflineManager = class {
5638
6106
  const state = this.collections.get(slug);
5639
6107
  return `${total}|${rows.map((row) => {
5640
6108
  const key = String(row.id);
5641
- return `${key}:${state?.rows.get(key)?.rev ?? 0}`;
6109
+ const entry = state?.rows.get(key);
6110
+ return entry?.row === row ? `${key}:${entry.rev}` : `${key}=${JSON.stringify(row)}`;
5642
6111
  }).join(",")}`;
5643
6112
  }
5644
6113
  notifyCollection(slug, broadcast = true) {
@@ -5694,7 +6163,7 @@ var OfflineManager = class {
5694
6163
  }
5695
6164
  for (const entry of snapshots) {
5696
6165
  const key = entry.key.slice(`${scope}|q|${slug}|`.length);
5697
- if (entry.value) state.snapshots.set(key, entry.value);
6166
+ if (entry.value) state.snapshots.set(key, readSnapshot(entry.value));
5698
6167
  }
5699
6168
  for (const entry of absent) state.absent.add(entry.key.slice(`${scope}|abs|${slug}|`.length));
5700
6169
  })().catch(() => void 0).finally(() => {
@@ -5744,6 +6213,7 @@ var OfflineManager = class {
5744
6213
  partial: true
5745
6214
  };
5746
6215
  }
6216
+ if (snapshot.rows) return this.projectedAnswer(slug, params, snapshot, snapshot.rows, fromCache);
5747
6217
  const rows = [];
5748
6218
  const seen = /* @__PURE__ */ new Set();
5749
6219
  /** Rows the server counted that we know are no longer in the result. */
@@ -5786,6 +6256,71 @@ var OfflineManager = class {
5786
6256
  partial: !exact || !orderIsLocal
5787
6257
  };
5788
6258
  }
6259
+ /**
6260
+ * Answer a projected query (`fields`, `distinct`) from the server's own
6261
+ * answer to it.
6262
+ *
6263
+ * The server's rows are the answer. The local database contributes only
6264
+ * what it knows better: a row with unsynced writes shows them, narrowed to
6265
+ * the columns the server sent, or drops out if it was deleted here or
6266
+ * edited out of a filter this side can evaluate; and rows created here
6267
+ * join the first page when there is somewhere to put them. A distinct
6268
+ * query, or an order on a column the projection does not carry, leaves
6269
+ * them out, and the result says it is partial.
6270
+ */
6271
+ projectedAnswer(slug, params, snapshot, served, fromCache) {
6272
+ const state = this.collectionState(slug);
6273
+ const exact = isExactlyEvaluable(params);
6274
+ const rows = [];
6275
+ const seen = /* @__PURE__ */ new Set();
6276
+ let removed = 0;
6277
+ for (const row of served) {
6278
+ const key = row.id === void 0 || row.id === null ? void 0 : String(row.id);
6279
+ if (key !== void 0) seen.add(key);
6280
+ if (key === void 0 || !this.hasPending(slug, key)) {
6281
+ rows.push(row);
6282
+ continue;
6283
+ }
6284
+ const local = state.rows.get(key)?.row;
6285
+ if (!local || exact && !matchesParams(local, params)) {
6286
+ removed++;
6287
+ continue;
6288
+ }
6289
+ rows.push(pick(local, Object.keys(row)));
6290
+ }
6291
+ let added = 0;
6292
+ let unplaced = false;
6293
+ const offset = snapshot.offset ?? 0;
6294
+ if (exact && offset === 0) {
6295
+ const shape = served.length > 0 ? Object.keys(served[0]) : ["id", ...params?.fields ?? []];
6296
+ const placeable = !params?.distinct && (normalizeOrderBy(params?.orderBy) ?? []).every(([field]) => shape.includes(field));
6297
+ for (const [key, entry] of state.rows) {
6298
+ if (seen.has(key) || !this.hasPending(slug, key)) continue;
6299
+ if (!this.isLocallyCreated(slug, key)) continue;
6300
+ if (!matchesParams(entry.row, params)) continue;
6301
+ if (!placeable) {
6302
+ unplaced = true;
6303
+ continue;
6304
+ }
6305
+ rows.push(pick(entry.row, shape));
6306
+ added++;
6307
+ }
6308
+ }
6309
+ const orderIsLocal = added === 0 || isLocallySortable(rows, params?.orderBy);
6310
+ if (added > 0 && params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
6311
+ return {
6312
+ data: rows,
6313
+ meta: {
6314
+ total: Math.max(rows.length, snapshot.total - removed + added),
6315
+ limit: snapshot.limit,
6316
+ offset,
6317
+ hasMore: snapshot.hasMore
6318
+ },
6319
+ fromCache,
6320
+ hasPendingWrites: rows.some((row) => row.id !== void 0 && row.id !== null && this.hasPending(slug, row.id)),
6321
+ partial: !exact || !orderIsLocal || unplaced
6322
+ };
6323
+ }
5789
6324
  localFind(slug, params) {
5790
6325
  const state = this.collections.get(slug);
5791
6326
  const snapshot = this.snapshotFor(slug, params);
@@ -5869,8 +6404,14 @@ var OfflineManager = class {
5869
6404
  * Rows that came back unchanged keep their identity and revision, so a
5870
6405
  * refetch that changed nothing does not re-render every live query that
5871
6406
  * touches them — or rewrite them all to disk.
6407
+ *
6408
+ * A `projection` (see {@link isProjection}) only refreshes the columns it
6409
+ * carries on rows already held, and never creates one. It is not a row:
6410
+ * stored as one, it replaced the full row every other query reads and
6411
+ * wrote the narrowed copy to disk, so a list lost its columns because a
6412
+ * dropdown elsewhere asked for titles.
5872
6413
  */
5873
- async ingest(slug, rows) {
6414
+ async ingest(slug, rows, projection = false) {
5874
6415
  if (rows.length === 0) return;
5875
6416
  const state = await this.ensureCollection(slug);
5876
6417
  const cachedAt = Date.now();
@@ -5879,15 +6420,20 @@ var OfflineManager = class {
5879
6420
  for (const raw of rows) {
5880
6421
  if (!raw || raw.id === void 0 || raw.id === null) continue;
5881
6422
  const key = String(raw.id);
5882
- const merged = this.hasPending(slug, key) ? this.applyPendingToRow(slug, key, { ...raw }) : { ...raw };
6423
+ const existing = state.rows.get(key);
6424
+ if (projection && !existing) continue;
6425
+ const base = projection && existing ? {
6426
+ ...existing.row,
6427
+ ...raw
6428
+ } : { ...raw };
6429
+ const merged = this.hasPending(slug, key) ? this.applyPendingToRow(slug, key, base) : base;
5883
6430
  if (merged === void 0) {
5884
6431
  state.rows.delete(key);
5885
6432
  deletes.push(this.rowKey(slug, key));
5886
6433
  continue;
5887
6434
  }
5888
6435
  this.forgetTombstone(slug, key);
5889
- state.freshRows.add(key);
5890
- const existing = state.rows.get(key);
6436
+ if (!projection) state.freshRows.add(key);
5891
6437
  if (existing && JSON.stringify(existing.row) === JSON.stringify(merged)) {
5892
6438
  existing.cachedAt = cachedAt;
5893
6439
  continue;
@@ -5952,13 +6498,17 @@ var OfflineManager = class {
5952
6498
  total: meta.total ?? result.data?.length ?? 0,
5953
6499
  limit: meta.limit ?? window.limit,
5954
6500
  offset: meta.offset ?? window.offset,
5955
- hasMore: meta.hasMore ?? false
6501
+ hasMore: meta.hasMore ?? false,
6502
+ ...isProjection(params) ? { rows: (result.data ?? []).map((row) => ({ ...row })) } : {}
5956
6503
  };
5957
6504
  const state = this.collectionState(slug);
5958
6505
  const key = buildQueryString(params);
5959
6506
  state.snapshots.set(key, snapshot);
5960
6507
  state.fresh.add(key);
5961
- this.writeCache(`${this.scope}|q|${slug}|${key}`, snapshot);
6508
+ this.writeCache(`${this.scope}|q|${slug}|${key}`, snapshot.rows ? {
6509
+ ...snapshot,
6510
+ rows: snapshot.rows.map((row) => dehydrateRow(row))
6511
+ } : snapshot);
5962
6512
  this.evictSnapshots(slug);
5963
6513
  return snapshot;
5964
6514
  }
@@ -6022,7 +6572,7 @@ var OfflineManager = class {
6022
6572
  await this.ensureQueueLoaded();
6023
6573
  if (mutation.type === "update") {
6024
6574
  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) {
6575
+ if (tail && tail.mutationId !== this.inFlightId && tail.sent === void 0 && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
6026
6576
  tail.data = {
6027
6577
  ...tail.data,
6028
6578
  ...mutation.data,
@@ -6033,7 +6583,7 @@ var OfflineManager = class {
6033
6583
  }
6034
6584
  }
6035
6585
  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)) {
6586
+ if (this.queue.some((m) => m.collection === mutation.collection && m.type === "create" && m.id === mutation.id && m.generatedId === true && m.sent === void 0 && m.mutationId !== this.inFlightId)) {
6037
6587
  const doomed = this.queue.filter((m) => m.collection === mutation.collection && m.id === mutation.id && (m.type === "create" || m.type === "update") && m.mutationId !== this.inFlightId);
6038
6588
  for (const op of doomed) await this.store.dequeue(this.queueKey(op));
6039
6589
  this.queue = this.queue.filter((m) => !doomed.includes(m));
@@ -6158,7 +6708,7 @@ var OfflineManager = class {
6158
6708
  if (op.type === "create") {
6159
6709
  let row;
6160
6710
  try {
6161
- row = await inner.create(op.data, void 0, { idempotencyKey: op.mutationId });
6711
+ row = await this.sendQueuedWrite(op, (idempotencyKey) => inner.create(op.data, void 0, { idempotencyKey }), (sent) => inner.create(sent.data, sent.id, { idempotencyKey: sent.idempotencyKey }));
6162
6712
  } catch (error) {
6163
6713
  if (!(op.generatedId === true && isDuplicateKeyError(error))) throw error;
6164
6714
  row = await inner.findById(op.id).catch(() => void 0);
@@ -6167,10 +6717,15 @@ var OfflineManager = class {
6167
6717
  await this.adoptServerRow(op, op.id, row);
6168
6718
  } else if (op.type === "createMany") {
6169
6719
  const queued = op.data ?? [];
6170
- const rows = await inner.createMany(queued, {
6720
+ const asSent = (sent) => inner.createMany(sent.data, {
6171
6721
  ...op.upsert ? { upsert: true } : {},
6172
- idempotencyKey: op.mutationId
6722
+ ...sent.onConflict?.length ? { onConflict: sent.onConflict } : {},
6723
+ idempotencyKey: sent.idempotencyKey
6173
6724
  });
6725
+ const rows = op.sent?.onConflict?.length ? await asSent(op.sent) : await this.sendQueuedWrite(op, (idempotencyKey) => inner.createMany(queued, {
6726
+ ...op.upsert ? { upsert: true } : {},
6727
+ idempotencyKey
6728
+ }), asSent);
6174
6729
  for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
6175
6730
  } else if (op.type === "updateMany") {
6176
6731
  const queued = op.updates ?? [];
@@ -6180,18 +6735,36 @@ var OfflineManager = class {
6180
6735
  })), { idempotencyKey: op.mutationId });
6181
6736
  for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
6182
6737
  } else if (op.type === "update") {
6183
- const row = await inner.update(op.id, op.data);
6738
+ const row = await inner.update(op.id, op.data, op.sent ? { idempotencyKey: op.sent.idempotencyKey } : void 0);
6184
6739
  await this.ingestReplaced(op, op.id, row);
6185
6740
  } else if (op.type === "deleteMany") {
6186
6741
  const ids = op.ids ?? [];
6187
6742
  await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
6188
6743
  for (const id of ids) this.removeLocalRow(op.collection, id, true);
6189
6744
  } else if (op.type === "delete") {
6190
- await inner.delete(op.id);
6745
+ await inner.delete(op.id, { idempotencyKey: op.sent?.idempotencyKey ?? op.mutationId });
6191
6746
  this.removeLocalRow(op.collection, op.id, true);
6192
6747
  }
6193
6748
  }
6194
6749
  /**
6750
+ * Send a queued write under its key.
6751
+ *
6752
+ * A write the queue sent once already, before it was queued, carries that
6753
+ * attempt's key and is sent under it again, as queued. If the server holds
6754
+ * the key for a different request, the difference is the ids minted here
6755
+ * — so the first attempt committed and only its answer was lost, and
6756
+ * sending that request again returns the stored answer instead of a
6757
+ * second copy of the rows.
6758
+ */
6759
+ async sendQueuedWrite(op, asQueued, asSent) {
6760
+ try {
6761
+ return await asQueued(op.sent?.idempotencyKey ?? op.mutationId);
6762
+ } catch (error) {
6763
+ if (!op.sent || !isIdempotencyKeyReusedError(error)) throw error;
6764
+ return asSent(op.sent);
6765
+ }
6766
+ }
6767
+ /**
6195
6768
  * Take the server's version of a row the client created offline.
6196
6769
  *
6197
6770
  * The server may have assigned a different id — a serial column ignores
@@ -6362,7 +6935,7 @@ var OfflineManager = class {
6362
6935
  state.snapshots = /* @__PURE__ */ new Map();
6363
6936
  for (const entry of snapshots) {
6364
6937
  const key = entry.key.slice(`${scope}|q|${slug}|`.length);
6365
- if (entry.value) state.snapshots.set(key, entry.value);
6938
+ if (entry.value) state.snapshots.set(key, readSnapshot(entry.value));
6366
6939
  }
6367
6940
  state.absent = new Set(absent.map((entry) => entry.key.slice(`${scope}|abs|${slug}|`.length)));
6368
6941
  this.notifyCollection(slug, false);
@@ -6503,15 +7076,28 @@ function createRebaseClient(options) {
6503
7076
  let session = auth.getSession();
6504
7077
  if (session && session.expiresAt <= Date.now() + 1e4) try {
6505
7078
  session = await auth.refreshSession();
6506
- } catch (e) {}
7079
+ } catch (e) {
7080
+ session = auth.getSession();
7081
+ }
6507
7082
  return session?.accessToken || options.token || "";
6508
7083
  },
6509
7084
  onUnauthorized: options.onUnauthorized || (() => auth.handleUnauthorized())
6510
7085
  });
7086
+ let realtimeUid = auth.getSession()?.user?.uid ?? null;
6511
7087
  auth.onAuthStateChange((event, session) => {
6512
7088
  if (!ws) return;
6513
- if (event === "SIGNED_OUT") ws.disconnect();
6514
- else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
7089
+ if (event === "SIGNED_OUT") {
7090
+ realtimeUid = null;
7091
+ ws.disconnect();
7092
+ } else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
7093
+ const uid = session?.user?.uid ?? null;
7094
+ if (uid !== realtimeUid) {
7095
+ realtimeUid = uid;
7096
+ const hadSocket = ws.hasSocket;
7097
+ ws.disconnect();
7098
+ if (hadSocket) ws.ensureConnected();
7099
+ return;
7100
+ }
6515
7101
  if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
6516
7102
  }
6517
7103
  });
@@ -6651,7 +7237,7 @@ channel: (name, options) => {
6651
7237
  const prefix = endpoint.startsWith("/") ? "" : "/";
6652
7238
  return transport.request(`${prefix}${endpoint}`, {
6653
7239
  method: "POST",
6654
- body: payload ? JSON.stringify(payload) : void 0
7240
+ body: payload !== void 0 ? JSON.stringify(payload) : void 0
6655
7241
  });
6656
7242
  },
6657
7243
  batch: async (operations, options) => {