@rebasepro/client 0.21.2-canary.g1ea48be → 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,6 +1,6 @@
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";
3
- import { toSnakeCase, unref } from "@rebasepro/utils";
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
+ import { splitSearchTerms, toSnakeCase, unref } from "@rebasepro/utils";
4
4
  //#region src/reviver.ts
5
5
  function rebaseReviver(_key, value) {
6
6
  if (value && typeof value === "object" && "__type" in 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,12 +3057,46 @@ 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);
2820
3070
  }
2821
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;
3097
+ }
3098
+ return true;
3099
+ }
2822
3100
  attemptReconnect() {
2823
3101
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
2824
3102
  console.error("Max reconnection attempts reached");
@@ -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
  /**
@@ -4742,16 +5141,23 @@ function matchesLogical(row, condition) {
4742
5141
  * cached row does not carry — a local list may therefore be missing rows the
4743
5142
  * server would have returned, which is why {@link isExactlyEvaluable} refuses
4744
5143
  * to call a search query exact.
5144
+ *
5145
+ * Every term has to match, but they may match different fields — the same rule
5146
+ * both drivers apply, and the one that finds `sebastian melendez` on a row that
5147
+ * keeps the two halves of a name in separate fields. Matching the typed string
5148
+ * as a whole found neither that row nor, because the space was part of the
5149
+ * needle, anything at all for `sebastian `.
4745
5150
  */
4746
5151
  function matchesSearch(row, searchString) {
4747
5152
  if (!searchString) return true;
4748
- const needle = searchString.trim().toLowerCase();
4749
- if (!needle) return true;
4750
- for (const value of Object.values(row)) {
4751
- if (typeof value === "string" && value.toLowerCase().includes(needle)) return true;
4752
- if (typeof value === "number" && String(value).includes(needle)) return true;
4753
- }
4754
- return false;
5153
+ const terms = splitSearchTerms(searchString).map((term) => term.toLowerCase());
5154
+ if (terms.length === 0) return true;
5155
+ const values = Object.values(row);
5156
+ return terms.every((term) => values.some((value) => {
5157
+ if (typeof value === "string") return value.toLowerCase().includes(term);
5158
+ if (typeof value === "number") return String(value).includes(term);
5159
+ return false;
5160
+ }));
4755
5161
  }
4756
5162
  /** Does this row belong in the result set for `params`, ignoring pagination? */
4757
5163
  function matchesParams(row, params) {
@@ -4904,14 +5310,14 @@ function logicalReachesThroughRelation(condition, depth = 0) {
4904
5310
  *
4905
5311
  * Asked of the rows rather than of the query, because unlike a filter this one
4906
5312
  * *is* decidable from the data in hand: {@link compareValues} reaches the
4907
- * collator only when it cannot read both operands as numbers, and `toComparable`
4908
- * 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
4909
5315
  * value on the sort column normalises to a number, the collator is unreachable
4910
5316
  * and the local order is the server's order.
4911
5317
  *
4912
5318
  * A text column is therefore refused — see {@link isExactlyEvaluable} for why
4913
- * the two cannot be made to agree — and so is a column this page happens to see
4914
- * 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").
4915
5321
  *
4916
5322
  * Nulls are fine either way: they are ordered by an explicit rule (last
4917
5323
  * ascending, first descending) that matches Postgres and never reaches the
@@ -4926,7 +5332,6 @@ function isLocallySortable(rows, orderBy) {
4926
5332
  if (isNullish(value)) return true;
4927
5333
  if (typeof value === "number" || typeof value === "boolean") return true;
4928
5334
  if (typeof value === "bigint") return true;
4929
- if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) return true;
4930
5335
  return false;
4931
5336
  }));
4932
5337
  }
@@ -5018,6 +5423,29 @@ function optimisticRow(fields, id) {
5018
5423
  id
5019
5424
  };
5020
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
+ }
5021
5449
  var MISSING = "\0missing";
