@rebasepro/client 0.8.0 → 0.9.1-canary.09aaf62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -1,5 +1,5 @@
1
- import { QueryBuilder, and, cond, or, serializeFilter, serializeLogicalCondition } from "@rebasepro/common";
2
- import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, Vector } from "@rebasepro/types";
1
+ import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, Vector, isPublicStoragePath } from "@rebasepro/types";
2
+ import { COMPOSITE_ID_SEPARATOR, QueryBuilder, and, buildCompositeId, cond, or, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
3
3
  import { toSnakeCase } from "@rebasepro/utils";
4
4
  //#region src/reviver.ts
5
5
  function rebaseReviver(_key, value) {
@@ -30,25 +30,16 @@ function rebaseReviver(_key, value) {
30
30
  }
31
31
  //#endregion
32
32
  //#region src/transport.ts
33
- var RebaseApiError = class extends Error {
34
- status;
35
- code;
36
- details;
37
- constructor(status, message, code, details) {
38
- super(message);
39
- this.name = "RebaseApiError";
40
- this.status = status;
41
- this.code = code;
42
- this.details = details;
43
- }
44
- };
45
33
  function buildQueryString(params) {
46
34
  if (!params) return "";
47
35
  const parts = [];
48
36
  if (params.limit != null) parts.push(`limit=${params.limit}`);
49
37
  if (params.offset != null) parts.push(`offset=${params.offset}`);
50
38
  if (params.page != null) parts.push(`page=${params.page}`);
51
- if (params.orderBy) parts.push(`orderBy=${encodeURIComponent(params.orderBy)}`);
39
+ if (params.orderBy) {
40
+ const wire = serializeOrderBy(params.orderBy);
41
+ if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
42
+ }
52
43
  if (params.searchString) parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
53
44
  if (params.include && params.include.length > 0) parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
54
45
  if (params.logical) {
@@ -97,8 +88,7 @@ function createTransport(config) {
97
88
  } catch (e) {}
98
89
  const getErrorField = (obj, field) => {
99
90
  const err = obj?.error;
100
- if (err && typeof err === "object" && err !== null && field in err) return err[field];
101
- return obj?.[field];
91
+ if (err && typeof err === "object" && err !== null) return err[field];
102
92
  };
103
93
  if (res.status === 401 && onUnauthorizedHandler) {
104
94
  if (await onUnauthorizedHandler()) {
@@ -121,7 +111,11 @@ function createTransport(config) {
121
111
  if (!retryRes.ok) {
122
112
  let fallbackMessage = retryRes.statusText;
123
113
  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.`;
124
- throw new RebaseApiError(retryRes.status, String(getErrorField(retryBody, "message") || fallbackMessage || `Request failed with status ${retryRes.status}`), getErrorField(retryBody, "code"), getErrorField(retryBody, "details"));
114
+ throw new RebaseApiError$1(String(getErrorField(retryBody, "message") || fallbackMessage || `Request failed with status ${retryRes.status}`), {
115
+ status: retryRes.status,
116
+ code: getErrorField(retryBody, "code"),
117
+ details: getErrorField(retryBody, "details")
118
+ });
125
119
  }
126
120
  return retryBody;
127
121
  }
@@ -129,7 +123,11 @@ function createTransport(config) {
129
123
  if (!res.ok) {
130
124
  let fallbackMessage = res.statusText;
131
125
  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.`;
132
- throw new RebaseApiError(res.status, String(getErrorField(body, "message") || fallbackMessage || `Request failed with status ${res.status}`), getErrorField(body, "code"), getErrorField(body, "details"));
126
+ throw new RebaseApiError$1(String(getErrorField(body, "message") || fallbackMessage || `Request failed with status ${res.status}`), {
127
+ status: res.status,
128
+ code: getErrorField(body, "code"),
129
+ details: getErrorField(body, "details")
130
+ });
133
131
  }
134
132
  return body;
135
133
  }
@@ -165,6 +163,29 @@ function createTransport(config) {
165
163
  }
166
164
  //#endregion
167
165
  //#region src/auth.ts
166
+ /** Map a raw user object from an auth response (`/login`, `/refresh`, `/me`) to a `User`. */
167
+ function mapRawUser(raw) {
168
+ return {
169
+ uid: raw.uid,
170
+ email: raw.email ?? null,
171
+ displayName: raw.displayName ?? null,
172
+ photoURL: raw.photoURL ?? null,
173
+ providerId: raw.providerId ?? "password",
174
+ isAnonymous: raw.isAnonymous ?? false,
175
+ emailVerified: raw.emailVerified,
176
+ roles: raw.roles,
177
+ metadata: raw.metadata
178
+ };
179
+ }
180
+ /** Placeholder user, used only as a last resort when none can be resolved. */
181
+ var EMPTY_USER = {
182
+ uid: "",
183
+ email: null,
184
+ displayName: null,
185
+ photoURL: null,
186
+ providerId: "password",
187
+ isAnonymous: false
188
+ };
168
189
  function createMemoryStorage() {
169
190
  const store = {};
170
191
  return {
@@ -195,11 +216,20 @@ function createAuth(transport, options) {
195
216
  const authPath = opts.authPath || "/auth";
196
217
  const autoRefresh = opts.autoRefresh !== false;
197
218
  const persistSession = opts.persistSession !== false;
219
+ const authFlowMode = opts.authFlowMode || "json";
198
220
  const STORAGE_KEY = "rebase_auth";
199
221
  const REFRESH_BUFFER_MS = 12e4;
222
+ const MAX_REFRESH_RETRIES = 5;
223
+ const REFRESH_RETRY_BASE_MS = 1e3;
224
+ const REFRESH_RETRY_MAX_MS = 3e4;
200
225
  let currentSession = null;
201
226
  const listeners = /* @__PURE__ */ new Set();
202
227
  let refreshTimeout = null;
228
+ let inFlightRefresh = null;
229
+ let resolveInitialized;
230
+ const isInitialized = new Promise((resolve) => {
231
+ resolveInitialized = resolve;
232
+ });
203
233
  function authUrl(endpoint) {
204
234
  return transport.baseUrl + transport.apiPath + authPath + endpoint;
205
235
  }
@@ -207,7 +237,11 @@ function createAuth(transport, options) {
207
237
  return transport.fetchFn || globalThis.fetch;
208
238
  }
209
239
  function throwApiError(status, body, statusText) {
210
- throw new RebaseApiError(status, body?.error?.message || body?.message || statusText, body?.error?.code || body?.code, body?.error?.details || body?.details);
240
+ throw new RebaseApiError(body?.error?.message || body?.message || statusText, {
241
+ status,
242
+ code: body?.error?.code || body?.code,
243
+ details: body?.error?.details || body?.details
244
+ });
211
245
  }
212
246
  function emit(event, session) {
213
247
  for (const fn of listeners) try {
@@ -215,7 +249,7 @@ function createAuth(transport, options) {
215
249
  } catch (e) {}
216
250
  }
217
251
  function saveSession(session) {
218
- if (!persistSession) return;
252
+ if (!persistSession || authFlowMode === "cookie") return;
219
253
  try {
220
254
  storage.setItem(STORAGE_KEY, JSON.stringify(session));
221
255
  } catch (e) {}
@@ -232,28 +266,53 @@ function createAuth(transport, options) {
232
266
  } catch (e) {}
233
267
  return null;
234
268
  }
269
+ /**
270
+ * A refresh failure is only fatal if the refresh token itself is rejected
271
+ * (expired / invalid / forbidden). Network blips, timeouts, and 5xx (e.g. a
272
+ * backend restart mid-session) are transient and must NOT log the user out.
273
+ */
274
+ function isFatalRefreshError(err) {
275
+ if (!(err instanceof RebaseApiError)) return false;
276
+ if (err.code === "INVALID_TOKEN" || err.code === "TOKEN_EXPIRED") return true;
277
+ return err.status === 401 || err.status === 403;
278
+ }
279
+ async function attemptScheduledRefresh(attempt) {
280
+ try {
281
+ await refreshSession();
282
+ } catch (err) {
283
+ if (isFatalRefreshError(err)) {
284
+ signOut();
285
+ return;
286
+ }
287
+ if (attempt >= MAX_REFRESH_RETRIES) {
288
+ signOut();
289
+ return;
290
+ }
291
+ const backoff = Math.min(REFRESH_RETRY_BASE_MS * 2 ** attempt, REFRESH_RETRY_MAX_MS);
292
+ refreshTimeout = setTimeout(() => {
293
+ attemptScheduledRefresh(attempt + 1);
294
+ }, backoff);
295
+ }
296
+ }
235
297
  function scheduleRefresh(expiresAt) {
236
298
  if (refreshTimeout) clearTimeout(refreshTimeout);
237
299
  if (!autoRefresh) return;
238
300
  const delay = expiresAt - REFRESH_BUFFER_MS - Date.now();
239
301
  if (delay <= 0) {
240
- refreshSession().catch(() => signOut());
302
+ attemptScheduledRefresh(0);
241
303
  return;
242
304
  }
243
- refreshTimeout = setTimeout(async () => {
244
- try {
245
- await refreshSession();
246
- } catch (e) {
247
- signOut();
248
- }
305
+ refreshTimeout = setTimeout(() => {
306
+ attemptScheduledRefresh(0);
249
307
  }, delay);
250
308
  }
251
309
  function handleAuthResponse(data, event) {
310
+ const user = mapRawUser(data.user);
252
311
  const session = {
253
312
  accessToken: data.tokens.accessToken,
254
- refreshToken: data.tokens.refreshToken,
313
+ refreshToken: data.tokens.refreshToken || currentSession?.refreshToken || "",
255
314
  expiresAt: data.tokens.accessTokenExpiresAt,
256
- user: data.user
315
+ user
257
316
  };
258
317
  currentSession = session;
259
318
  saveSession(session);
@@ -269,7 +328,8 @@ function createAuth(transport, options) {
269
328
  body: JSON.stringify({
270
329
  email,
271
330
  password
272
- })
331
+ }),
332
+ credentials: authFlowMode === "cookie" ? "include" : void 0
273
333
  });
274
334
  const body = await res.json().catch(() => ({}));
275
335
  if (!res.ok) throwApiError(res.status, body, res.statusText);
@@ -290,7 +350,8 @@ function createAuth(transport, options) {
290
350
  const res = await fetchFn(authUrl("/register"), {
291
351
  method: "POST",
292
352
  headers: { "Content-Type": "application/json" },
293
- body: JSON.stringify(payload)
353
+ body: JSON.stringify(payload),
354
+ credentials: authFlowMode === "cookie" ? "include" : void 0
294
355
  });
295
356
  const body = await res.json().catch(() => ({}));
296
357
  if (!res.ok) throwApiError(res.status, body, res.statusText);
@@ -313,7 +374,8 @@ function createAuth(transport, options) {
313
374
  const res = await getFetch()(authUrl("/google"), {
314
375
  method: "POST",
315
376
  headers: { "Content-Type": "application/json" },
316
- body: JSON.stringify(payload)
377
+ body: JSON.stringify(payload),
378
+ credentials: authFlowMode === "cookie" ? "include" : void 0
317
379
  });
318
380
  const responseBody = await res.json().catch(() => ({}));
319
381
  if (!res.ok) throwApiError(res.status, responseBody, res.statusText);
@@ -331,7 +393,8 @@ function createAuth(transport, options) {
331
393
  body: JSON.stringify({
332
394
  code,
333
395
  redirectUri
334
- })
396
+ }),
397
+ credentials: authFlowMode === "cookie" ? "include" : void 0
335
398
  });
336
399
  const body = await res.json().catch(() => ({}));
337
400
  if (!res.ok) throwApiError(res.status, body, res.statusText);
@@ -350,7 +413,8 @@ function createAuth(transport, options) {
350
413
  const res = await getFetch()(authUrl(`/${providerId}`), {
351
414
  method: "POST",
352
415
  headers: { "Content-Type": "application/json" },
353
- body: JSON.stringify(payload)
416
+ body: JSON.stringify(payload),
417
+ credentials: authFlowMode === "cookie" ? "include" : void 0
354
418
  });
355
419
  const body = await res.json().catch(() => ({}));
356
420
  if (!res.ok) throwApiError(res.status, body, res.statusText);
@@ -426,10 +490,11 @@ function createAuth(transport, options) {
426
490
  async function signOut() {
427
491
  const fetchFn = getFetch();
428
492
  try {
429
- if (currentSession?.refreshToken) await fetchFn(authUrl("/logout"), {
493
+ if (authFlowMode === "cookie" || currentSession?.refreshToken) await fetchFn(authUrl("/logout"), {
430
494
  method: "POST",
431
495
  headers: { "Content-Type": "application/json" },
432
- body: JSON.stringify({ refreshToken: currentSession.refreshToken })
496
+ body: JSON.stringify({ refreshToken: currentSession?.refreshToken }),
497
+ credentials: authFlowMode === "cookie" ? "include" : void 0
433
498
  });
434
499
  } catch (e) {}
435
500
  currentSession = null;
@@ -441,20 +506,35 @@ function createAuth(transport, options) {
441
506
  transport.setToken(null);
442
507
  emit("SIGNED_OUT", null);
443
508
  }
444
- async function refreshSession() {
445
- if (!currentSession?.refreshToken) throw new Error("No active session to refresh");
509
+ function refreshSession() {
510
+ if (inFlightRefresh) return inFlightRefresh;
511
+ inFlightRefresh = doRefreshSession().finally(() => {
512
+ inFlightRefresh = null;
513
+ });
514
+ return inFlightRefresh;
515
+ }
516
+ async function doRefreshSession() {
517
+ if (authFlowMode !== "cookie" && !currentSession?.refreshToken) throw new Error("No active session to refresh");
446
518
  const res = await getFetch()(authUrl("/refresh"), {
447
519
  method: "POST",
448
520
  headers: { "Content-Type": "application/json" },
449
- body: JSON.stringify({ refreshToken: currentSession.refreshToken })
521
+ body: JSON.stringify({ refreshToken: currentSession?.refreshToken }),
522
+ credentials: authFlowMode === "cookie" ? "include" : void 0
450
523
  });
451
524
  const body = await res.json().catch(() => ({}));
452
525
  if (!res.ok) throwApiError(res.status, body, res.statusText);
526
+ const accessToken = body.tokens.accessToken;
527
+ transport.setToken(accessToken);
528
+ let user = currentSession?.user;
529
+ if (body.user && typeof body.user.uid === "string") user = mapRawUser(body.user);
530
+ else if (!user || !user.uid) try {
531
+ user = await getUser();
532
+ } catch {}
453
533
  const session = {
454
- accessToken: body.tokens.accessToken,
455
- refreshToken: body.tokens.refreshToken,
534
+ accessToken,
535
+ refreshToken: body.tokens.refreshToken || currentSession?.refreshToken || "",
456
536
  expiresAt: body.tokens.accessTokenExpiresAt,
457
- user: currentSession.user
537
+ user: user ?? EMPTY_USER
458
538
  };
459
539
  currentSession = session;
460
540
  saveSession(session);
@@ -466,6 +546,18 @@ function createAuth(transport, options) {
466
546
  async function getUser() {
467
547
  return (await transport.request(authPath + "/me", { method: "GET" })).user;
468
548
  }
549
+ /**
550
+ * Resolve an email to a minimal public profile (`uid`, `displayName`,
551
+ * `photoURL`) for invite-by-email flows. Returns `null` when no account
552
+ * matches. Requires the backend to opt in via `auth.allowUserLookup`;
553
+ * otherwise the endpoint is absent and this rejects.
554
+ */
555
+ async function findUserByEmail(email) {
556
+ return (await transport.request(authPath + "/find-user", {
557
+ method: "POST",
558
+ body: JSON.stringify({ email })
559
+ })).user;
560
+ }
469
561
  async function updateUser(updates) {
470
562
  const data = await transport.request(authPath + "/me", {
471
563
  method: "PATCH",
@@ -539,7 +631,8 @@ function createAuth(transport, options) {
539
631
  const res = await getFetch()(authUrl("/magic-link/verify"), {
540
632
  method: "POST",
541
633
  headers: { "Content-Type": "application/json" },
542
- body: JSON.stringify({ token })
634
+ body: JSON.stringify({ token }),
635
+ credentials: authFlowMode === "cookie" ? "include" : void 0
543
636
  });
544
637
  const body = await res.json().catch(() => ({}));
545
638
  if (!res.ok) throwApiError(res.status, body, res.statusText);
@@ -586,21 +679,29 @@ function createAuth(transport, options) {
586
679
  }
587
680
  if (persistSession) {
588
681
  const stored = loadStoredSession();
589
- if (stored && stored.accessToken && stored.refreshToken) {
590
- if (stored.expiresAt > Date.now()) {
591
- currentSession = stored;
592
- transport.setToken(stored.accessToken);
593
- scheduleRefresh(stored.expiresAt);
594
- } else if (stored.refreshToken) {
595
- currentSession = stored;
596
- refreshSession().catch(() => {
597
- currentSession = null;
598
- clearStoredSession();
599
- transport.setToken(null);
600
- });
601
- }
602
- }
603
- }
682
+ if (stored && stored.accessToken) if (stored.expiresAt > Date.now()) {
683
+ currentSession = stored;
684
+ transport.setToken(stored.accessToken);
685
+ scheduleRefresh(stored.expiresAt);
686
+ resolveInitialized();
687
+ } else if (authFlowMode === "cookie" || stored.refreshToken) {
688
+ currentSession = stored;
689
+ refreshSession().then(() => {
690
+ resolveInitialized();
691
+ }).catch(() => {
692
+ currentSession = null;
693
+ clearStoredSession();
694
+ transport.setToken(null);
695
+ resolveInitialized();
696
+ });
697
+ } else resolveInitialized();
698
+ else if (authFlowMode === "cookie") refreshSession().then(() => {
699
+ resolveInitialized();
700
+ }).catch(() => {
701
+ resolveInitialized();
702
+ });
703
+ else resolveInitialized();
704
+ } else resolveInitialized();
604
705
  return {
605
706
  signInWithEmail,
606
707
  signUp,
@@ -620,6 +721,7 @@ function createAuth(transport, options) {
620
721
  signOut,
621
722
  refreshSession,
622
723
  getUser,
724
+ findUserByEmail,
623
725
  updateUser,
624
726
  resetPasswordForEmail,
625
727
  resetPassword,
@@ -633,7 +735,8 @@ function createAuth(transport, options) {
633
735
  revokeAllSessions,
634
736
  getAuthConfig,
635
737
  getSession,
636
- onAuthStateChange
738
+ onAuthStateChange,
739
+ isInitialized: () => isInitialized
637
740
  };
638
741
  }
639
742
  function createCookieStorage(options = {}) {
@@ -766,6 +869,32 @@ function createCron(transport, options) {
766
869
  };
767
870
  }
768
871
  //#endregion
872
+ //#region src/backups.ts
873
+ function createBackups(transport, options) {
874
+ const backupsPath = options?.backupsPath || "/admin/backups";
875
+ async function list() {
876
+ return transport.request(backupsPath, { method: "GET" });
877
+ }
878
+ /**
879
+ * Download a backup's bytes. Uses an authenticated fetch (not the JSON
880
+ * transport) so the octet-stream response comes back as a Blob.
881
+ */
882
+ async function download(key) {
883
+ const token = await transport.resolveToken();
884
+ const url = `${transport.baseUrl}${transport.apiPath}${backupsPath}/download?key=${encodeURIComponent(key)}`;
885
+ const res = await fetch(url, {
886
+ method: "GET",
887
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
888
+ });
889
+ if (!res.ok) throw new Error(`Failed to download backup (${res.status})`);
890
+ return res.blob();
891
+ }
892
+ return {
893
+ list,
894
+ download
895
+ };
896
+ }
897
+ //#endregion
769
898
  //#region src/api-keys.ts
770
899
  /**
771
900
  * Creates a client for managing API keys via the admin routes.
@@ -810,20 +939,108 @@ function createApiKeys(transport, options) {
810
939
  };
811
940
  }
812
941
  //#endregion
813
- //#region src/collection.ts
942
+ //#region src/sdk_query_builder.ts
814
943
  /**
815
- * Wrap a flat row (returned by the REST API as `{ id, ...fields }`) into
816
- * a proper `Entity<M>` structure expected by the core framework.
817
- * The `id` is kept inside `values` as well, since collection properties
818
- * may define an `isId` field that the form binds to `formex.values`.
944
+ * SDK Query Builder returns flat rows (`FindResult<M>`) instead of
945
+ * Entity-wrapped results (`FindResponse<M>`).
946
+ *
947
+ * @example
948
+ * const { data } = await rebase.data.posts
949
+ * .where("status", "==", "published")
950
+ * .orderBy("created_at", "desc")
951
+ * .limit(10)
952
+ * .find();
953
+ *
954
+ * console.log(data[0].title); // flat access
819
955
  */
820
- function rowToEntity(row, slug) {
821
- return {
822
- id: row.id,
823
- path: slug,
824
- values: row
825
- };
826
- }
956
+ var SDKQueryBuilder = class {
957
+ collection;
958
+ params = { where: {} };
959
+ constructor(collection) {
960
+ this.collection = collection;
961
+ }
962
+ where(columnOrCondition, operator, value) {
963
+ if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
964
+ this.params.logical = columnOrCondition;
965
+ return this;
966
+ }
967
+ if (!this.params.where) this.params.where = {};
968
+ const column = columnOrCondition;
969
+ const condition = [operator, value];
970
+ const existing = this.params.where[column];
971
+ if (existing === void 0) this.params.where[column] = condition;
972
+ else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) this.params.where[column].push(condition);
973
+ else {
974
+ let firstCondition;
975
+ if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") firstCondition = existing;
976
+ else firstCondition = ["==", existing];
977
+ this.params.where[column] = [firstCondition, condition];
978
+ }
979
+ return this;
980
+ }
981
+ /**
982
+ * Order the results by a specific column.
983
+ */
984
+ orderBy(column, direction = "asc") {
985
+ this.params.orderBy = [column, direction];
986
+ return this;
987
+ }
988
+ /**
989
+ * Limit the number of results returned.
990
+ */
991
+ limit(count) {
992
+ this.params.limit = count;
993
+ return this;
994
+ }
995
+ /**
996
+ * Skip the first N results.
997
+ */
998
+ offset(count) {
999
+ this.params.offset = count;
1000
+ return this;
1001
+ }
1002
+ /**
1003
+ * Set a free-text search string if supported by the backend.
1004
+ */
1005
+ search(searchString) {
1006
+ this.params.searchString = searchString;
1007
+ return this;
1008
+ }
1009
+ /**
1010
+ * Include related entities in the response.
1011
+ * Relations will be populated with full data instead of just IDs.
1012
+ *
1013
+ * @param relations - Relation names to include, or "*" for all.
1014
+ * @example
1015
+ * client.data.posts.include("tags", "author").find()
1016
+ */
1017
+ include(...relations) {
1018
+ this.params.include = relations;
1019
+ return this;
1020
+ }
1021
+ /**
1022
+ * Execute the find query and return the results as flat rows.
1023
+ */
1024
+ async find() {
1025
+ return this.collection.find(this.params);
1026
+ }
1027
+ /**
1028
+ * Count the records matching this query.
1029
+ */
1030
+ async count() {
1031
+ if (!this.collection.count) throw new Error("count() is not supported by this collection client.");
1032
+ return this.collection.count(this.params);
1033
+ }
1034
+ /**
1035
+ * Listen to realtime updates matching this query.
1036
+ */
1037
+ listen(onUpdate, onError) {
1038
+ 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.");
1039
+ return this.collection.listen(this.params, onUpdate, onError);
1040
+ }
1041
+ };
1042
+ //#endregion
1043
+ //#region src/collection.ts
827
1044
  function createCollectionClient(transport, slug, ws) {
828
1045
  const basePath = `/data/${slug}`;
829
1046
  const client = {
@@ -831,7 +1048,7 @@ function createCollectionClient(transport, slug, ws) {
831
1048
  const qs = buildQueryString(params);
832
1049
  const raw = await transport.request(basePath + qs, { method: "GET" });
833
1050
  return {
834
- data: (raw.data || []).map((row) => rowToEntity(row, slug)),
1051
+ data: raw.data || [],
835
1052
  meta: raw.meta
836
1053
  };
837
1054
  },
@@ -839,7 +1056,7 @@ function createCollectionClient(transport, slug, ws) {
839
1056
  try {
840
1057
  const raw = await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "GET" });
841
1058
  if (!raw) return void 0;
842
- return rowToEntity(raw, slug);
1059
+ return raw;
843
1060
  } catch (err) {
844
1061
  if (err instanceof RebaseApiError && err.status === 404) return;
845
1062
  throw err;
@@ -848,19 +1065,30 @@ function createCollectionClient(transport, slug, ws) {
848
1065
  async create(data, id) {
849
1066
  const body = { ...data };
850
1067
  if (id !== void 0) body.id = id;
851
- return rowToEntity(await transport.request(basePath, {
1068
+ return await transport.request(basePath, {
852
1069
  method: "POST",
853
1070
  body: JSON.stringify(body)
854
- }), slug);
1071
+ });
1072
+ },
1073
+ async createMany(data, options) {
1074
+ if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
1075
+ if (data.length === 0) return [];
1076
+ return (await transport.request(`${basePath}/bulk`, {
1077
+ method: "POST",
1078
+ body: JSON.stringify({
1079
+ rows: data,
1080
+ ...options?.upsert ? { upsert: true } : {}
1081
+ })
1082
+ })).data || [];
855
1083
  },
856
1084
  async update(id, data) {
857
- return rowToEntity(await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
1085
+ return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
858
1086
  method: "PUT",
859
1087
  body: JSON.stringify(data)
860
- }), slug);
1088
+ });
861
1089
  },
862
1090
  async delete(id) {
863
- return transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
1091
+ await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
864
1092
  },
865
1093
  async count(params) {
866
1094
  const qs = buildQueryString({
@@ -871,24 +1099,24 @@ function createCollectionClient(transport, slug, ws) {
871
1099
  return (await transport.request(basePath + "/count" + qs, { method: "GET" })).count ?? 0;
872
1100
  },
873
1101
  where(columnOrCondition, operator, value) {
874
- const builder = new QueryBuilder(client);
1102
+ const builder = new SDKQueryBuilder(client);
875
1103
  if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
876
1104
  return builder.where(columnOrCondition, operator, value);
877
1105
  },
878
1106
  orderBy(column, direction) {
879
- return new QueryBuilder(client).orderBy(column, direction);
1107
+ return new SDKQueryBuilder(client).orderBy(column, direction);
880
1108
  },
881
1109
  limit(count) {
882
- return new QueryBuilder(client).limit(count);
1110
+ return new SDKQueryBuilder(client).limit(count);
883
1111
  },
884
1112
  offset(count) {
885
- return new QueryBuilder(client).offset(count);
1113
+ return new SDKQueryBuilder(client).offset(count);
886
1114
  },
887
1115
  search(searchString) {
888
- return new QueryBuilder(client).search(searchString);
1116
+ return new SDKQueryBuilder(client).search(searchString);
889
1117
  },
890
1118
  include(...relations) {
891
- return new QueryBuilder(client).include(...relations);
1119
+ return new SDKQueryBuilder(client).include(...relations);
892
1120
  }
893
1121
  };
894
1122
  if (ws) {
@@ -900,33 +1128,46 @@ function createCollectionClient(transport, slug, ws) {
900
1128
  filter: params?.where,
901
1129
  limit: params?.limit,
902
1130
  startAfter: params?.offset ? String(params.offset) : void 0,
903
- orderBy: params?.orderBy?.split(":")[0],
904
- order: params?.orderBy?.split(":")[1],
1131
+ orderBy: params?.orderBy?.[0],
1132
+ order: params?.orderBy?.[1],
905
1133
  searchString: params?.searchString
906
- }, (entities) => {
1134
+ }, (incomingRows) => {
907
1135
  const currentUpdateId = ++lastUpdateId;
908
1136
  const requestedLimit = params?.limit || 20;
909
1137
  const offset = params?.offset || 0;
910
- onUpdate({
911
- data: entities,
912
- meta: {
913
- total: entities.length,
914
- limit: requestedLimit,
915
- offset,
916
- hasMore: entities.length >= requestedLimit
917
- }
918
- });
1138
+ const rows = incomingRows;
1139
+ const heuristicTotal = rows.length;
1140
+ const heuristicHasMore = rows.length >= requestedLimit;
919
1141
  if (client.count) client.count(params).then((total) => {
920
1142
  if (active && currentUpdateId === lastUpdateId) onUpdate({
921
- data: entities,
1143
+ data: rows,
922
1144
  meta: {
923
1145
  total,
924
1146
  limit: requestedLimit,
925
1147
  offset,
926
- hasMore: offset + entities.length < total
1148
+ hasMore: offset + rows.length < total
927
1149
  }
928
1150
  });
929
- }).catch(() => {});
1151
+ }).catch(() => {
1152
+ if (active && currentUpdateId === lastUpdateId) onUpdate({
1153
+ data: rows,
1154
+ meta: {
1155
+ total: heuristicTotal,
1156
+ limit: requestedLimit,
1157
+ offset,
1158
+ hasMore: heuristicHasMore
1159
+ }
1160
+ });
1161
+ });
1162
+ else onUpdate({
1163
+ data: rows,
1164
+ meta: {
1165
+ total: heuristicTotal,
1166
+ limit: requestedLimit,
1167
+ offset,
1168
+ hasMore: heuristicHasMore
1169
+ }
1170
+ });
930
1171
  }, onError);
931
1172
  return () => {
932
1173
  active = false;
@@ -934,11 +1175,11 @@ function createCollectionClient(transport, slug, ws) {
934
1175
  };
935
1176
  };
936
1177
  client.listenById = (id, onUpdate, onError) => {
937
- return ws.listenEntity({
1178
+ return ws.listenOne({
938
1179
  path: slug,
939
- entityId: String(id)
940
- }, (entity) => {
941
- if (entity) onUpdate(entity);
1180
+ id: String(id)
1181
+ }, (row) => {
1182
+ if (row) onUpdate(row);
942
1183
  else onUpdate(void 0);
943
1184
  }, onError);
944
1185
  };
@@ -986,10 +1227,12 @@ function createStorage(transport, storageId) {
986
1227
  if (!storageId) return path;
987
1228
  return `${path}${path.includes("?") ? "&" : "?"}storageId=${encodeURIComponent(storageId)}`;
988
1229
  };
989
- async function putObject({ file, key, metadata, bucket }) {
1230
+ async function putObject({ file, key, metadata, bucket, public: isPublic }) {
990
1231
  const formData = new FormData();
991
1232
  formData.append("file", file);
992
- if (key) formData.append("key", key);
1233
+ let effectiveKey = key;
1234
+ if (isPublic && effectiveKey && !isPublicStoragePath(effectiveKey)) effectiveKey = `${PUBLIC_STORAGE_PREFIX}${effectiveKey.replace(/^\/+/, "")}`;
1235
+ if (effectiveKey) formData.append("key", effectiveKey);
993
1236
  if (bucket) formData.append("bucket", bucket);
994
1237
  if (storageId) formData.append("storageId", storageId);
995
1238
  if (metadata) {
@@ -1003,8 +1246,11 @@ function createStorage(transport, storageId) {
1003
1246
  }
1004
1247
  async function getSignedUrl(keyOrUrl, bucket) {
1005
1248
  const cacheKey = bucket ? `${bucket}/${keyOrUrl}` : keyOrUrl;
1006
- const cached = urlsCache.get(cacheKey);
1007
- if (cached) return cached;
1249
+ const cachedEntry = urlsCache.get(cacheKey);
1250
+ if (cachedEntry) {
1251
+ if (!cachedEntry.expiresAt || cachedEntry.expiresAt > Date.now()) return cachedEntry.config;
1252
+ urlsCache.delete(cacheKey);
1253
+ }
1008
1254
  let filePath = keyOrUrl;
1009
1255
  if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://") || filePath.startsWith("gs://"))) filePath = filePath.substring(filePath.indexOf("://") + 3);
1010
1256
  if (bucket && filePath && !filePath.startsWith(bucket)) filePath = `${bucket}/${filePath}`;
@@ -1012,15 +1258,32 @@ function createStorage(transport, storageId) {
1012
1258
  url: null,
1013
1259
  fileNotFound: true
1014
1260
  };
1261
+ if (isPublicStoragePath(filePath)) {
1262
+ const publicConfig = { url: withStorageId(`${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}`) };
1263
+ urlsCache.set(cacheKey, { config: publicConfig });
1264
+ return publicConfig;
1265
+ }
1015
1266
  try {
1016
1267
  const result = await transport.request(withStorageId(`/storage/metadata/${filePath}`));
1017
- const activeToken = await transport.resolveToken();
1018
- const tokenQuery = activeToken ? `?token=${activeToken}` : "";
1268
+ if (result.data.public) {
1269
+ const publicConfig = {
1270
+ url: withStorageId(`${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}`),
1271
+ metadata: result.data
1272
+ };
1273
+ urlsCache.set(cacheKey, { config: publicConfig });
1274
+ return publicConfig;
1275
+ }
1276
+ const scopedToken = result.data.token;
1277
+ const tokenQuery = scopedToken ? `?token=${scopedToken}` : "";
1019
1278
  const downloadConfig = {
1020
1279
  url: withStorageId(`${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}${tokenQuery}`),
1021
1280
  metadata: result.data
1022
1281
  };
1023
- urlsCache.set(cacheKey, downloadConfig);
1282
+ const expiresAt = result.data.tokenExpiresIn ? Date.now() + (result.data.tokenExpiresIn - 10) * 1e3 : void 0;
1283
+ urlsCache.set(cacheKey, {
1284
+ config: downloadConfig,
1285
+ expiresAt
1286
+ });
1024
1287
  return downloadConfig;
1025
1288
  } catch (e) {
1026
1289
  if (e instanceof Error && "status" in e && e.status === 404) return {
@@ -1031,16 +1294,13 @@ function createStorage(transport, storageId) {
1031
1294
  }
1032
1295
  }
1033
1296
  async function getObject(key, bucket) {
1034
- let filePath = key;
1035
- if (filePath && (filePath.startsWith("local://") || filePath.startsWith("s3://") || filePath.startsWith("gs://"))) filePath = filePath.substring(filePath.indexOf("://") + 3);
1036
- if (bucket && filePath && !filePath.startsWith(bucket)) filePath = `${bucket}/${filePath}`;
1037
- if (!filePath || filePath.trim() === "" || filePath === "/") return null;
1038
- const url = withStorageId(`${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}`);
1039
- const response = await transport.fetchFn(url, { headers: transport.getHeaders ? transport.getHeaders() : {} });
1297
+ const downloadConfig = await getSignedUrl(key, bucket);
1298
+ if (downloadConfig.fileNotFound || !downloadConfig.url) return null;
1299
+ const response = await transport.fetchFn(downloadConfig.url, { headers: {} });
1040
1300
  if (response.status === 404) return null;
1041
1301
  if (!response.ok) throw new Error("Failed to get file");
1042
1302
  const blob = await response.blob();
1043
- const fileName = filePath.split("/").pop() || "file";
1303
+ const fileName = (bucket ? `${bucket}/${key}` : key).split("/").pop() || "file";
1044
1304
  return new File([blob], fileName, { type: blob.type });
1045
1305
  }
1046
1306
  async function deleteObject(key, bucket) {
@@ -1137,27 +1397,45 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
1137
1397
  function extractMessageError(message) {
1138
1398
  const payload = message.payload;
1139
1399
  const errPayload = payload?.error;
1400
+ const errorMessage = typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error";
1401
+ const errorCode = typeof errPayload === "object" ? errPayload.code : payload?.code;
1140
1402
  return {
1141
- errorMessage: typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error",
1142
- errorCode: typeof errPayload === "object" ? errPayload.code : payload?.code
1403
+ errorMessage: typeof errorMessage === "string" ? errorMessage : errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage),
1404
+ errorCode
1143
1405
  };
1144
1406
  }
1145
- var ApiError = class extends Error {
1146
- code;
1147
- error;
1148
- constructor(message, error, code) {
1149
- super(message);
1150
- this.name = "ApiError";
1151
- this.code = code;
1152
- this.error = error;
1153
- }
1154
- };
1407
+ /**
1408
+ * Low-level realtime WebSocket client.
1409
+ *
1410
+ * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
1411
+ * manages this internally (exposed as `client.ws`, typed by the minimal
1412
+ * `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
1413
+ * package root only because the `@rebasepro/client-postgres` driver
1414
+ * instantiates it directly; its surface may change without a major bump.
1415
+ */
1155
1416
  var RebaseWebSocketClient = class {
1156
1417
  websocketUrl;
1157
1418
  ws = null;
1158
1419
  getAuthToken;
1159
1420
  subscriptions = /* @__PURE__ */ new Map();
1160
1421
  listeners = /* @__PURE__ */ new Map();
1422
+ /** Channel-name → handlers, for broadcast and presence frames. */
1423
+ channelHandlers = /* @__PURE__ */ new Map();
1424
+ /** Subscribe to broadcast/presence frames for one channel. */
1425
+ onChannelMessage(channel, handler) {
1426
+ if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, /* @__PURE__ */ new Set());
1427
+ this.channelHandlers.get(channel).add(handler);
1428
+ return () => {
1429
+ const handlers = this.channelHandlers.get(channel);
1430
+ if (!handlers) return;
1431
+ handlers.delete(handler);
1432
+ if (handlers.size === 0) this.channelHandlers.delete(channel);
1433
+ };
1434
+ }
1435
+ /** Notified after the socket comes back, so channels can re-join. */
1436
+ onReconnect(handler) {
1437
+ return this.on("reconnect", handler);
1438
+ }
1161
1439
  on(event, cb) {
1162
1440
  if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
1163
1441
  this.listeners.get(event).add(cb);
@@ -1167,7 +1445,7 @@ var RebaseWebSocketClient = class {
1167
1445
  if (this.listeners.has(event)) this.listeners.get(event).forEach((cb) => cb(...args));
1168
1446
  }
1169
1447
  collectionSubscriptions = /* @__PURE__ */ new Map();
1170
- entitySubscriptions = /* @__PURE__ */ new Map();
1448
+ singleSubscriptions = /* @__PURE__ */ new Map();
1171
1449
  backendToCollectionKey = /* @__PURE__ */ new Map();
1172
1450
  backendToEntityKey = /* @__PURE__ */ new Map();
1173
1451
  pendingRequests = /* @__PURE__ */ new Map();
@@ -1176,6 +1454,7 @@ var RebaseWebSocketClient = class {
1176
1454
  isConnected = false;
1177
1455
  messageQueue = [];
1178
1456
  requestTimeoutMs = 3e4;
1457
+ subscriptionTimeoutMs = 3e4;
1179
1458
  reconnectTimeout = null;
1180
1459
  isAuthenticated = false;
1181
1460
  authPromise = null;
@@ -1281,6 +1560,7 @@ var RebaseWebSocketClient = class {
1281
1560
  this.emit(wasReconnect ? "reconnect" : "connect");
1282
1561
  this.processMessageQueue();
1283
1562
  if (wasReconnect) this.resubscribeAll();
1563
+ this.armPendingSubscribeWatchdogs();
1284
1564
  };
1285
1565
  this.ws.onmessage = (event) => {
1286
1566
  try {
@@ -1295,6 +1575,7 @@ var RebaseWebSocketClient = class {
1295
1575
  this.isConnected = false;
1296
1576
  this.isAuthenticated = false;
1297
1577
  this.authPromise = null;
1578
+ this.suspendSubscribeWatchdogs();
1298
1579
  this.emit("disconnect");
1299
1580
  for (const [reqId, request] of this.pendingRequests.entries()) {
1300
1581
  if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
@@ -1302,7 +1583,7 @@ var RebaseWebSocketClient = class {
1302
1583
  request.message._queuedResolve = request.resolve;
1303
1584
  request.message._queuedReject = request.reject;
1304
1585
  this.messageQueue.push(request.message);
1305
- } else request.reject(new ApiError("Connection closed", "Connection closed"));
1586
+ } else request.reject(new RebaseApiError$1("Connection closed"));
1306
1587
  this.pendingRequests.delete(reqId);
1307
1588
  }
1308
1589
  this.attemptReconnect();
@@ -1326,6 +1607,7 @@ var RebaseWebSocketClient = class {
1326
1607
  attemptReconnect() {
1327
1608
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
1328
1609
  console.error("Max reconnection attempts reached");
1610
+ this.failAllPendingSubscriptions(new RebaseApiError$1("Connection lost", { code: "CONNECTION_LOST" }));
1329
1611
  return;
1330
1612
  }
1331
1613
  this.reconnectAttempts++;
@@ -1369,7 +1651,7 @@ var RebaseWebSocketClient = class {
1369
1651
  }
1370
1652
  }
1371
1653
  /**
1372
- * Shared logic for re-subscribing a collection or entity subscription
1654
+ * Shared logic for re-subscribing a collection or row subscription
1373
1655
  * after an auth error is resolved by refreshing credentials.
1374
1656
  */
1375
1657
  resubscribeAfterAuthRefresh(message, subscription, subscriptionKey, idPrefix, backendKeyMap, messageType) {
@@ -1380,29 +1662,18 @@ var RebaseWebSocketClient = class {
1380
1662
  subscription.backendSubscriptionId = newBackendId;
1381
1663
  backendKeyMap.delete(oldBackendId);
1382
1664
  backendKeyMap.set(newBackendId, subscriptionKey);
1383
- this.sendMessage({
1384
- type: messageType,
1385
- payload: {
1386
- ...subscription.props,
1387
- subscriptionId: newBackendId
1388
- }
1389
- }).catch((error) => {
1390
- console.error(`[WS] Failed to re-subscribe ${idPrefix} after auth refresh:`, subscriptionKey, error);
1391
- subscription.callbacks.forEach((callback) => {
1392
- if (callback.onError) callback.onError(error);
1393
- });
1394
- });
1395
- } else {
1396
- const { errorMessage, errorCode } = extractMessageError(message);
1397
- const error = new ApiError(errorMessage, errorMessage, errorCode);
1398
- subscription.callbacks.forEach((callback) => {
1399
- if (callback.onError) callback.onError(error);
1400
- });
1665
+ if (messageType === "subscribe_collection") this.sendCollectionSubscribe(subscriptionKey);
1666
+ else this.sendEntitySubscribe(subscriptionKey);
1667
+ return;
1401
1668
  }
1669
+ const { errorMessage, errorCode } = extractMessageError(message);
1670
+ const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1671
+ if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
1672
+ else this.failEntitySubscription(subscriptionKey, error);
1402
1673
  }).catch((err) => {
1403
- subscription.callbacks.forEach((callback) => {
1404
- if (callback.onError) callback.onError(err);
1405
- });
1674
+ const error = err instanceof Error ? err : new Error(String(err));
1675
+ if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
1676
+ else this.failEntitySubscription(subscriptionKey, error);
1406
1677
  });
1407
1678
  }
1408
1679
  handleWebSocketMessage(message) {
@@ -1415,7 +1686,7 @@ var RebaseWebSocketClient = class {
1415
1686
  if (refreshed && pendingReq.message) this.doSendMessage(pendingReq.message, pendingReq.resolve, pendingReq.reject).catch(pendingReq.reject);
1416
1687
  else {
1417
1688
  const { errorMessage, errorCode } = extractMessageError(message);
1418
- pendingReq.reject(new ApiError(errorMessage, errorMessage, errorCode));
1689
+ pendingReq.reject(new RebaseApiError$1(errorMessage, { code: errorCode }));
1419
1690
  }
1420
1691
  }).catch((err) => {
1421
1692
  pendingReq.reject(err);
@@ -1423,7 +1694,7 @@ var RebaseWebSocketClient = class {
1423
1694
  } else {
1424
1695
  this.pendingRequests.delete(requestId);
1425
1696
  const { errorMessage, errorCode } = extractMessageError(message);
1426
- pendingReq.reject(new ApiError(errorMessage, errorMessage, errorCode));
1697
+ pendingReq.reject(new RebaseApiError$1(errorMessage, { code: errorCode }));
1427
1698
  }
1428
1699
  else {
1429
1700
  this.pendingRequests.delete(requestId);
@@ -1431,19 +1702,33 @@ var RebaseWebSocketClient = class {
1431
1702
  }
1432
1703
  return;
1433
1704
  }
1705
+ if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff")) {
1706
+ const handlers = this.channelHandlers.get(message.channel);
1707
+ if (handlers) for (const handler of [...handlers]) try {
1708
+ handler(message);
1709
+ } catch (error) {
1710
+ console.error("Error in channel handler:", error);
1711
+ }
1712
+ return;
1713
+ }
1434
1714
  if (subscriptionId && type === "collection_update") {
1435
1715
  const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
1436
1716
  if (subscriptionKey) {
1437
1717
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
1438
1718
  if (collectionSub) {
1439
- const incomingEntities = message.entities || [];
1440
- const entities = this.mergeEntities(collectionSub.latestData, incomingEntities);
1441
- collectionSub.latestData = entities;
1719
+ const incomingRows = message.rows || [];
1720
+ const updatePks = message.pks;
1721
+ if (updatePks) collectionSub.pks = updatePks;
1722
+ const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
1723
+ collectionSub.latestData = rows;
1442
1724
  collectionSub.lastUpdated = Date.now();
1443
1725
  collectionSub.isInitialDataReceived = true;
1726
+ if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
1727
+ collectionSub.subscribeTimeout = void 0;
1728
+ collectionSub.subscribeInFlight = false;
1444
1729
  collectionSub.callbacks.forEach((callback) => {
1445
1730
  try {
1446
- callback.onUpdate(entities);
1731
+ callback.onUpdate(rows);
1447
1732
  } catch (error) {
1448
1733
  console.error("Error in collection subscription callback:", error);
1449
1734
  if (callback.onError) callback.onError(error instanceof Error ? error : new Error(String(error)));
@@ -1453,21 +1738,24 @@ var RebaseWebSocketClient = class {
1453
1738
  }
1454
1739
  }
1455
1740
  }
1456
- if (subscriptionId && type === "collection_entity_patch") {
1741
+ if (subscriptionId && type === "collection_patch") {
1457
1742
  const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
1458
1743
  if (subscriptionKey) {
1459
1744
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
1460
1745
  if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
1461
- const patchEntity = message.entity ?? null;
1462
- const patchEntityId = message.entityId;
1746
+ const patchWireEntity = message.row ?? null;
1747
+ const patchMessage = message;
1748
+ const patchEntityId = patchMessage.id;
1749
+ if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
1750
+ const patchRow = patchWireEntity ? patchWireEntity : null;
1463
1751
  let updated;
1464
- if (patchEntity === null || patchEntity === void 0) updated = collectionSub.latestData.filter((e) => String(e.id) !== String(patchEntityId));
1752
+ if (patchRow === null) updated = collectionSub.latestData.filter((e) => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId));
1465
1753
  else {
1466
- const idx = collectionSub.latestData.findIndex((e) => String(e.id) === String(patchEntity.id));
1754
+ const idx = collectionSub.latestData.findIndex((e) => this.rowAddress(e, collectionSub.pks) === String(patchEntityId));
1467
1755
  if (idx >= 0) {
1468
1756
  updated = [...collectionSub.latestData];
1469
- updated[idx] = patchEntity;
1470
- } else updated = [patchEntity, ...collectionSub.latestData];
1757
+ updated[idx] = patchRow;
1758
+ } else updated = [patchRow, ...collectionSub.latestData];
1471
1759
  }
1472
1760
  collectionSub.latestData = updated;
1473
1761
  collectionSub.lastUpdated = Date.now();
@@ -1483,20 +1771,24 @@ var RebaseWebSocketClient = class {
1483
1771
  }
1484
1772
  }
1485
1773
  }
1486
- if (subscriptionId && type === "entity_update") {
1774
+ if (subscriptionId && type === "single_update") {
1487
1775
  const subscriptionKey = this.backendToEntityKey.get(subscriptionId);
1488
1776
  if (subscriptionKey) {
1489
- const entitySub = this.entitySubscriptions.get(subscriptionKey);
1777
+ const entitySub = this.singleSubscriptions.get(subscriptionKey);
1490
1778
  if (entitySub) {
1491
- const entity = message.entity ?? null;
1492
- entitySub.latestData = entity;
1779
+ const wireEntity = message.row ?? null;
1780
+ const row = wireEntity ? wireEntity : null;
1781
+ entitySub.latestData = row;
1493
1782
  entitySub.lastUpdated = Date.now();
1494
1783
  entitySub.isInitialDataReceived = true;
1784
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
1785
+ entitySub.subscribeTimeout = void 0;
1786
+ entitySub.subscribeInFlight = false;
1495
1787
  entitySub.callbacks.forEach((callback) => {
1496
1788
  try {
1497
- callback.onUpdate(entity);
1789
+ callback.onUpdate(row);
1498
1790
  } catch (error) {
1499
- console.error("Error in entity subscription callback:", error);
1791
+ console.error("Error in row subscription callback:", error);
1500
1792
  if (callback.onError) callback.onError(error instanceof Error ? error : new Error(String(error)));
1501
1793
  }
1502
1794
  });
@@ -1513,8 +1805,11 @@ var RebaseWebSocketClient = class {
1513
1805
  this.resubscribeAfterAuthRefresh(message, collectionSub, collectionKey, "collection", this.backendToCollectionKey, "subscribe_collection");
1514
1806
  return;
1515
1807
  }
1808
+ if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
1809
+ collectionSub.subscribeTimeout = void 0;
1810
+ collectionSub.subscribeInFlight = false;
1516
1811
  const { errorMessage, errorCode } = extractMessageError(message);
1517
- const error = new ApiError(errorMessage, errorMessage, errorCode);
1812
+ const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1518
1813
  collectionSub.callbacks.forEach((callback) => {
1519
1814
  if (callback.onError) callback.onError(error);
1520
1815
  });
@@ -1523,14 +1818,17 @@ var RebaseWebSocketClient = class {
1523
1818
  }
1524
1819
  const entityKey = this.backendToEntityKey.get(subscriptionId);
1525
1820
  if (entityKey) {
1526
- const entitySub = this.entitySubscriptions.get(entityKey);
1821
+ const entitySub = this.singleSubscriptions.get(entityKey);
1527
1822
  if (entitySub) {
1528
1823
  if (this.isAuthError(message)) {
1529
- this.resubscribeAfterAuthRefresh(message, entitySub, entityKey, "entity", this.backendToEntityKey, "subscribe_entity");
1824
+ this.resubscribeAfterAuthRefresh(message, entitySub, entityKey, "row", this.backendToEntityKey, "subscribe_one");
1530
1825
  return;
1531
1826
  }
1827
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
1828
+ entitySub.subscribeTimeout = void 0;
1829
+ entitySub.subscribeInFlight = false;
1532
1830
  const { errorMessage, errorCode } = extractMessageError(message);
1533
- const error = new ApiError(errorMessage, errorMessage, errorCode);
1831
+ const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1534
1832
  entitySub.callbacks.forEach((callback) => {
1535
1833
  if (callback.onError) callback.onError(error);
1536
1834
  });
@@ -1544,7 +1842,7 @@ var RebaseWebSocketClient = class {
1544
1842
  if (message.type === "ERROR" || message.error) {
1545
1843
  if (callback.onError) {
1546
1844
  const { errorMessage, errorCode } = extractMessageError(message);
1547
- callback.onError(new ApiError(errorMessage, errorMessage, errorCode));
1845
+ callback.onError(new RebaseApiError$1(errorMessage, { code: errorCode }));
1548
1846
  }
1549
1847
  } else callback.onUpdate(message);
1550
1848
  }
@@ -1601,6 +1899,10 @@ var RebaseWebSocketClient = class {
1601
1899
  throw error;
1602
1900
  }
1603
1901
  }
1902
+ /**
1903
+ * Public because `RebaseRealtimeChannel` sends channel frames through it.
1904
+ * Not part of the stable surface — prefer `client.realtime.channel(name)`.
1905
+ */
1604
1906
  sendMessage(message) {
1605
1907
  const queuedMsg = message;
1606
1908
  if (queuedMsg._queuedResolve && queuedMsg._queuedReject) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
@@ -1618,15 +1920,14 @@ var RebaseWebSocketClient = class {
1618
1920
  if (message.type !== "AUTHENTICATE" && this.getAuthToken && !this.isAuthenticated) try {
1619
1921
  await this.ensureAuthenticated();
1620
1922
  } catch (error) {
1621
- const errorMessage = error instanceof Error ? error.message : "Authentication required";
1622
- reject(new ApiError(errorMessage, errorMessage));
1923
+ reject(new RebaseApiError$1(error instanceof Error ? error.message : "Authentication required"));
1623
1924
  return;
1624
1925
  }
1625
1926
  const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1626
1927
  message.requestId = requestId;
1627
1928
  const expectsResponse = ![
1628
1929
  "subscribe_collection",
1629
- "subscribe_entity",
1930
+ "subscribe_one",
1630
1931
  "unsubscribe",
1631
1932
  "join_channel",
1632
1933
  "leave_channel",
@@ -1639,7 +1940,7 @@ var RebaseWebSocketClient = class {
1639
1940
  const timeoutHandle = setTimeout(() => {
1640
1941
  if (this.pendingRequests.has(requestId)) {
1641
1942
  this.pendingRequests.delete(requestId);
1642
- reject(new ApiError("Request timed out", "Request timed out"));
1943
+ reject(new RebaseApiError$1("Request timed out"));
1643
1944
  }
1644
1945
  }, this.requestTimeoutMs);
1645
1946
  this.pendingRequests.set(requestId, {
@@ -1659,30 +1960,30 @@ var RebaseWebSocketClient = class {
1659
1960
  if (!expectsResponse) resolve(void 0);
1660
1961
  } catch (error) {
1661
1962
  if (expectsResponse) this.pendingRequests.delete(requestId);
1662
- reject(new ApiError("Failed to send message", error instanceof Error ? error.message : "Unknown error"));
1963
+ reject(new RebaseApiError$1("Failed to send message", { cause: error }));
1663
1964
  }
1664
1965
  }
1665
1966
  async fetchCollection(props) {
1666
1967
  return (await this.sendMessage({
1667
1968
  type: "FETCH_COLLECTION",
1668
1969
  payload: props
1669
- })).entities || [];
1970
+ })).rows || [];
1670
1971
  }
1671
- async fetchEntity(props) {
1972
+ async fetchOne(props) {
1672
1973
  return (await this.sendMessage({
1673
- type: "FETCH_ENTITY",
1974
+ type: "FETCH_ONE",
1674
1975
  payload: props
1675
- })).entity ?? void 0;
1976
+ })).row ?? void 0;
1676
1977
  }
1677
- async saveEntity(props) {
1978
+ async save(props) {
1678
1979
  return (await this.sendMessage({
1679
- type: "SAVE_ENTITY",
1980
+ type: "SAVE",
1680
1981
  payload: props
1681
- })).entity;
1982
+ })).row;
1682
1983
  }
1683
- async deleteEntity(props) {
1984
+ async delete(props) {
1684
1985
  await this.sendMessage({
1685
- type: "DELETE_ENTITY",
1986
+ type: "DELETE",
1686
1987
  payload: props
1687
1988
  });
1688
1989
  }
@@ -1707,21 +2008,21 @@ var RebaseWebSocketClient = class {
1707
2008
  async fetchCurrentDatabase() {
1708
2009
  return (await this.sendMessage({ type: "FETCH_CURRENT_DATABASE" })).database;
1709
2010
  }
1710
- async checkUniqueField(path, name, value, entityId, collection) {
2011
+ async checkUniqueField(path, name, value, id, collection) {
1711
2012
  return (await this.sendMessage({
1712
2013
  type: "CHECK_UNIQUE_FIELD",
1713
2014
  payload: {
1714
2015
  path,
1715
2016
  name,
1716
2017
  value,
1717
- entityId,
2018
+ id,
1718
2019
  collection
1719
2020
  }
1720
2021
  })).isUnique;
1721
2022
  }
1722
- async countEntities(props) {
2023
+ async count(props) {
1723
2024
  return (await this.sendMessage({
1724
- type: "COUNT_ENTITIES",
2025
+ type: "COUNT",
1725
2026
  payload: props
1726
2027
  })).count;
1727
2028
  }
@@ -1813,33 +2114,53 @@ var RebaseWebSocketClient = class {
1813
2114
  return val;
1814
2115
  }
1815
2116
  /**
1816
- * Merge incoming entities with cached data, preserving cached references
1817
- * for entities whose values haven't changed. This avoids unnecessary
1818
- * React re-renders when the server refetches all entities but most
2117
+ * The address of a row, for matching it against another copy of itself.
2118
+ *
2119
+ * A row is exactly its columns and carries no address, so it is derived
2120
+ * from the key columns the server named — including the ordinary case where
2121
+ * that key is `id`, which the server reports like any other.
2122
+ *
2123
+ * Undefined when there are no keys, which means the server could not
2124
+ * resolve any: such rows genuinely cannot be recognised, and guessing at a
2125
+ * column called `id` would be inventing an identity for a table that has
2126
+ * none.
2127
+ */
2128
+ rowAddress(row, pks) {
2129
+ if (!pks || pks.length === 0) return void 0;
2130
+ const address = buildCompositeId(row, pks);
2131
+ if (!address || address.split(COMPOSITE_ID_SEPARATOR).every((part) => part === "")) return void 0;
2132
+ return address;
2133
+ }
2134
+ /**
2135
+ * Merge incoming rows with cached data, preserving cached references
2136
+ * for rows whose values haven't changed. This avoids unnecessary
2137
+ * React re-renders when the server refetches all rows but most
1819
2138
  * haven't actually changed.
1820
2139
  */
1821
- mergeEntities(cached, incoming) {
2140
+ mergeRows(cached, incoming, pks) {
1822
2141
  if (!cached || cached.length === 0) return incoming;
1823
2142
  const cachedById = /* @__PURE__ */ new Map();
1824
- for (const entity of cached) cachedById.set(entity.id, entity);
1825
- return incoming.map((incomingEntity) => {
1826
- const cachedEntity = cachedById.get(incomingEntity.id);
1827
- if (!cachedEntity) return incomingEntity;
1828
- if (cachedEntity.path === incomingEntity.path) {
1829
- const normCached = this.normalizeForComparison(cachedEntity.values);
1830
- const normIncoming = this.normalizeForComparison(incomingEntity.values);
1831
- if (this.deepEqual(normCached, normIncoming)) return cachedEntity;
1832
- else {
1833
- const mismatches = {};
1834
- const allKeys = new Set([...Object.keys(normCached), ...Object.keys(normIncoming)]);
1835
- for (const key of allKeys) if (!this.deepEqual(normCached[key], normIncoming[key])) mismatches[key] = {
1836
- cached: normCached[key],
1837
- incoming: normIncoming[key]
1838
- };
1839
- console.debug(`[RebaseWS] Row ${incomingEntity.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
1840
- }
2143
+ for (const row of cached) {
2144
+ const address = this.rowAddress(row, pks);
2145
+ if (address !== void 0) cachedById.set(address, row);
2146
+ }
2147
+ return incoming.map((incomingRow) => {
2148
+ const address = this.rowAddress(incomingRow, pks);
2149
+ const cachedRow = address === void 0 ? void 0 : cachedById.get(address);
2150
+ if (!cachedRow) return incomingRow;
2151
+ const normCached = this.normalizeForComparison(cachedRow);
2152
+ const normIncoming = this.normalizeForComparison(incomingRow);
2153
+ if (this.deepEqual(normCached, normIncoming)) return cachedRow;
2154
+ else {
2155
+ const mismatches = {};
2156
+ const allKeys = new Set([...Object.keys(normCached), ...Object.keys(normIncoming)]);
2157
+ for (const key of allKeys) if (!this.deepEqual(normCached[key], normIncoming[key])) mismatches[key] = {
2158
+ cached: normCached[key],
2159
+ incoming: normIncoming[key]
2160
+ };
2161
+ console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
1841
2162
  }
1842
- return incomingEntity;
2163
+ return incomingRow;
1843
2164
  });
1844
2165
  }
1845
2166
  listenCollection(props, onUpdate, onError) {
@@ -1858,9 +2179,12 @@ var RebaseWebSocketClient = class {
1858
2179
  console.error("Error in collection subscription callback:", error);
1859
2180
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
1860
2181
  }
2182
+ else if (!existingSubscription.subscribeInFlight) this.sendCollectionSubscribe(subscriptionKey);
1861
2183
  return () => {
1862
2184
  callbackMap.delete(callbackId);
1863
2185
  if (callbackMap.size === 0) {
2186
+ if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
2187
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
1864
2188
  this.collectionSubscriptions.delete(subscriptionKey);
1865
2189
  this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
1866
2190
  if (this.isConnected && this.ws) this.sendMessage({
@@ -1882,21 +2206,14 @@ var RebaseWebSocketClient = class {
1882
2206
  props
1883
2207
  });
1884
2208
  this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
1885
- this.sendMessage({
1886
- type: "subscribe_collection",
1887
- payload: {
1888
- ...props,
1889
- subscriptionId: backendSubscriptionId
1890
- }
1891
- }).catch((error) => {
1892
- if (onError) onError(error);
1893
- });
2209
+ this.sendCollectionSubscribe(subscriptionKey);
1894
2210
  return () => {
1895
2211
  const subscription = this.collectionSubscriptions.get(subscriptionKey);
1896
2212
  if (subscription) {
1897
2213
  const callbacks = subscription.callbacks;
1898
2214
  callbacks.delete(callbackId);
1899
2215
  if (callbacks.size === 0) {
2216
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
1900
2217
  this.collectionSubscriptions.delete(subscriptionKey);
1901
2218
  this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
1902
2219
  if (this.isConnected && this.ws) this.sendMessage({
@@ -1907,10 +2224,10 @@ var RebaseWebSocketClient = class {
1907
2224
  }
1908
2225
  };
1909
2226
  }
1910
- listenEntity(props, onUpdate, onError) {
1911
- const subscriptionKey = this.createEntitySubscriptionKey(props);
2227
+ listenOne(props, onUpdate, onError) {
2228
+ const subscriptionKey = this.createSingleSubscriptionKey(props);
1912
2229
  const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1913
- const existingSubscription = this.entitySubscriptions.get(subscriptionKey);
2230
+ const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
1914
2231
  if (existingSubscription) {
1915
2232
  const callbackMap = existingSubscription.callbacks;
1916
2233
  callbackMap.set(callbackId, {
@@ -1920,13 +2237,16 @@ var RebaseWebSocketClient = class {
1920
2237
  if (existingSubscription.latestData !== void 0 && existingSubscription.isInitialDataReceived) try {
1921
2238
  onUpdate(existingSubscription.latestData);
1922
2239
  } catch (error) {
1923
- console.error("Error in entity subscription callback:", error);
2240
+ console.error("Error in row subscription callback:", error);
1924
2241
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
1925
2242
  }
2243
+ else if (!existingSubscription.subscribeInFlight) this.sendEntitySubscribe(subscriptionKey);
1926
2244
  return () => {
1927
2245
  callbackMap.delete(callbackId);
1928
2246
  if (callbackMap.size === 0) {
1929
- this.entitySubscriptions.delete(subscriptionKey);
2247
+ if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
2248
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
2249
+ this.singleSubscriptions.delete(subscriptionKey);
1930
2250
  this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
1931
2251
  if (this.isConnected && this.ws) this.sendMessage({
1932
2252
  type: "unsubscribe",
@@ -1941,28 +2261,20 @@ var RebaseWebSocketClient = class {
1941
2261
  onUpdate,
1942
2262
  onError
1943
2263
  });
1944
- this.entitySubscriptions.set(subscriptionKey, {
2264
+ this.singleSubscriptions.set(subscriptionKey, {
1945
2265
  backendSubscriptionId,
1946
2266
  callbacks: callbackMap,
1947
2267
  props
1948
2268
  });
1949
2269
  this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
1950
- this.sendMessage({
1951
- type: "subscribe_entity",
1952
- payload: {
1953
- ...props,
1954
- subscriptionId: backendSubscriptionId
1955
- }
1956
- }).catch((error) => {
1957
- if (onError) onError(error);
1958
- });
2270
+ this.sendEntitySubscribe(subscriptionKey);
1959
2271
  return () => {
1960
- const subscription = this.entitySubscriptions.get(subscriptionKey);
2272
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
1961
2273
  if (subscription) {
1962
2274
  const callbacks = subscription.callbacks;
1963
2275
  callbacks.delete(callbackId);
1964
2276
  if (callbacks.size === 0) {
1965
- this.entitySubscriptions.delete(subscriptionKey);
2277
+ this.singleSubscriptions.delete(subscriptionKey);
1966
2278
  this.backendToEntityKey.delete(subscription.backendSubscriptionId);
1967
2279
  if (this.isConnected && this.ws) this.sendMessage({
1968
2280
  type: "unsubscribe",
@@ -1973,43 +2285,178 @@ var RebaseWebSocketClient = class {
1973
2285
  };
1974
2286
  }
1975
2287
  /**
2288
+ * Send a `subscribe_collection` for an already-registered subscription and
2289
+ * arm its watchdog.
2290
+ *
2291
+ * Every path that registers a collection subscription goes through here, so
2292
+ * that a subscribe which never lands — a rejected send, or a server that
2293
+ * never answers — always ends up in `failCollectionSubscription` rather than
2294
+ * leaving the entry parked with `isInitialDataReceived === false` forever.
2295
+ */
2296
+ sendCollectionSubscribe(subscriptionKey) {
2297
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
2298
+ if (!subscription) return;
2299
+ const backendSubscriptionId = subscription.backendSubscriptionId;
2300
+ subscription.subscribeInFlight = true;
2301
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2302
+ subscription.subscribeTimeout = void 0;
2303
+ if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
2304
+ this.sendMessage({
2305
+ type: "subscribe_collection",
2306
+ payload: {
2307
+ ...subscription.props,
2308
+ subscriptionId: backendSubscriptionId
2309
+ }
2310
+ }).catch((error) => {
2311
+ const current = this.collectionSubscriptions.get(subscriptionKey);
2312
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
2313
+ this.failCollectionSubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
2314
+ });
2315
+ }
2316
+ /** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
2317
+ sendEntitySubscribe(subscriptionKey) {
2318
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
2319
+ if (!subscription) return;
2320
+ const backendSubscriptionId = subscription.backendSubscriptionId;
2321
+ subscription.subscribeInFlight = true;
2322
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2323
+ subscription.subscribeTimeout = void 0;
2324
+ if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
2325
+ this.sendMessage({
2326
+ type: "subscribe_one",
2327
+ payload: {
2328
+ ...subscription.props,
2329
+ subscriptionId: backendSubscriptionId
2330
+ }
2331
+ }).catch((error) => {
2332
+ const current = this.singleSubscriptions.get(subscriptionKey);
2333
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
2334
+ this.failEntitySubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
2335
+ });
2336
+ }
2337
+ /**
2338
+ * Report a subscribe failure to every listener and drop the registration.
2339
+ *
2340
+ * Dropping it is the point: the callbacks stay live (their components are
2341
+ * still mounted and have been told), but the next `listenCollection` for
2342
+ * these params finds no entry and issues a fresh subscribe instead of
2343
+ * silently attaching to a dead one.
2344
+ */
2345
+ failCollectionSubscription(subscriptionKey, error) {
2346
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
2347
+ if (!subscription) return;
2348
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2349
+ subscription.subscribeInFlight = false;
2350
+ this.collectionSubscriptions.delete(subscriptionKey);
2351
+ this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
2352
+ subscription.callbacks.forEach((callback) => {
2353
+ if (callback.onError) try {
2354
+ callback.onError(error);
2355
+ } catch (callbackError) {
2356
+ console.error("Error in collection subscription error callback:", callbackError);
2357
+ }
2358
+ });
2359
+ }
2360
+ /** The `listenOne` counterpart of {@link failCollectionSubscription}. */
2361
+ failEntitySubscription(subscriptionKey, error) {
2362
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
2363
+ if (!subscription) return;
2364
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2365
+ subscription.subscribeInFlight = false;
2366
+ this.singleSubscriptions.delete(subscriptionKey);
2367
+ this.backendToEntityKey.delete(subscription.backendSubscriptionId);
2368
+ subscription.callbacks.forEach((callback) => {
2369
+ if (callback.onError) try {
2370
+ callback.onError(error);
2371
+ } catch (callbackError) {
2372
+ console.error("Error in row subscription error callback:", callbackError);
2373
+ }
2374
+ });
2375
+ }
2376
+ /**
2377
+ * Stop the watchdogs without failing anything — used when the socket drops,
2378
+ * since the reconnect path re-subscribes everything anyway and a watchdog
2379
+ * firing mid-reconnect would tear down healthy subscriptions.
2380
+ */
2381
+ suspendSubscribeWatchdogs() {
2382
+ for (const sub of this.collectionSubscriptions.values()) {
2383
+ if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
2384
+ sub.subscribeTimeout = void 0;
2385
+ sub.subscribeInFlight = false;
2386
+ }
2387
+ for (const sub of this.singleSubscriptions.values()) {
2388
+ if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
2389
+ sub.subscribeTimeout = void 0;
2390
+ sub.subscribeInFlight = false;
2391
+ }
2392
+ }
2393
+ /**
2394
+ * Arm watchdogs for subscribes that were requested while offline and have
2395
+ * just been flushed to the socket. Their timers were deliberately not set at
2396
+ * request time, so without this they would have no timeout at all.
2397
+ */
2398
+ armPendingSubscribeWatchdogs() {
2399
+ for (const [key, sub] of this.collectionSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
2400
+ for (const [key, sub] of this.singleSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
2401
+ }
2402
+ sendCollectionSubscribeWatchdog(subscriptionKey) {
2403
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
2404
+ if (!subscription) return;
2405
+ const backendSubscriptionId = subscription.backendSubscriptionId;
2406
+ subscription.subscribeTimeout = setTimeout(() => {
2407
+ const current = this.collectionSubscriptions.get(subscriptionKey);
2408
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
2409
+ if (!current.subscribeInFlight) return;
2410
+ this.failCollectionSubscription(subscriptionKey, new RebaseApiError$1("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
2411
+ }, this.subscriptionTimeoutMs);
2412
+ }
2413
+ sendEntitySubscribeWatchdog(subscriptionKey) {
2414
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
2415
+ if (!subscription) return;
2416
+ const backendSubscriptionId = subscription.backendSubscriptionId;
2417
+ subscription.subscribeTimeout = setTimeout(() => {
2418
+ const current = this.singleSubscriptions.get(subscriptionKey);
2419
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
2420
+ if (!current.subscribeInFlight) return;
2421
+ this.failEntitySubscription(subscriptionKey, new RebaseApiError$1("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
2422
+ }, this.subscriptionTimeoutMs);
2423
+ }
2424
+ /**
2425
+ * Fail every subscription that never received data. Called when reconnection
2426
+ * is given up on, so views surface an error instead of spinning forever.
2427
+ */
2428
+ failAllPendingSubscriptions(error) {
2429
+ for (const key of [...this.collectionSubscriptions.keys()]) {
2430
+ const sub = this.collectionSubscriptions.get(key);
2431
+ if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
2432
+ }
2433
+ for (const key of [...this.singleSubscriptions.keys()]) {
2434
+ const sub = this.singleSubscriptions.get(key);
2435
+ if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
2436
+ }
2437
+ }
2438
+ /**
1976
2439
  * Re-send all active subscriptions to the backend after a reconnect.
1977
2440
  * The server wipes subscription state when a client disconnects, so
1978
2441
  * we need to re-register everything to resume receiving updates.
1979
2442
  */
1980
2443
  resubscribeAll() {
1981
- console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.entitySubscriptions.size} entity(ies)`);
2444
+ console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.singleSubscriptions.size} row(ies)`);
1982
2445
  for (const [key, sub] of this.collectionSubscriptions.entries()) {
1983
2446
  const oldBackendId = sub.backendSubscriptionId;
1984
2447
  const newBackendId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1985
2448
  sub.backendSubscriptionId = newBackendId;
1986
2449
  this.backendToCollectionKey.delete(oldBackendId);
1987
2450
  this.backendToCollectionKey.set(newBackendId, key);
1988
- this.sendMessage({
1989
- type: "subscribe_collection",
1990
- payload: {
1991
- ...sub.props,
1992
- subscriptionId: newBackendId
1993
- }
1994
- }).catch((error) => {
1995
- console.error("[WS] Failed to re-subscribe collection:", key, error);
1996
- });
2451
+ this.sendCollectionSubscribe(key);
1997
2452
  }
1998
- for (const [key, sub] of this.entitySubscriptions.entries()) {
2453
+ for (const [key, sub] of this.singleSubscriptions.entries()) {
1999
2454
  const oldBackendId = sub.backendSubscriptionId;
2000
2455
  const newBackendId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
2001
2456
  sub.backendSubscriptionId = newBackendId;
2002
2457
  this.backendToEntityKey.delete(oldBackendId);
2003
2458
  this.backendToEntityKey.set(newBackendId, key);
2004
- this.sendMessage({
2005
- type: "subscribe_entity",
2006
- payload: {
2007
- ...sub.props,
2008
- subscriptionId: newBackendId
2009
- }
2010
- }).catch((error) => {
2011
- console.error("[WS] Failed to re-subscribe entity:", key, error);
2012
- });
2459
+ this.sendEntitySubscribe(key);
2013
2460
  }
2014
2461
  }
2015
2462
  createCollectionSubscriptionKey(props) {
@@ -2031,8 +2478,192 @@ var RebaseWebSocketClient = class {
2031
2478
  return value;
2032
2479
  });
2033
2480
  }
2034
- createEntitySubscriptionKey(props) {
2035
- return `${props.path}|${props.entityId}`;
2481
+ createSingleSubscriptionKey(props) {
2482
+ return `${props.path}|${props.id}`;
2483
+ }
2484
+ };
2485
+ //#endregion
2486
+ //#region src/realtime-channel.ts
2487
+ /**
2488
+ * Re-send presence comfortably inside the server's 30s expiry.
2489
+ *
2490
+ * Two-thirds of the window: one lost heartbeat still leaves time for the next
2491
+ * before the entry is reaped, so a single dropped frame is not a disappearance.
2492
+ */
2493
+ var PRESENCE_HEARTBEAT_MS = 2e4;
2494
+ var RebaseRealtimeChannel = class {
2495
+ name;
2496
+ transport;
2497
+ presenceHandlers = /* @__PURE__ */ new Set();
2498
+ broadcastHandlers = /* @__PURE__ */ new Set();
2499
+ unsubscribers = [];
2500
+ /** Last known roster, kept so handlers always get a full picture. */
2501
+ presences = {};
2502
+ /** What this client last tracked, replayed on reconnect and heartbeat. */
2503
+ trackedState = null;
2504
+ heartbeat = null;
2505
+ joined = false;
2506
+ constructor(name, transport) {
2507
+ this.name = name;
2508
+ this.transport = transport;
2509
+ }
2510
+ /**
2511
+ * Join the channel and ask for the current roster.
2512
+ *
2513
+ * Called automatically by `track`, `broadcast`, `onPresence` and
2514
+ * `onBroadcast`; calling it directly is only needed to start receiving
2515
+ * before there is anything to send.
2516
+ */
2517
+ async join() {
2518
+ if (this.joined) return;
2519
+ this.joined = true;
2520
+ this.unsubscribers.push(this.transport.onChannelMessage(this.name, (message) => this.handle(message)));
2521
+ this.unsubscribers.push(this.transport.onReconnect(() => {
2522
+ this.rejoin();
2523
+ }));
2524
+ await this.transport.sendMessage({
2525
+ type: "join_channel",
2526
+ channel: this.name
2527
+ });
2528
+ await this.transport.sendMessage({
2529
+ type: "presence_state",
2530
+ channel: this.name
2531
+ });
2532
+ }
2533
+ async rejoin() {
2534
+ try {
2535
+ await this.transport.sendMessage({
2536
+ type: "join_channel",
2537
+ channel: this.name
2538
+ });
2539
+ await this.transport.sendMessage({
2540
+ type: "presence_state",
2541
+ channel: this.name
2542
+ });
2543
+ if (this.trackedState) await this.transport.sendMessage({
2544
+ type: "presence_track",
2545
+ channel: this.name,
2546
+ state: this.trackedState
2547
+ });
2548
+ } catch {}
2549
+ }
2550
+ /**
2551
+ * Publish this client's presence state, and keep publishing it.
2552
+ *
2553
+ * Calling `track` again replaces the state (and restarts the heartbeat),
2554
+ * which is how you update e.g. a cursor position.
2555
+ */
2556
+ async track(state) {
2557
+ await this.join();
2558
+ this.trackedState = state;
2559
+ await this.transport.sendMessage({
2560
+ type: "presence_track",
2561
+ channel: this.name,
2562
+ state
2563
+ });
2564
+ if (!this.heartbeat) {
2565
+ this.heartbeat = setInterval(() => {
2566
+ if (!this.trackedState) return;
2567
+ this.transport.sendMessage({
2568
+ type: "presence_track",
2569
+ channel: this.name,
2570
+ state: this.trackedState
2571
+ }).catch(() => {});
2572
+ }, PRESENCE_HEARTBEAT_MS);
2573
+ this.heartbeat.unref?.();
2574
+ }
2575
+ }
2576
+ /** Stop publishing presence, without leaving the channel. */
2577
+ async untrack() {
2578
+ this.stopHeartbeat();
2579
+ this.trackedState = null;
2580
+ if (this.joined) await this.transport.sendMessage({
2581
+ type: "presence_untrack",
2582
+ channel: this.name
2583
+ });
2584
+ }
2585
+ /**
2586
+ * Observe the roster. The handler fires immediately with what is already
2587
+ * known, then on every change.
2588
+ */
2589
+ onPresence(handler) {
2590
+ this.presenceHandlers.add(handler);
2591
+ this.join();
2592
+ if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
2593
+ return () => this.presenceHandlers.delete(handler);
2594
+ }
2595
+ /** Send a broadcast. The sender does not receive its own message. */
2596
+ async broadcast(event, payload) {
2597
+ await this.join();
2598
+ await this.transport.sendMessage({
2599
+ type: "broadcast",
2600
+ channel: this.name,
2601
+ event,
2602
+ payload
2603
+ });
2604
+ }
2605
+ onBroadcast(eventOrHandler, maybeHandler) {
2606
+ const wrapped = typeof eventOrHandler === "string" ? (e) => {
2607
+ if (e.event === eventOrHandler) maybeHandler(e.payload);
2608
+ } : eventOrHandler;
2609
+ this.broadcastHandlers.add(wrapped);
2610
+ this.join();
2611
+ return () => this.broadcastHandlers.delete(wrapped);
2612
+ }
2613
+ /** Leave the channel and release every listener and timer. */
2614
+ async leave() {
2615
+ this.stopHeartbeat();
2616
+ this.trackedState = null;
2617
+ this.presences = {};
2618
+ this.presenceHandlers.clear();
2619
+ this.broadcastHandlers.clear();
2620
+ for (const off of this.unsubscribers) off();
2621
+ this.unsubscribers = [];
2622
+ if (this.joined) {
2623
+ this.joined = false;
2624
+ await this.transport.sendMessage({
2625
+ type: "leave_channel",
2626
+ channel: this.name
2627
+ });
2628
+ }
2629
+ }
2630
+ stopHeartbeat() {
2631
+ if (this.heartbeat) {
2632
+ clearInterval(this.heartbeat);
2633
+ this.heartbeat = null;
2634
+ }
2635
+ }
2636
+ /** Fold an incoming frame into the roster and fan it out. */
2637
+ handle(message) {
2638
+ switch (message.type) {
2639
+ case "presence_state":
2640
+ this.presences = message.presences ?? {};
2641
+ this.emitPresence();
2642
+ break;
2643
+ case "presence_diff": {
2644
+ const joins = message.joins ?? {};
2645
+ const leaves = message.leaves ?? {};
2646
+ for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
2647
+ for (const id of Object.keys(leaves)) delete this.presences[id];
2648
+ this.emitPresence({
2649
+ joins,
2650
+ leaves
2651
+ });
2652
+ break;
2653
+ }
2654
+ case "broadcast": {
2655
+ const event = {
2656
+ event: message.event,
2657
+ payload: message.payload
2658
+ };
2659
+ for (const handler of this.broadcastHandlers) handler(event);
2660
+ break;
2661
+ }
2662
+ }
2663
+ }
2664
+ emitPresence(diff) {
2665
+ const snapshot = { ...this.presences };
2666
+ for (const handler of this.presenceHandlers) handler(snapshot, diff);
2036
2667
  }
2037
2668
  };
2038
2669
  //#endregion
@@ -2063,6 +2694,7 @@ function createRebaseClient(options) {
2063
2694
  const auth = createAuth(transport, options.auth);
2064
2695
  const admin = createAdmin(transport, options.admin);
2065
2696
  const cron = createCron(transport, options.cron);
2697
+ const backups = createBackups(transport);
2066
2698
  const apiKeys = createApiKeys(transport, options.apiKeys);
2067
2699
  const storage = createStorage(transport);
2068
2700
  const functions = createFunctionsClient(transport);
@@ -2083,8 +2715,10 @@ function createRebaseClient(options) {
2083
2715
  });
2084
2716
  return storageSourcesPromise;
2085
2717
  };
2086
- const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
2718
+ const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
2087
2719
  let ws;
2720
+ /** One channel object per name — see `realtime.channel`. */
2721
+ const realtimeChannels = /* @__PURE__ */ new Map();
2088
2722
  if (resolvedWsUrl) {
2089
2723
  ws = new RebaseWebSocketClient({
2090
2724
  websocketUrl: resolvedWsUrl,
@@ -2120,7 +2754,45 @@ function createRebaseClient(options) {
2120
2754
  return false;
2121
2755
  }
2122
2756
  });
2757
+ /**
2758
+ * Suggest the closest known collection key for a mistyped accessor.
2759
+ * Uses edit-distance-1 and prefix matching — no external dependency.
2760
+ */
2761
+ function suggestCollection(prop, knownKeys) {
2762
+ const prefixMatch = knownKeys.find((k) => k.startsWith(prop) || prop.startsWith(k));
2763
+ if (prefixMatch) return prefixMatch;
2764
+ for (const key of knownKeys) {
2765
+ if (Math.abs(key.length - prop.length) > 1) continue;
2766
+ let diffs = 0;
2767
+ const longer = key.length >= prop.length ? key : prop;
2768
+ const shorter = key.length >= prop.length ? prop : key;
2769
+ if (longer.length === shorter.length) for (let i = 0; i < longer.length; i++) {
2770
+ if (longer[i] !== shorter[i]) {
2771
+ if (i + 1 < longer.length && longer[i] === shorter[i + 1] && longer[i + 1] === shorter[i]) {
2772
+ diffs++;
2773
+ i++;
2774
+ if (diffs > 1) break;
2775
+ continue;
2776
+ }
2777
+ diffs++;
2778
+ }
2779
+ if (diffs > 1) break;
2780
+ }
2781
+ else {
2782
+ let li = 0;
2783
+ let si = 0;
2784
+ while (li < longer.length) {
2785
+ if (si < shorter.length && longer[li] === shorter[si]) si++;
2786
+ else diffs++;
2787
+ li++;
2788
+ if (diffs > 1) break;
2789
+ }
2790
+ }
2791
+ if (diffs <= 1) return key;
2792
+ }
2793
+ }
2123
2794
  const collectionClients = /* @__PURE__ */ new Map();
2795
+ let untypedWarned = false;
2124
2796
  function collection(slug) {
2125
2797
  if (!collectionClients.has(slug)) collectionClients.set(slug, createCollectionClient(transport, slug, ws));
2126
2798
  return collectionClients.get(slug);
@@ -2129,7 +2801,19 @@ function createRebaseClient(options) {
2129
2801
  if (prop === "collection") return collection;
2130
2802
  if (typeof prop === "symbol") return void 0;
2131
2803
  if (typeof prop === "string" && prop !== "then" && prop !== "toJSON" && prop !== "$$typeof") {
2132
- if (options.collections && prop in options.collections) return collection(options.collections[prop]);
2804
+ if (options.collections) {
2805
+ if (prop in options.collections) return collection(options.collections[prop]);
2806
+ const knownKeys = Object.keys(options.collections);
2807
+ const suggestion = suggestCollection(prop, knownKeys);
2808
+ let msg = `Unknown collection accessor "${prop}". Known collections: ${knownKeys.join(", ")}.`;
2809
+ if (suggestion) msg += ` Did you mean "${suggestion}"?`;
2810
+ msg += ` Use data.collection("<slug>") for dynamic slugs.`;
2811
+ throw new RebaseClientError(msg);
2812
+ }
2813
+ if (!untypedWarned) {
2814
+ untypedWarned = true;
2815
+ console.warn(`[Rebase] Untyped data access detected (client.data.${prop}). Collection names are resolved via snake_case conversion, which may cause silent 404s at request time. Pass a \`collections\` dictionary to createRebaseClient() or use the generated SDK for type-safe access.`);
2816
+ }
2133
2817
  return collection(toSnakeCase(prop));
2134
2818
  }
2135
2819
  } });
@@ -2137,6 +2821,7 @@ function createRebaseClient(options) {
2137
2821
  auth,
2138
2822
  admin,
2139
2823
  cron,
2824
+ backups,
2140
2825
  apiKeys,
2141
2826
  functions,
2142
2827
  storage,
@@ -2144,6 +2829,36 @@ function createRebaseClient(options) {
2144
2829
  createStorageSource,
2145
2830
  fetchStorageSources,
2146
2831
  ws,
2832
+ realtime: {
2833
+ /**
2834
+ * Join a broadcast/presence channel.
2835
+ *
2836
+ * Repeated calls with the same name return the same channel, so
2837
+ * separate components can attach handlers without each opening its
2838
+ * own membership — and `leave()` from one would otherwise silently
2839
+ * cut off the others.
2840
+ */
2841
+ channel: (name) => {
2842
+ if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
2843
+ let existing = realtimeChannels.get(name);
2844
+ if (!existing) {
2845
+ existing = new RebaseRealtimeChannel(name, ws);
2846
+ realtimeChannels.set(name, existing);
2847
+ }
2848
+ return existing;
2849
+ } },
2850
+ /**
2851
+ * Release the realtime socket and its reconnect timer.
2852
+ *
2853
+ * Until this returns, the open socket keeps the Node event loop alive
2854
+ * and the process will not exit on its own. Safe to call when realtime
2855
+ * was never started, and safe to call twice.
2856
+ */
2857
+ close: () => {
2858
+ for (const channel of realtimeChannels.values()) channel.leave();
2859
+ realtimeChannels.clear();
2860
+ ws?.disconnect();
2861
+ },
2147
2862
  setToken: transport.setToken,
2148
2863
  setAuthTokenGetter: transport.setAuthTokenGetter,
2149
2864
  setOnUnauthorized: transport.setOnUnauthorized,
@@ -2158,11 +2873,10 @@ function createRebaseClient(options) {
2158
2873
  });
2159
2874
  return res.data ?? res;
2160
2875
  },
2161
- data: dataProxy,
2162
- email: void 0
2876
+ data: dataProxy
2163
2877
  };
2164
2878
  }
2165
2879
  //#endregion
2166
- export { ApiError, ClientStorageSourceRegistry, QueryBuilder, RebaseApiError, RebaseWebSocketClient, and, buildQueryString, cond, createAdmin, createApiKeys, createAuth, createCollectionClient, createCookieStorage, createCron, createFunctionsClient, createMemoryStorage, createRebaseClient, createStorage, createTransport, or, rebaseReviver };
2880
+ export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, or };
2167
2881
 
2168
2882
  //# sourceMappingURL=index.es.js.map