@rebasepro/client 0.17.3 → 0.18.1

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.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/dist/auth.d.ts +80 -0
  3. package/dist/functions.d.ts +6 -1
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.es.js +471 -94
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/offline-connectivity.d.ts +12 -1
  8. package/dist/offline.d.ts +23 -1
  9. package/dist/query-contract.types.d.ts +30 -0
  10. package/dist/realtime-channel.d.ts +29 -1
  11. package/dist/sdk_query_builder.d.ts +21 -2
  12. package/dist/transport.d.ts +24 -0
  13. package/package.json +28 -15
  14. package/src/admin.ts +0 -90
  15. package/src/anonymous-client-guard.test.ts +0 -190
  16. package/src/api-keys.ts +0 -87
  17. package/src/auth-listener-errors.test.ts +0 -57
  18. package/src/auth-refresh-overflow.test.ts +0 -89
  19. package/src/auth.ts +0 -982
  20. package/src/backups.ts +0 -40
  21. package/src/client-close.test.ts +0 -80
  22. package/src/collection-listen-meta.test.ts +0 -105
  23. package/src/collection-observe.test.ts +0 -138
  24. package/src/collection.test.ts +0 -293
  25. package/src/collection.ts +0 -525
  26. package/src/cron.test.ts +0 -164
  27. package/src/cron.ts +0 -62
  28. package/src/data-proxy.test.ts +0 -183
  29. package/src/errors.ts +0 -9
  30. package/src/functions.ts +0 -82
  31. package/src/index.ts +0 -639
  32. package/src/like-pattern-redos.test.ts +0 -61
  33. package/src/offline-codec.ts +0 -79
  34. package/src/offline-connectivity.test.ts +0 -191
  35. package/src/offline-connectivity.ts +0 -255
  36. package/src/offline-idb-store.test.ts +0 -340
  37. package/src/offline-integration.test.ts +0 -180
  38. package/src/offline-query.test.ts +0 -431
  39. package/src/offline-query.ts +0 -529
  40. package/src/offline-store.ts +0 -357
  41. package/src/offline-sync-engine.test.ts +0 -857
  42. package/src/offline.test.ts +0 -897
  43. package/src/offline.ts +0 -1928
  44. package/src/query-contract.types.ts +0 -206
  45. package/src/query_builder.ts +0 -1
  46. package/src/realtime-channel.test.ts +0 -542
  47. package/src/realtime-channel.ts +0 -539
  48. package/src/realtime-concurrent-subscribe.test.ts +0 -102
  49. package/src/realtime-error-surfacing.test.ts +0 -105
  50. package/src/realtime-optout.test.ts +0 -279
  51. package/src/realtime-row-identity.test.ts +0 -254
  52. package/src/realtime-subscription-key.test.ts +0 -92
  53. package/src/reviver.ts +0 -39
  54. package/src/sdk_query_builder.ts +0 -206
  55. package/src/storage-key-encoding.test.ts +0 -65
  56. package/src/storage-registry.ts +0 -102
  57. package/src/storage.ts +0 -253
  58. package/src/transport-baseurl.test.ts +0 -101
  59. package/src/transport.ts +0 -505
  60. package/src/vector-search-listen.test.ts +0 -42
  61. package/src/vector-search-query.test.ts +0 -55
  62. package/src/websocket-url.test.ts +0 -97
  63. package/src/websocket.ts +0 -1837