5022
5450
  /**
5023
5451
  * Replays to spend on a mutation whose idempotency key the server is still
@@ -5196,7 +5624,7 @@ var OfflineManager = class {
5196
5624
  if (this.connectivity.shouldAttempt()) try {
5197
5625
  const res = await inner.find(params);
5198
5626
  this.connectivity.markSuccess();
5199
- await this.ingest(slug, res.data ?? []);
5627
+ await this.ingest(slug, res.data ?? [], isProjection(params));
5200
5628
  const snapshot = this.recordSnapshot(slug, params, res);
5201
5629
  const answer = this.answer(slug, params, snapshot);
5202
5630
  this.notifyCollection(slug, false);
@@ -5252,10 +5680,15 @@ var OfflineManager = class {
5252
5680
  });
5253
5681
  return row;
5254
5682
  },
5255
- create: async (data, id) => {
5683
+ create: async (data, id, options) => {
5256
5684
  await this.ensureCollection(slug);
5685
+ const idempotencyKey = options?.idempotencyKey ?? createMutationId();
5686
+ let sent = false;
5257
5687
  if (this.connectivity.shouldAttempt()) try {
5258
- const row = await inner.create(data, id);
5688
+ const row = await inner.create(data, id, {
5689
+ ...options,
5690
+ idempotencyKey
5691
+ });
5259
5692
  this.connectivity.markSuccess();
5260
5693
  await this.ingest(slug, [row]);
5261
5694
  this.notifyCollection(slug);
@@ -5264,6 +5697,7 @@ var OfflineManager = class {
5264
5697
  } catch (error) {
5265
5698
  if (!isNetworkError(error)) throw error;
5266
5699
  this.connectivity.markFailure();
5700
+ sent = true;
5267
5701
  }
5268
5702
  const providedId = id ?? data.id;
5269
5703
  const rowId = providedId ?? this.mintOfflineId(slug);
@@ -5274,6 +5708,11 @@ var OfflineManager = class {
5274
5708
  id: rowId,
5275
5709
  data: row,
5276
5710
  generatedId: providedId === void 0,
5711
+ ...sent ? { sent: {
5712
+ idempotencyKey,
5713
+ data: { ...data },
5714
+ id
5715
+ } } : {},
5277
5716
  rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
5278
5717
  });
5279
5718
  this.setLocalRow(slug, rowId, row);
@@ -5284,8 +5723,13 @@ var OfflineManager = class {
5284
5723
  await this.ensureCollection(slug);
5285
5724
  if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
5286
5725
  if (data.length === 0) return [];
5726
+ const idempotencyKey = options?.idempotencyKey ?? createMutationId();
5727
+ let sent = false;
5287
5728
  if (this.connectivity.shouldAttempt()) try {
5288
- const rows = await inner.createMany(data, options);
5729
+ const rows = await inner.createMany(data, {
5730
+ ...options,
5731
+ idempotencyKey
5732
+ });
5289
5733
  this.connectivity.markSuccess();
5290
5734
  await this.ingest(slug, rows);
5291
5735
  this.notifyCollection(slug);
@@ -5294,7 +5738,9 @@ var OfflineManager = class {
5294
5738
  } catch (error) {
5295
5739
  if (!isNetworkError(error)) throw error;
5296
5740
  this.connectivity.markFailure();
5741
+ sent = true;
5297
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" });
5298
5744
  const rows = data.map((r) => optimisticRow(r, r.id ?? this.mintOfflineId(slug)));
5299
5745
  const rollback = {};
5300
5746
  for (const row of rows) {
@@ -5306,6 +5752,11 @@ var OfflineManager = class {
5306
5752
  type: "createMany",
5307
5753
  data: rows,
5308
5754
  upsert: options?.upsert,
5755
+ ...sent ? { sent: {
5756
+ idempotencyKey,
5757
+ data: data.map((r) => ({ ...r })),
5758
+ ...options?.onConflict?.length ? { onConflict: [...options.onConflict] } : {}
5759
+ } } : {},
5309
5760
  rollback: { rows: rollback }
5310
5761
  });
5311
5762
  for (const row of rows) this.setLocalRow(slug, row.id, row);
@@ -5417,8 +5868,14 @@ var OfflineManager = class {
5417
5868
  },
5418
5869
  update: async (id, data, options) => {
5419
5870
  await this.ensureCollection(slug);
5871
+ const idempotencyKey = options?.idempotencyKey ?? createMutationId();
5872
+ let sent = false;
5873
+ let sendFailure;
5420
5874
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
5421
- const row = await inner.update(id, data, options);
5875
+ const row = await inner.update(id, data, {
5876
+ ...options,
5877
+ idempotencyKey
5878
+ });
5422
5879
  this.connectivity.markSuccess();
5423
5880
  await this.ingest(slug, [row]);
5424
5881
  this.notifyCollection(slug);
@@ -5426,14 +5883,25 @@ var OfflineManager = class {
5426
5883
  } catch (error) {
5427
5884
  if (!isNetworkError(error)) throw error;
5428
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" });
5429
5897
  }
5430
- 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" });
5431
5898
  const base = this.rawLocalRow(slug, id);
5432
5899
  await this.enqueue({
5433
5900
  collection: slug,
5434
5901
  type: "update",
5435
5902
  id,
5436
5903
  data,
5904
+ ...sent ? { sent: { idempotencyKey } } : {},
5437
5905
  rollback: { rows: { [String(id)]: base ?? null } }
5438
5906
  });
5439
5907
  const optimistic = optimisticRow({
@@ -5446,8 +5914,13 @@ var OfflineManager = class {
5446
5914
  },
5447
5915
  delete: async (id, options) => {
5448
5916
  await this.ensureCollection(slug);
5917
+ const idempotencyKey = options?.idempotencyKey ?? createMutationId();
5918
+ let sent = false;
5449
5919
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
5450
- await inner.delete(id, options);
5920
+ await inner.delete(id, {
5921
+ ...options,
5922
+ idempotencyKey
5923
+ });
5451
5924
  this.connectivity.markSuccess();
5452
5925
  this.removeLocalRow(slug, id, true);
5453
5926
  this.notifyCollection(slug);
@@ -5456,11 +5929,13 @@ var OfflineManager = class {
5456
5929
  } catch (error) {
5457
5930
  if (!isNetworkError(error)) throw error;
5458
5931
  this.connectivity.markFailure();
5932
+ sent = true;
5459
5933
  }
5460
5934
  await this.enqueue({
5461
5935
  collection: slug,
5462
5936
  type: "delete",
5463
5937
  id,
5938
+ ...sent ? { sent: { idempotencyKey } } : {},
5464
5939
  rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
5465
5940
  });
5466
5941
  this.removeLocalRow(slug, id);
@@ -5504,7 +5979,7 @@ var OfflineManager = class {
5504
5979
  listenById: inner.listenById
5505
5980
  };
5506
5981
  if (!isUnsupported(inner.listen)) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
5507
- this.ingest(slug, response.data ?? []).then(() => this.notifyCollection(slug, false));
5982
+ this.ingest(slug, response.data ?? [], isProjection(params)).then(() => this.notifyCollection(slug, false));
5508
5983
  onUpdate(response);
5509
5984
  }, onError);
5510
5985
  if (!isUnsupported(inner.listenById)) wrapped.listenById = (id, onUpdate, onError) => inner.listenById(id, (row) => {
@@ -5553,7 +6028,7 @@ var OfflineManager = class {
5553
6028
  if (!closed) observer.emit();
5554
6029
  })();
5555
6030
  if (options?.realtime !== false && !isUnsupported(inner.listen)) unlisten = inner.listen(params, (response) => {
5556
- this.ingest(slug, response.data ?? []).then(() => {
6031
+ this.ingest(slug, response.data ?? [], isProjection(params)).then(() => {
5557
6032
  this.recordSnapshot(slug, params, response);
5558
6033
  this.notifyCollection(slug, false);
5559
6034
  });
@@ -5631,7 +6106,8 @@ var OfflineManager = class {
5631
6106
  const state = this.collections.get(slug);
5632
6107
  return `${total}|${rows.map((row) => {
5633
6108
  const key = String(row.id);
5634
- 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)}`;
5635
6111
  }).join(",")}`;
5636
6112
  }
5637
6113
  notifyCollection(slug, broadcast = true) {
@@ -5687,7 +6163,7 @@ var OfflineManager = class {
5687
6163
  }
5688
6164
  for (const entry of snapshots) {
5689
6165
  const key = entry.key.slice(`${scope}|q|${slug}|`.length);
5690
- if (entry.value) state.snapshots.set(key, entry.value);
6166
+ if (entry.value) state.snapshots.set(key, readSnapshot(entry.value));
5691
6167
  }
5692
6168
  for (const entry of absent) state.absent.add(entry.key.slice(`${scope}|abs|${slug}|`.length));
5693
6169
  })().catch(() => void 0).finally(() => {
@@ -5737,6 +6213,7 @@ var OfflineManager = class {
5737
6213
  partial: true
5738
6214
  };
5739
6215
  }
6216
+ if (snapshot.rows) return this.projectedAnswer(slug, params, snapshot, snapshot.rows, fromCache);
5740
6217
  const rows = [];
5741
6218
  const seen = /* @__PURE__ */ new Set();
5742
6219
  /** Rows the server counted that we know are no longer in the result. */
@@ -5779,6 +6256,71 @@ var OfflineManager = class {
5779
6256
  partial: !exact || !orderIsLocal
5780
6257
  };
5781
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
+ }
5782
6324
  localFind(slug, params) {
5783
6325
  const state = this.collections.get(slug);
5784
6326
  const snapshot = this.snapshotFor(slug, params);
@@ -5862,8 +6404,14 @@ var OfflineManager = class {
5862
6404
  * Rows that came back unchanged keep their identity and revision, so a
5863
6405
  * refetch that changed nothing does not re-render every live query that
5864
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.
5865
6413
  */
5866
- async ingest(slug, rows) {
6414
+ async ingest(slug, rows, projection = false) {
5867
6415
  if (rows.length === 0) return;
5868
6416
  const state = await this.ensureCollection(slug);
5869
6417
  const cachedAt = Date.now();
@@ -5872,15 +6420,20 @@ var OfflineManager = class {
5872
6420
  for (const raw of rows) {
5873
6421
  if (!raw || raw.id === void 0 || raw.id === null) continue;
5874
6422
  const key = String(raw.id);
5875
- 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;
5876
6430
  if (merged === void 0) {
5877
6431
  state.rows.delete(key);
5878
6432
  deletes.push(this.rowKey(slug, key));
5879
6433
  continue;
5880
6434
  }
5881
6435
  this.forgetTombstone(slug, key);
5882
- state.freshRows.add(key);
5883
- const existing = state.rows.get(key);
6436
+ if (!projection) state.freshRows.add(key);
5884
6437
  if (existing && JSON.stringify(existing.row) === JSON.stringify(merged)) {
5885
6438
  existing.cachedAt = cachedAt;
5886
6439
  continue;
@@ -5945,13 +6498,17 @@ var OfflineManager = class {
5945
6498
  total: meta.total ?? result.data?.length ?? 0,
5946
6499
  limit: meta.limit ?? window.limit,
5947
6500
  offset: meta.offset ?? window.offset,
5948
- hasMore: meta.hasMore ?? false
6501
+ hasMore: meta.hasMore ?? false,
6502
+ ...isProjection(params) ? { rows: (result.data ?? []).map((row) => ({ ...row })) } : {}
5949
6503
  };
5950
6504
  const state = this.collectionState(slug);
5951
6505
  const key = buildQueryString(params);
5952
6506
  state.snapshots.set(key, snapshot);
5953
6507
  state.fresh.add(key);
5954
- 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);
5955
6512
  this.evictSnapshots(slug);
5956
6513
  return snapshot;
5957
6514
  }
@@ -6015,7 +6572,7 @@ var OfflineManager = class {
6015
6572
  await this.ensureQueueLoaded();
6016
6573
  if (mutation.type === "update") {
6017
6574
  const tail = this.queue[this.queue.length - 1];
6018
- 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) {
6019
6576
  tail.data = {
6020
6577
  ...tail.data,
6021
6578
  ...mutation.data,
@@ -6026,7 +6583,7 @@ var OfflineManager = class {
6026
6583
  }
6027
6584
  }
6028
6585
  if (mutation.type === "delete") {
6029
- 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)) {
6030
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);
6031
6588
  for (const op of doomed) await this.store.dequeue(this.queueKey(op));
6032
6589
  this.queue = this.queue.filter((m) => !doomed.includes(m));
@@ -6151,7 +6708,7 @@ var OfflineManager = class {
6151
6708
  if (op.type === "create") {
6152
6709
  let row;
6153
6710
  try {
6154
- 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 }));
6155
6712
  } catch (error) {
6156
6713
  if (!(op.generatedId === true && isDuplicateKeyError(error))) throw error;
6157
6714
  row = await inner.findById(op.id).catch(() => void 0);
@@ -6160,10 +6717,15 @@ var OfflineManager = class {
6160
6717
  await this.adoptServerRow(op, op.id, row);
6161
6718
  } else if (op.type === "createMany") {
6162
6719
  const queued = op.data ?? [];
6163
- const rows = await inner.createMany(queued, {
6720
+ const asSent = (sent) => inner.createMany(sent.data, {
6164
6721
  ...op.upsert ? { upsert: true } : {},
6165
- idempotencyKey: op.mutationId
6722
+ ...sent.onConflict?.length ? { onConflict: sent.onConflict } : {},
6723
+ idempotencyKey: sent.idempotencyKey
6166
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);
6167
6729
  for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
6168
6730
  } else if (op.type === "updateMany") {
6169
6731
  const queued = op.updates ?? [];
@@ -6173,18 +6735,36 @@ var OfflineManager = class {
6173
6735
  })), { idempotencyKey: op.mutationId });
6174
6736
  for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
6175
6737
  } else if (op.type === "update") {
6176
- 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);
6177
6739
  await this.ingestReplaced(op, op.id, row);
6178
6740
  } else if (op.type === "deleteMany") {
6179
6741
  const ids = op.ids ?? [];
6180
6742
  await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
6181
6743
  for (const id of ids) this.removeLocalRow(op.collection, id, true);
6182
6744
  } else if (op.type === "delete") {
6183
- await inner.delete(op.id);
6745
+ await inner.delete(op.id, { idempotencyKey: op.sent?.idempotencyKey ?? op.mutationId });
6184
6746
  this.removeLocalRow(op.collection, op.id, true);
6185
6747
  }
6186
6748
  }
6187
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
+ /**
6188
6768
  * Take the server's version of a row the client created offline.
6189
6769
  *
6190
6770
  * The server may have assigned a different id — a serial column ignores
@@ -6355,7 +6935,7 @@ var OfflineManager = class {
6355
6935
  state.snapshots = /* @__PURE__ */ new Map();
6356
6936
  for (const entry of snapshots) {
6357
6937
  const key = entry.key.slice(`${scope}|q|${slug}|`.length);
6358
- if (entry.value) state.snapshots.set(key, entry.value);
6938
+ if (entry.value) state.snapshots.set(key, readSnapshot(entry.value));
6359
6939
  }
6360
6940
  state.absent = new Set(absent.map((entry) => entry.key.slice(`${scope}|abs|${slug}|`.length)));
6361
6941
  this.notifyCollection(slug, false);
@@ -6496,15 +7076,28 @@ function createRebaseClient(options) {
6496
7076
  let session = auth.getSession();
6497
7077
  if (session && session.expiresAt <= Date.now() + 1e4) try {
6498
7078
  session = await auth.refreshSession();
6499
- } catch (e) {}
7079
+ } catch (e) {
7080
+ session = auth.getSession();
7081
+ }
6500
7082
  return session?.accessToken || options.token || "";
6501
7083
  },
6502
7084
  onUnauthorized: options.onUnauthorized || (() => auth.handleUnauthorized())
6503
7085
  });
7086
+ let realtimeUid = auth.getSession()?.user?.uid ?? null;
6504
7087
  auth.onAuthStateChange((event, session) => {
6505
7088
  if (!ws) return;
6506
- if (event === "SIGNED_OUT") ws.disconnect();
6507
- 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
+ }
6508
7101
  if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
6509
7102
  }
6510
7103
  });
@@ -6644,7 +7237,7 @@ channel: (name, options) => {
6644
7237
  const prefix = endpoint.startsWith("/") ? "" : "/";
6645
7238
  return transport.request(`${prefix}${endpoint}`, {
6646
7239
  method: "POST",
6647
- body: payload ? JSON.stringify(payload) : void 0
7240
+ body: payload !== void 0 ? JSON.stringify(payload) : void 0
6648
7241
  });
6649
7242
  },
6650
7243
  batch: async (operations, options) => {