package/dist/index.es.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, RebaseClientError as RebaseClientError$1, Vector, isPublicStoragePath, parseRelationAggregateSort, toCanonicalOp } from "@rebasepro/types";
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, isPublicStoragePath, isUnsupported, parseRelationAggregateSort, sortKeyToString, toCanonicalOp, unsupportedMethod } from "@rebasepro/types";
2
2
  import { COMPOSITE_ID_SEPARATOR, QueryBuilder, RebasePaginationError, and, buildCompositeId, collectAllPages, cond, normalizeOrderBy, or, paginateFind, resolveFindWindow, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
3
3
  import { toSnakeCase } from "@rebasepro/utils";
4
4
  //#region src/reviver.ts
@@ -67,7 +67,13 @@ var ANONYMOUS_SERVER_CLIENT_WARNING = "[rebase] This client was created outside
67
67
  */
68
68
  function assertNoUndefinedFilterValues(where) {
69
69
  const reject = (field, op) => {
70
- throw new RebaseClientError$1(`Filter on "${field}" has an undefined value (["${String(op)}", undefined]). Omit "${field}" from \`where\` to skip the filter, or use ["is-null", null] to match SQL NULL.`);
70
+ throw new RebaseClientError$1(`Filter on "${field}" has an undefined value (["${String(op)}", undefined]). Omit "${field}" from \`where\` to skip the filter, or use ["is-null", null] to match SQL NULL.`, {
71
+ code: "INVALID_FILTER",
72
+ details: {
73
+ field,
74
+ operator: String(op)
75
+ }
76
+ });
71
77
  };
72
78
  for (const [field, condition] of Object.entries(where)) {
73
79
  if (condition === void 0) continue;
@@ -139,7 +145,40 @@ function resolveBaseUrl(configured) {
139
145
  return "";
140
146
  }
141
147
  function createTransport(config, environment) {
142
- const fetchFn = config.fetch || globalThis.fetch;
148
+ const rawFetch = config.fetch || globalThis.fetch;
149
+ /**
150
+ * `fetch`, but its rejection is a `RebaseApiError` like everything else
151
+ * this client throws.
152
+ *
153
+ * A transport failure — DNS, a refused connection, CORS, an aborted
154
+ * request — rejects with whatever the runtime's `fetch` felt like: a
155
+ * `TypeError` reading "Failed to fetch" in a browser, a `TypeError` with a
156
+ * `cause` in undici, a `DOMException` on abort. So the one class the SDK
157
+ * documents as "a `catch` block only ever needs to check for this" did not
158
+ * cover the most common failure of all, and `e.status` / `e.code` were
159
+ * undefined on the error every app hits first: the server being down.
160
+ *
161
+ * `status: 0` rather than a made-up 5xx. There was no response, and 0 is
162
+ * how `XMLHttpRequest` has always spelled that; a fabricated 503 would be
163
+ * indistinguishable from one the server actually sent. The original error
164
+ * is on `cause`, so nothing is hidden.
165
+ *
166
+ * Wrapped once, at construction, because the request path reads `fetchFn`
167
+ * twice — the first attempt and the post-refresh retry — and a wrapper
168
+ * applied at one call site is a wrapper missing from the other.
169
+ */
170
+ const fetchFn = async (input, init) => {
171
+ try {
172
+ return await rawFetch(input, init);
173
+ } catch (e) {
174
+ if (e instanceof RebaseApiError$1) throw e;
175
+ throw new RebaseApiError$1(`Could not reach the server at ${typeof input === "string" ? input : String(input.url ?? input)}: ${e instanceof Error ? e.message : String(e)}`, {
176
+ status: 0,
177
+ code: "NETWORK_ERROR",
178
+ cause: e
179
+ });
180
+ }
181
+ };
143
182
  const apiPath = config.apiPath || "/api";
144
183
  for (const field of ["baseUrl", "storageUrlOrigin"]) {
145
184
  const value = config[field];
@@ -170,9 +209,14 @@ function createTransport(config, environment) {
170
209
  anonymousWarningIssued = true;
171
210
  console.warn(ANONYMOUS_SERVER_CLIENT_WARNING);
172
211
  }
212
+ const defaultHeaders = {
213
+ ...config.headers ?? {},
214
+ ...config.schemaVersion ? { [SCHEMA_VERSION_HEADER]: config.schemaVersion } : {}
215
+ };
173
216
  function getHeaders(activeToken, init) {
174
217
  return {
175
218
  "Content-Type": "application/json",
219
+ ...defaultHeaders,
176
220
  ...activeToken ? { Authorization: `Bearer ${activeToken}` } : {},
177
221
  ...init?.headers || {}
178
222
  };
@@ -194,6 +238,52 @@ function createTransport(config, environment) {
194
238
  code: "INVALID_JSON_RESPONSE"
195
239
  });
196
240
  }
241
+ /**
242
+ * The server always emits the canonical
243
+ * `{ error: { message, code, details?, requestId? } }` envelope (formatted
244
+ * by the central `errorHandler`), so a field is read strictly from
245
+ * `body.error.*` and never from the top level.
246
+ */
247
+ function getErrorField(obj, field) {
248
+ const err = obj?.error;
249
+ if (err && typeof err === "object" && err !== null) return err[field];
250
+ }
251
+ /**
252
+ * `Retry-After` as whole seconds, or `undefined`.
253
+ *
254
+ * The header is either a number of seconds or an HTTP date; both spellings
255
+ * are legal and servers use both, so both are read. A date in the past —
256
+ * which happens on a clock skew — is clamped to 0 rather than negative.
257
+ */
258
+ function parseRetryAfter(header) {
259
+ if (!header) return void 0;
260
+ const seconds = Number(header.trim());
261
+ if (Number.isFinite(seconds)) return Math.max(0, Math.floor(seconds));
262
+ const at = Date.parse(header);
263
+ if (Number.isNaN(at)) return void 0;
264
+ return Math.max(0, Math.round((at - Date.now()) / 1e3));
265
+ }
266
+ /**
267
+ * Everything a failed response says about itself, in one place.
268
+ *
269
+ * The request path reads a response twice — the first attempt and the
270
+ * post-refresh retry — and the two used to build their error separately.
271
+ * Which is how they came to disagree: both dropped `requestId` and
272
+ * `Retry-After`, and a fix applied to one would have missed the other.
273
+ */
274
+ function errorFrom(res, body, fallbackMessage) {
275
+ const requestId = getErrorField(body, "requestId") ?? res.headers?.get("X-Request-ID") ?? void 0;
276
+ return new RebaseApiError$1(String(getErrorField(body, "message") || fallbackMessage || `Request failed with status ${res.status}`), {
277
+ status: res.status,
278
+ code: getErrorField(body, "code"),
279
+ details: getErrorField(body, "details"),
280
+ ...typeof requestId === "string" && requestId ? { requestId } : {},
281
+ ...(() => {
282
+ const retryAfter = parseRetryAfter(res.headers?.get("Retry-After") ?? null);
283
+ return retryAfter === void 0 ? {} : { retryAfterSeconds: retryAfter };
284
+ })()
285
+ });
286
+ }
197
287
  async function request(path, init) {
198
288
  const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
199
289
  let activeToken = token;
@@ -232,10 +322,6 @@ function createTransport(config, environment) {
232
322
  } catch (e) {
233
323
  unreadableBody = true;
234
324
  }
235
- const getErrorField = (obj, field) => {
236
- const err = obj?.error;
237
- if (err && typeof err === "object" && err !== null) return err[field];
238
- };
239
325
  if (res.status === 401 && onUnauthorizedHandler) {
240
326
  if (await onUnauthorizedHandler()) {
241
327
  let retryToken = token;
@@ -260,11 +346,7 @@ function createTransport(config, environment) {
260
346
  if (!retryRes.ok) {
261
347
  let fallbackMessage = retryRes.statusText;
262
348
  if (retryRes.status === 404 && !fallbackMessage) fallbackMessage = `Endpoint not found (${init?.method || "GET"} ${path}). This usually means the collection is not registered on the backend, or the frontend API URL configuration (e.g. VITE_API_URL) is missing or pointing to the wrong host.`;
263
- throw new RebaseApiError$1(String(getErrorField(retryBody, "message") || fallbackMessage || `Request failed with status ${retryRes.status}`), {
264
- status: retryRes.status,
265
- code: getErrorField(retryBody, "code"),
266
- details: getErrorField(retryBody, "details")
267
- });
349
+ throw errorFrom(retryRes, retryBody, fallbackMessage);
268
350
  }
269
351
  if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
270
352
  return retryBody;
@@ -273,11 +355,7 @@ function createTransport(config, environment) {
273
355
  if (!res.ok) {
274
356
  let fallbackMessage = res.statusText;
275
357
  if (res.status === 404 && !fallbackMessage) fallbackMessage = `Endpoint not found (${init?.method || "GET"} ${path}). This usually means the collection is not registered on the backend, or the frontend API URL configuration (e.g. VITE_API_URL) is missing or pointing to the wrong host.`;
276
- throw new RebaseApiError$1(String(getErrorField(body, "message") || fallbackMessage || `Request failed with status ${res.status}`), {
277
- status: res.status,
278
- code: getErrorField(body, "code"),
279
- details: getErrorField(body, "details")
280
- });
358
+ throw errorFrom(res, body, fallbackMessage);
281
359
  }
282
360
  if (unreadableBody) throw unreadableResponse(res.status, text);
283
361
  return body;
@@ -783,7 +861,7 @@ function createAuth(transport, options) {
783
861
  return inFlightRefresh;
784
862
  }
785
863
  async function doRefreshSession() {
786
- if (authFlowMode !== "cookie" && !currentSession?.refreshToken) throw new Error("No active session to refresh");
864
+ 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" });
787
865
  const res = await getFetch()(authUrl("/refresh"), {
788
866
  method: "POST",
789
867
  headers: { "Content-Type": "application/json" },
@@ -979,6 +1057,136 @@ function createAuth(transport, options) {
979
1057
  refreshToken: session.refreshToken
980
1058
  };
981
1059
  }
1060
+ /**
1061
+ * Sign in without credentials, as a throwaway account.
1062
+ *
1063
+ * The account is real — it has an id, roles and a session, so row-level
1064
+ * security scopes its data exactly as it would a signed-up user's. What it
1065
+ * does not have is a way back: nobody can sign in *as* it a second time, so
1066
+ * everything it owns is lost when the session is. `linkAnonymous` is how it
1067
+ * stops being throwaway.
1068
+ *
1069
+ * Refused with `ANONYMOUS_AUTH_DISABLED` unless the backend has it on.
1070
+ */
1071
+ async function signInAnonymously() {
1072
+ const res = await getFetch()(authUrl("/anonymous"), {
1073
+ method: "POST",
1074
+ headers: { "Content-Type": "application/json" },
1075
+ credentials: authFlowMode === "cookie" ? "include" : void 0
1076
+ });
1077
+ const body = await res.json().catch(() => ({}));
1078
+ if (!res.ok) throwApiError(res.status, body, res.statusText);
1079
+ const session = handleAuthResponse(body, "SIGNED_IN");
1080
+ return {
1081
+ user: session.user,
1082
+ accessToken: session.accessToken,
1083
+ refreshToken: session.refreshToken
1084
+ };
1085
+ }
1086
+ /**
1087
+ * Put an email and password on the anonymous account this client holds.
1088
+ *
1089
+ * The user keeps their id, so everything they created while anonymous stays
1090
+ * theirs — which is the point: the alternative is asking a visitor to sign
1091
+ * up before they have any reason to.
1092
+ *
1093
+ * Requires a live anonymous session. `NOT_ANONYMOUS` (400) if the session
1094
+ * belongs to an ordinary account, `EMAIL_EXISTS` (409) if the address is
1095
+ * taken.
1096
+ */
1097
+ async function linkAnonymous(email, password) {
1098
+ const session = handleAuthResponse(await transport.request(authPath + "/anonymous/link", {
1099
+ method: "POST",
1100
+ body: JSON.stringify({
1101
+ email,
1102
+ password
1103
+ })
1104
+ }), "USER_UPDATED");
1105
+ return {
1106
+ user: session.user,
1107
+ accessToken: session.accessToken,
1108
+ refreshToken: session.refreshToken
1109
+ };
1110
+ }
1111
+ /**
1112
+ * Multi-factor authentication: TOTP factors, and the challenge that raises
1113
+ * a session to `aal2`.
1114
+ *
1115
+ * The backend has served all six of these since MFA landed; the SDK exposed
1116
+ * none of them, so every app that wanted MFA hand-wrote `fetch` calls
1117
+ * against `/auth/mfa/*` — including the auth header and the cookie flag,
1118
+ * which is exactly what a client is for.
1119
+ */
1120
+ const mfa = {
1121
+ /**
1122
+ * Start enrolling a TOTP factor.
1123
+ *
1124
+ * Returns the secret and an `otpauth://` URI to render as a QR code,
1125
+ * plus ten single-use recovery codes. **Show the recovery codes once
1126
+ * and never again** — only their hashes are stored.
1127
+ *
1128
+ * The factor is not usable until `verify` confirms the user's
1129
+ * authenticator produced a correct code from it.
1130
+ */
1131
+ async enroll(options) {
1132
+ return transport.request(authPath + "/mfa/enroll", {
1133
+ method: "POST",
1134
+ body: JSON.stringify(options ?? {})
1135
+ });
1136
+ },
1137
+ /** Confirm an enrolment with a code from the authenticator app. */
1138
+ async verify(factorId, code) {
1139
+ return transport.request(authPath + "/mfa/verify", {
1140
+ method: "POST",
1141
+ body: JSON.stringify({
1142
+ factorId,
1143
+ code
1144
+ })
1145
+ });
1146
+ },
1147
+ /** The factors on this account, verified or not. */
1148
+ async listFactors() {
1149
+ return (await transport.request(authPath + "/mfa/factors", { method: "GET" })).factors;
1150
+ },
1151
+ /**
1152
+ * Remove a factor. Requires an `aal2` session — one that has already
1153
+ * answered a challenge — so a stolen `aal1` token cannot turn MFA off.
1154
+ */
1155
+ async unenroll(factorId) {
1156
+ return transport.request(authPath + "/mfa/unenroll", {
1157
+ method: "DELETE",
1158
+ body: JSON.stringify({ factorId })
1159
+ });
1160
+ },
1161
+ /** Open a challenge against a verified factor. Expires in five minutes. */
1162
+ async challenge(factorId) {
1163
+ return transport.request(authPath + "/mfa/challenge", {
1164
+ method: "POST",
1165
+ body: JSON.stringify({ factorId })
1166
+ });
1167
+ },
1168
+ /**
1169
+ * Answer a challenge with a TOTP code or a recovery code.
1170
+ *
1171
+ * This is where an MFA-enrolled account's real session is minted, at
1172
+ * `aal2` — so the returned tokens replace the ones the sign-in handed
1173
+ * back, and this client adopts them.
1174
+ */
1175
+ async verifyChallenge(challengeId, code) {
1176
+ const session = handleAuthResponse(await transport.request(authPath + "/mfa/challenge/verify", {
1177
+ method: "POST",
1178
+ body: JSON.stringify({
1179
+ challengeId,
1180
+ code
1181
+ })
1182
+ }), "SIGNED_IN");
1183
+ return {
1184
+ user: session.user,
1185
+ accessToken: session.accessToken,
1186
+ refreshToken: session.refreshToken
1187
+ };
1188
+ }
1189
+ };
982
1190
  async function getSessions() {
983
1191
  return (await transport.request(authPath + "/sessions", { method: "GET" })).sessions;
984
1192
  }
@@ -1054,6 +1262,9 @@ function createAuth(transport, options) {
1054
1262
  signInWithBitbucket,
1055
1263
  signInWithSlack,
1056
1264
  signInWithSpotify,
1265
+ signInAnonymously,
1266
+ linkAnonymous,
1267
+ mfa,
1057
1268
  signOut,
1058
1269
  stopAutoRefresh,
1059
1270
  refreshSession,
@@ -1303,7 +1514,11 @@ var SDKQueryBuilder = class {
1303
1514
  }
1304
1515
  where(columnOrCondition, operator, value) {
1305
1516
  if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
1306
- this.params.logical = columnOrCondition;
1517
+ const next = columnOrCondition;
1518
+ this.params.logical = this.params.logical ? {
1519
+ type: "and",
1520
+ conditions: [this.params.logical, next]
1521
+ } : next;
1307
1522
  return this;
1308
1523
  }
1309
1524
  if (!this.params.where) this.params.where = {};
@@ -1330,7 +1545,7 @@ var SDKQueryBuilder = class {
1330
1545
  */
1331
1546
  orderBy(column, direction = "asc") {
1332
1547
  const existing = normalizeOrderBy(this.params.orderBy) ?? [];
1333
- this.params.orderBy = [...existing, [column, direction]];
1548
+ this.params.orderBy = [...existing, [sortKeyToString(column), direction]];
1334
1549
  return this;
1335
1550
  }
1336
1551
  /**
@@ -1419,27 +1634,81 @@ var SDKQueryBuilder = class {
1419
1634
  return this.collection.find(this.params);
1420
1635
  }
1421
1636
  /**
1637
+ * Page through everything this query matches, one row at a time.
1638
+ *
1639
+ * `.limit()` on the builder becomes the page size, so the ceiling on a
1640
+ * single `find()` is not a ceiling on what the query can read.
1641
+ */
1642
+ iterate(options) {
1643
+ return this.collection.iterate({
1644
+ ...this.params,
1645
+ ...this.params.limit !== void 0 && { pageSize: this.params.limit },
1646
+ ...options
1647
+ });
1648
+ }
1649
+ /** Collect everything this query matches into one array. */
1650
+ findAll(options) {
1651
+ return this.collection.findAll({
1652
+ ...this.params,
1653
+ ...this.params.limit !== void 0 && { pageSize: this.params.limit },
1654
+ ...options
1655
+ });
1656
+ }
1657
+ /**
1422
1658
  * Count the records matching this query.
1423
1659
  */
1424
1660
  async count() {
1425
- if (!this.collection.count) throw new Error("count() is not supported by this collection client.");
1426
1661
  return this.collection.count(this.params);
1427
1662
  }
1428
1663
  /**
1429
1664
  * Listen to realtime updates matching this query.
1665
+ *
1666
+ * The guard that used to stand here — `if (!this.collection.listen) throw`
1667
+ * — has moved onto the client itself, which is where the same failure had
1668
+ * to be diagnosed anyway: `client.data.posts.listen(…)`, one call short of
1669
+ * this builder, answered `undefined is not a function`. The message is now
1670
+ * the same either way.
1430
1671
  */
1431
1672
  listen(onUpdate, onError) {
1432
- if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl, and not when it was created with realtime: false.");
1433
1673
  return this.collection.listen(this.params, onUpdate, onError);
1434
1674
  }
1435
1675
  };
1436
1676
  //#endregion
1437
1677
  //#region src/collection.ts
1438
1678
  /**
1439
- * Counts currently in flight, keyed by the exact request they issue. Entries
1440
- * live only for the duration of the request — see `count()` for why.
1679
+ * What `listen`/`listenById` say on a client that has no socket.
1680
+ *
1681
+ * One sentence, in one place: it used to live in `SDKQueryBuilder.listen` only,
1682
+ * so the same failure reached callers of `client.data.posts.listen` as
1683
+ * `undefined is not a function`.
1441
1684
  */
1442
- var inflightCounts = /* @__PURE__ */ new Map();
1685
+ var NO_SOCKET = "Listen is only available when RebaseClient is configured with a websocketUrl, and not when it was created with realtime: false.";
1686
+ /**
1687
+ * Counts currently in flight, **per client**, keyed by the exact request they
1688
+ * issue. Entries live only for the duration of the request — see `count()` for
1689
+ * why they exist at all.
1690
+ *
1691
+ * The map used to be module-level, shared by every client in the process. A
1692
+ * request key is a path and a query string: it says nothing about *who* is
1693
+ * asking. So two clients holding different credentials — an admin panel beside
1694
+ * a signed-in user's client, a per-request server client in a Node process, a
1695
+ * test asserting one client's calls — counting the same collection would share
1696
+ * one request, and whichever arrived second was answered with the first one's
1697
+ * total. Row-level security makes those genuinely different numbers.
1698
+ *
1699
+ * Keyed on the transport instead, which is the object a credential lives on.
1700
+ * A `WeakMap`, so a closed client's entry goes with it.
1701
+ */
1702
+ var inflightCounts = /* @__PURE__ */ new WeakMap();
1703
+ /** The in-flight map for one transport, created on first use. */
1704
+ function inflightCountsFor(transport) {
1705
+ let map = inflightCounts.get(transport);
1706
+ if (!map) {
1707
+ map = /* @__PURE__ */ new Map();
1708
+ inflightCounts.set(transport, map);
1709
+ }
1710
+ return map;
1711
+ }
1443
1712
  function createCollectionClient(transport, slug, ws) {
1444
1713
  const basePath = `/data/${slug}`;
1445
1714
  const client = {
@@ -1467,6 +1736,14 @@ function createCollectionClient(transport, slug, ws) {
1467
1736
  throw err;
1468
1737
  }
1469
1738
  },
1739
+ async get(id) {
1740
+ const row = await client.findById(id);
1741
+ if (row === void 0) throw new RebaseApiError(`No record with id ${JSON.stringify(String(id))} in "${slug}".`, {
1742
+ status: 404,
1743
+ code: "NOT_FOUND"
1744
+ });
1745
+ return row;
1746
+ },
1470
1747
  async create(data, id, options) {
1471
1748
  const body = { ...data };
1472
1749
  if (id !== void 0) body.id = id;
@@ -1497,10 +1774,11 @@ function createCollectionClient(transport, slug, ws) {
1497
1774
  * and the route now name one verb, and a spec-validating gateway in
1498
1775
  * front of the API sees the operation the server actually implements.
1499
1776
  */
1500
- async update(id, data) {
1777
+ async update(id, data, options) {
1501
1778
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
1502
1779
  method: "PATCH",
1503
- body: JSON.stringify(data)
1780
+ body: JSON.stringify(data),
1781
+ ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
1504
1782
  });
1505
1783
  },
1506
1784
  async updateMany(updates, options) {
@@ -1543,14 +1821,15 @@ function createCollectionClient(transport, slug, ws) {
1543
1821
  include: void 0
1544
1822
  });
1545
1823
  const key = basePath + "/count" + qs;
1546
- const inflight = inflightCounts.get(key);
1547
- if (inflight) return inflight;
1824
+ const inflight = inflightCountsFor(transport);
1825
+ const existing = inflight.get(key);
1826
+ if (existing) return existing;
1548
1827
  const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
1549
- inflightCounts.set(key, request);
1828
+ inflight.set(key, request);
1550
1829
  try {
1551
1830
  return await request;
1552
1831
  } finally {
1553
- inflightCounts.delete(key);
1832
+ inflight.delete(key);
1554
1833
  }
1555
1834
  },
1556
1835
  observe(params, onResult, onError, options) {
@@ -1574,7 +1853,7 @@ function createCollectionClient(transport, slug, ws) {
1574
1853
  client.find(params).then((result) => deliver(result, false)).catch((error) => {
1575
1854
  if (!closed) onError?.(error);
1576
1855
  });
1577
- const live = options?.realtime !== false && client.listen ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
1856
+ const live = options?.realtime !== false && !isUnsupported(client.listen) ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
1578
1857
  return () => {
1579
1858
  closed = true;
1580
1859
  live?.();
@@ -1599,7 +1878,7 @@ function createCollectionClient(transport, slug, ws) {
1599
1878
  client.findById(id).then((row) => deliver(row, false)).catch((error) => {
1600
1879
  if (!closed) onError?.(error);
1601
1880
  });
1602
- const live = options?.realtime !== false && client.listenById ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
1881
+ const live = options?.realtime !== false && !isUnsupported(client.listenById) ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
1603
1882
  return () => {
1604
1883
  closed = true;
1605
1884
  live?.();
@@ -1627,7 +1906,9 @@ function createCollectionClient(transport, slug, ws) {
1627
1906
  },
1628
1907
  include(...relations) {
1629
1908
  return new SDKQueryBuilder(client).include(...relations);
1630
- }
1909
+ },
1910
+ listen: unsupportedMethod(NO_SOCKET),
1911
+ listenById: unsupportedMethod(NO_SOCKET)
1631
1912
  };
1632
1913
  if (ws) {
1633
1914
  client.listen = (params, onUpdate, onError) => {
@@ -1662,15 +1943,16 @@ function createCollectionClient(transport, slug, ws) {
1662
1943
  }
1663
1944
  });
1664
1945
  };
1665
- const emitWithoutCount = () => emit(offset + rows.length, rows.length >= requestedLimit);
1666
- if (client.count) client.count(params).then((total) => {
1946
+ client.count(params).then((total) => {
1667
1947
  lastKnownTotal = total;
1668
1948
  emit(total, offset + rows.length < total);
1669
1949
  }).catch(() => {
1670
- if (lastKnownTotal !== void 0) emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
1671
- else emitWithoutCount();
1950
+ if (lastKnownTotal !== void 0) {
1951
+ emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
1952
+ return;
1953
+ }
1954
+ emit(offset + rows.length, rows.length >= requestedLimit);
1672
1955
  });
1673
- else emitWithoutCount();
1674
1956
  }, onError);
1675
1957
  return () => {
1676
1958
  active = false;
@@ -1704,6 +1986,10 @@ function createCollectionClient(transport, slug, ws) {
1704
1986
  */
1705
1987
  function createFunctionsClient(transport) {
1706
1988
  return { async invoke(name, payload, options) {
1989
+ if (name.includes("/")) throw new RebaseClientError$1(`Invalid function name "${name}": a function name is a single path segment. Pass anything after it as \`options.path\` — invoke("${name.split("/")[0]}", payload, { path: "${name.split("/").slice(1).join("/")}" }).`, {
1990
+ code: "INVALID_FUNCTION_NAME",
1991
+ details: { name }
1992
+ });
1707
1993
  const method = options?.method ?? "POST";
1708
1994
  const rawPath = options?.path;
1709
1995
  const subPath = rawPath ? /^[?#]/.test(rawPath) ? rawPath : `/${rawPath.replace(/^\//, "")}` : "";
@@ -2325,7 +2611,7 @@ var RebaseWebSocketClient = class {
2325
2611
  }
2326
2612
  return;
2327
2613
  }
2328
- if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
2614
+ if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history" || type === "ERROR" || type === "error")) {
2329
2615
  const handlers = this.channelHandlers.get(message.channel);
2330
2616
  if (handlers) for (const handler of [...handlers]) try {
2331
2617
  handler(message);
@@ -2361,39 +2647,6 @@ var RebaseWebSocketClient = class {
2361
2647
  }
2362
2648
  }
2363
2649
  }
2364
- if (subscriptionId && type === "collection_patch") {
2365
- const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
2366
- if (subscriptionKey) {
2367
- const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
2368
- if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
2369
- const patchWireEntity = message.row ?? null;
2370
- const patchMessage = message;
2371
- const patchEntityId = patchMessage.id;
2372
- if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
2373
- const patchRow = patchWireEntity ? patchWireEntity : null;
2374
- let updated;
2375
- if (patchRow === null) updated = collectionSub.latestData.filter((e) => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId));
2376
- else {
2377
- const idx = collectionSub.latestData.findIndex((e) => this.rowAddress(e, collectionSub.pks) === String(patchEntityId));
2378
- if (idx >= 0) {
2379
- updated = [...collectionSub.latestData];
2380
- updated[idx] = patchRow;
2381
- } else updated = [patchRow, ...collectionSub.latestData];
2382
- }
2383
- collectionSub.latestData = updated;
2384
- collectionSub.lastUpdated = Date.now();
2385
- collectionSub.callbacks.forEach((callback) => {
2386
- try {
2387
- callback.onUpdate(updated);
2388
- } catch (error) {
2389
- console.error("Error in collection patch callback:", error);
2390
- if (callback.onError) callback.onError(error instanceof Error ? error : new Error(String(error)));
2391
- }
2392
- });
2393
- return;
2394
- }
2395
- }
2396
- }
2397
2650
  if (subscriptionId && type === "single_update") {
2398
2651
  const subscriptionKey = this.backendToEntityKey.get(subscriptionId);
2399
2652
  if (subscriptionKey) {
@@ -3116,6 +3369,7 @@ var RebaseRealtimeChannel = class {
3116
3369
  transport;
3117
3370
  presenceHandlers = /* @__PURE__ */ new Set();
3118
3371
  broadcastHandlers = /* @__PURE__ */ new Set();
3372
+ errorHandlers = /* @__PURE__ */ new Set();
3119
3373
  unsubscribers = [];
3120
3374
  /** Last known roster, kept so handlers always get a full picture. */
3121
3375
  presences = {};
@@ -3303,7 +3557,16 @@ var RebaseRealtimeChannel = class {
3303
3557
  if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
3304
3558
  return () => this.presenceHandlers.delete(handler);
3305
3559
  }
3306
- /** Send a broadcast. The sender does not receive its own message. */
3560
+ /**
3561
+ * Send a broadcast. The sender does not receive its own message.
3562
+ *
3563
+ * Resolves when the frame is written to the socket, **not** when the server
3564
+ * has accepted it. Channel frames are fire-and-forget by design — a
3565
+ * collaborative app broadcasts a cursor position sixty times a second, and
3566
+ * waiting for an acknowledgement on each would make that a round trip.
3567
+ *
3568
+ * A refusal therefore arrives on {@link onError}, not as a rejection here.
3569
+ */
3307
3570
  async broadcast(event, payload) {
3308
3571
  await this.join();
3309
3572
  await this.send("broadcast", {
@@ -3311,6 +3574,27 @@ var RebaseRealtimeChannel = class {
3311
3574
  payload
3312
3575
  });
3313
3576
  }
3577
+ /**
3578
+ * Observe refusals about this channel.
3579
+ *
3580
+ * `CHANNEL_FORBIDDEN` when an authorizer refuses a join, a broadcast or a
3581
+ * history read; `RATE_LIMITED` past the channel frame budget;
3582
+ * `CHANNEL_HISTORY_WRITE_FAILED` / `CHANNEL_HISTORY_READ_FAILED` when
3583
+ * retention cannot be served; `CHANNEL_BUS_PAYLOAD_TOO_LARGE` when a
3584
+ * broadcast on an ephemeral channel is too big to cross the bus.
3585
+ *
3586
+ * These used to be dropped on the floor — there is no promise to reject on
3587
+ * a fire-and-forget frame, so a forbidden broadcast looked exactly like a
3588
+ * delivered one. With no handler attached they are logged as a warning,
3589
+ * because an unobserved refusal is the failure this exists for.
3590
+ *
3591
+ * @returns An unsubscribe function.
3592
+ */
3593
+ onError(handler) {
3594
+ this.errorHandlers.add(handler);
3595
+ this.join();
3596
+ return () => this.errorHandlers.delete(handler);
3597
+ }
3314
3598
  onBroadcast(eventOrHandler, maybeHandler) {
3315
3599
  const wrapped = typeof eventOrHandler === "string" ? (e) => {
3316
3600
  if (e.event === eventOrHandler) maybeHandler(e.payload);
@@ -3443,6 +3727,23 @@ var RebaseRealtimeChannel = class {
3443
3727
  this.flushPendingLive();
3444
3728
  break;
3445
3729
  }
3730
+ case "ERROR":
3731
+ case "error": {
3732
+ const raw = message.payload?.error;
3733
+ const asObject = typeof raw === "object" && raw !== null ? raw : void 0;
3734
+ const text = asObject?.message ?? (typeof raw === "string" ? raw : void 0) ?? (typeof message.error === "string" ? message.error : void 0) ?? "The server refused a channel operation.";
3735
+ const error = new RebaseApiError$1(text, { ...asObject?.code ? { code: asObject.code } : {} });
3736
+ if (this.errorHandlers.size === 0) {
3737
+ console.warn(`[Rebase] Channel "${this.name}" error${asObject?.code ? ` (${asObject.code})` : ""}: ${text}. Attach \`channel.onError(...)\` to handle it.`);
3738
+ break;
3739
+ }
3740
+ for (const handler of [...this.errorHandlers]) try {
3741
+ handler(error);
3742
+ } catch (e) {
3743
+ console.error("Error in channel error handler:", e);
3744
+ }
3745
+ break;
3746
+ }
3446
3747
  }
3447
3748
  }
3448
3749
  /** Deliver everything held back during a catch-up, in sequence order. */
@@ -3552,12 +3853,20 @@ function hydrateRow(row) {
3552
3853
  * store immediately, which is the difference between an app that freezes when
3553
3854
  * the wifi drops and one that does not.
3554
3855
  */
3555
- /** The request never reached the server, so nothing was decided by it. */
3856
+ /**
3857
+ * The request never reached the server, so nothing was decided by it.
3858
+ *
3859
+ * One question, one answer: `status === 0` is how a transport spells "there
3860
+ * was no response at all", and the transport now wraps every `fetch` rejection
3861
+ * into a `RebaseApiError` carrying it. This used to also sniff for the shapes
3862
+ * a raw `fetch` rejects with — a `TypeError` in a browser, an `AbortError`, a
3863
+ * `TimeoutError` — which is a list that has to be kept in step with three
3864
+ * runtimes and was wrong in both directions: it missed anything they add, and
3865
+ * it claimed a `TypeError` thrown by application code inside a callback was a
3866
+ * network failure, which quietly served a cached answer in its place.
3867
+ */
3556
3868
  function isNetworkError(error) {
3557
- if (error instanceof RebaseApiError) return error.status === 0;
3558
- if (error instanceof TypeError) return true;
3559
- const name = error?.name;
3560
- return name === "AbortError" || name === "TimeoutError" || name === "NetworkError";
3869
+ return error instanceof RebaseApiError && error.status === 0;
3561
3870
  }
3562
3871
  /**
3563
3872
  * Statuses that mean "not now" rather than "not ever": a queued write that
@@ -4326,20 +4635,51 @@ function runLocalQuery(rows, params) {
4326
4635
  }
4327
4636
  //#endregion
4328
4637
  //#region src/offline.ts
4329
- /** True when a read failed because there was neither network nor local data. */
4638
+ /**
4639
+ * True when a read failed because there was neither network nor local data.
4640
+ *
4641
+ * The code is `OFFLINE`. It was `"offline"`, the one lowercase code in a
4642
+ * surface whose every other code is `SCREAMING_SNAKE_CASE` — including the
4643
+ * `NETWORK_ERROR` it sits next to — so `e.code === "OFFLINE"`, which is what
4644
+ * anyone writes, never matched.
4645
+ */
4330
4646
  function isOfflineError(error) {
4331
- return error instanceof RebaseApiError && error.code === "offline";
4647
+ return error instanceof RebaseApiError && error.code === "OFFLINE";
4332
4648
  }
4333
4649
  function offlineError(message) {
4334
4650
  return new RebaseApiError(message, {
4335
4651
  status: 0,
4336
- code: "offline"
4652
+ code: "OFFLINE"
4337
4653
  });
4338
4654
  }
4339
- function generateOfflineId() {
4655
+ function generateOfflineStringId() {
4340
4656
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
4341
4657
  return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
4342
4658
  }
4659
+ /**
4660
+ * The last temporary numeric id minted, so the next one is smaller.
4661
+ *
4662
+ * Module-scoped and strictly decreasing: two temporary ids in the same session
4663
+ * must not collide, and neither must one minted a millisecond after another.
4664
+ */
4665
+ var lastOfflineNumericId = 0;
4666
+ /**
4667
+ * A temporary id for a collection whose ids are numbers.
4668
+ *
4669
+ * **Negative**, which is the convention every local-first engine settles on for
4670
+ * the same reason: a real key is a positive integer or an identity sequence, so
4671
+ * the sign alone says "this one is not the server's yet" — and it stays a
4672
+ * number, which is what matters. A UUID string was minted for every collection,
4673
+ * so a `create` while offline handed back `{ id: "526e2faf-…" }` on a row the
4674
+ * generated types declare `id: number`. Anything that put that in a URL, a
4675
+ * foreign key or an `id.toFixed()` was wrong or threw until the replay
4676
+ * reconciled it — and the SDK's own types said it could not happen.
4677
+ */
4678
+ function generateOfflineNumericId() {
4679
+ const candidate = -Date.now();
4680
+ lastOfflineNumericId = lastOfflineNumericId === 0 ? candidate : Math.min(candidate, lastOfflineNumericId - 1);
4681
+ return lastOfflineNumericId;
4682
+ }
4343
4683
  var MISSING = "\0missing";
4344
4684
  /**
4345
4685
  * Replays to spend on a mutation whose idempotency key the server is still
@@ -4566,6 +4906,14 @@ var OfflineManager = class {
4566
4906
  if (this.collections.get(slug)?.absent.has(String(id))) return void 0;
4567
4907
  throw offlineError(`Offline: "${slug}" row ${String(id)} is not in the local database.`);
4568
4908
  },
4909
+ get: async (id) => {
4910
+ const row = await wrapped.findById(id);
4911
+ if (row === void 0) throw new RebaseApiError(`No record with id ${JSON.stringify(String(id))} in "${slug}".`, {
4912
+ status: 404,
4913
+ code: "NOT_FOUND"
4914
+ });
4915
+ return row;
4916
+ },
4569
4917
  create: async (data, id) => {
4570
4918
  await this.ensureCollection(slug);
4571
4919
  if (this.connectivity.shouldAttempt()) try {
@@ -4580,7 +4928,7 @@ var OfflineManager = class {
4580
4928
  this.connectivity.markFailure();
4581
4929
  }
4582
4930
  const providedId = id ?? data.id;
4583
- const rowId = providedId ?? generateOfflineId();
4931
+ const rowId = providedId ?? this.mintOfflineId(slug);
4584
4932
  const row = {
4585
4933
  ...data,
4586
4934
  id: rowId
@@ -4614,7 +4962,7 @@ var OfflineManager = class {
4614
4962
  }
4615
4963
  const rows = data.map((r) => ({
4616
4964
  ...r,
4617
- id: r.id ?? generateOfflineId()
4965
+ id: r.id ?? this.mintOfflineId(slug)
4618
4966
  }));
4619
4967
  const rollback = {};
4620
4968
  for (const row of rows) {
@@ -4778,13 +5126,15 @@ var OfflineManager = class {
4778
5126
  offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
4779
5127
  search: (searchString, options) => new SDKQueryBuilder(wrapped).search(searchString, options),
4780
5128
  vectorSearch: (property, vector, options) => new SDKQueryBuilder(wrapped).vectorSearch(property, vector, options),
4781
- include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
5129
+ include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations),
5130
+ listen: inner.listen,
5131
+ listenById: inner.listenById
4782
5132
  };
4783
- if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
5133
+ if (!isUnsupported(inner.listen)) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
4784
5134
  this.ingest(slug, response.data ?? []).then(() => this.notifyCollection(slug, false));
4785
5135
  onUpdate(response);
4786
5136
  }, onError);
4787
- if (inner.listenById) wrapped.listenById = (id, onUpdate, onError) => inner.listenById(id, (row) => {
5137
+ if (!isUnsupported(inner.listenById)) wrapped.listenById = (id, onUpdate, onError) => inner.listenById(id, (row) => {
4788
5138
  if (row) this.ingest(slug, [row]).then(() => this.notifyCollection(slug, false));
4789
5139
  onUpdate(row);
4790
5140
  }, onError);
@@ -4829,7 +5179,7 @@ var OfflineManager = class {
4829
5179
  }
4830
5180
  if (!closed) observer.emit();
4831
5181
  })();
4832
- if (options?.realtime !== false && inner.listen) unlisten = inner.listen(params, (response) => {
5182
+ if (options?.realtime !== false && !isUnsupported(inner.listen)) unlisten = inner.listen(params, (response) => {
4833
5183
  this.ingest(slug, response.data ?? []).then(() => {
4834
5184
  this.recordSnapshot(slug, params, response);
4835
5185
  this.notifyCollection(slug, false);
@@ -4881,7 +5231,7 @@ var OfflineManager = class {
4881
5231
  }
4882
5232
  if (!closed) observer.emit();
4883
5233
  })();
4884
- if (options?.realtime !== false && inner.listenById) unlisten = inner.listenById(id, (row) => {
5234
+ if (options?.realtime !== false && !isUnsupported(inner.listenById)) unlisten = inner.listenById(id, (row) => {
4885
5235
  if (!row) {
4886
5236
  if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, true);
4887
5237
  this.notifyCollection(slug, false);
@@ -5067,6 +5417,28 @@ var OfflineManager = class {
5067
5417
  partial: true
5068
5418
  };
5069
5419
  }
5420
+ /**
5421
+ * A temporary id of the type this collection's ids actually have.
5422
+ *
5423
+ * There is no schema here to read it off — the generated `Database` types
5424
+ * are erased at compile time, and the whole point of this path is that the
5425
+ * server is unreachable. So it is read off the rows the local database
5426
+ * already holds: a collection whose cached ids are numbers gets a negative
5427
+ * number, and everything else gets a UUID.
5428
+ *
5429
+ * A collection with nothing cached falls back to a UUID, which is the same
5430
+ * guess as before — but that is a first write on a device that has never
5431
+ * read the collection, which is also the case where nothing local can tell
5432
+ * the difference. Reading one row first is enough to fix it.
5433
+ */
5434
+ mintOfflineId(slug) {
5435
+ for (const entry of this.collections.get(slug)?.rows.values() ?? []) {
5436
+ const id = entry.row.id;
5437
+ if (typeof id === "number") return generateOfflineNumericId();
5438
+ if (typeof id === "string") break;
5439
+ }
5440
+ return generateOfflineStringId();
5441
+ }
5070
5442
  rawLocalRow(slug, id) {
5071
5443
  const entry = this.collections.get(slug)?.rows.get(String(id));
5072
5444
  return entry ? { ...entry.row } : void 0;
@@ -5829,7 +6201,13 @@ function createRebaseClient(options) {
5829
6201
  let msg = `Unknown collection accessor "${prop}". Known collections: ${knownKeys.join(", ")}.`;
5830
6202
  if (suggestion) msg += ` Did you mean "${suggestion}"?`;
5831
6203
  msg += ` Use data.collection("<slug>") for dynamic slugs.`;
5832
- throw new RebaseClientError(msg);
6204
+ throw new RebaseClientError(msg, {
6205
+ code: "UNKNOWN_COLLECTION",
6206
+ details: {
6207
+ accessor: prop,
6208
+ known: knownKeys
6209
+ }
6210
+ });
5833
6211
  }
5834
6212
  if (!untypedWarned) {
5835
6213
  untypedWarned = true;
@@ -5860,7 +6238,7 @@ function createRebaseClient(options) {
5860
6238
  * cut off the others.
5861
6239
  */
5862
6240
  channel: (name, options) => {
5863
- if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.");
6241
+ if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.", { code: "REALTIME_DISABLED" });
5864
6242
  let existing = realtimeChannels.get(name);
5865
6243
  if (!existing) {
5866
6244
  existing = new RebaseRealtimeChannel(name, ws, options);
@@ -5891,11 +6269,10 @@ channel: (name, options) => {
5891
6269
  collection,
5892
6270
  call: async (endpoint, payload) => {
5893
6271
  const prefix = endpoint.startsWith("/") ? "" : "/";
5894
- const res = await transport.request(`${prefix}${endpoint}`, {
6272
+ return transport.request(`${prefix}${endpoint}`, {
5895
6273
  method: "POST",
5896
6274
  body: payload ? JSON.stringify(payload) : void 0
5897
6275
  });
5898
- return res.data ?? res;
5899
6276
  },
5900
6277
  data: dataProxy,
5901
6278
  ...offlineManager ? { offline: offlineManager.api } : {}