@rebasepro/client 0.9.0 → 0.9.1-canary.0de22e0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, Vector, isPublicStoragePath } from "@rebasepro/types";
2
- import { QueryBuilder, and, cond, or, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
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) {
@@ -54,6 +54,28 @@ function buildQueryString(params) {
54
54
  }
55
55
  return parts.length > 0 ? "?" + parts.join("&") : "";
56
56
  }
57
+ /**
58
+ * The base every request and every caller-built URL resolves against.
59
+ *
60
+ * `baseUrl` is optional because the common production shape is a Rebase
61
+ * backend serving its own SPA, where the API is simply the page's origin.
62
+ * Leaving it unset is therefore the *correct* configuration there — and the
63
+ * one that keeps working when a second hostname (a custom domain) points at
64
+ * the same app.
65
+ *
66
+ * When unset in a browser this resolves to the page origin rather than "".
67
+ * Requests behave identically either way, but the empty string is a trap for
68
+ * anything that builds a URL from `client.baseUrl`: `new URL("" + path)`
69
+ * throws, so apps "fixed" it by baking an absolute host into their bundle —
70
+ * which is exactly what breaks the day a custom domain is added, and which no
71
+ * amount of CORS configuration repairs, because a SameSite=Lax auth cookie is
72
+ * not sent cross-site either.
73
+ */
74
+ function resolveBaseUrl(configured) {
75
+ if (configured) return configured.replace(/\/$/, "");
76
+ if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
77
+ return "";
78
+ }
57
79
  function createTransport(config) {
58
80
  const fetchFn = config.fetch || globalThis.fetch;
59
81
  const apiPath = config.apiPath || "/api";
@@ -68,7 +90,7 @@ function createTransport(config) {
68
90
  };
69
91
  }
70
92
  async function request(path, init) {
71
- const url = (config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "") + apiPath + path;
93
+ const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
72
94
  let activeToken = token;
73
95
  if (tokenGetter) try {
74
96
  const fetched = await tokenGetter();
@@ -143,7 +165,7 @@ function createTransport(config) {
143
165
  onUnauthorizedHandler = handler;
144
166
  },
145
167
  get baseUrl() {
146
- return config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "";
168
+ return resolveBaseUrl(config.baseUrl);
147
169
  },
148
170
  get apiPath() {
149
171
  return apiPath;
@@ -605,6 +627,30 @@ function createAuth(transport, options) {
605
627
  })
606
628
  });
607
629
  }
630
+ /**
631
+ * Link an OAuth provider to the **currently signed-in** account.
632
+ *
633
+ * Use this when `signIn*` failed with `EMAIL_NOT_VERIFIED` — an account
634
+ * with that email already exists under a different sign-in method — or to
635
+ * attach a provider whose email differs from the account's.
636
+ *
637
+ * The payload is the same one the provider's sign-in method takes, e.g.
638
+ * `linkProvider("google", { idToken })`.
639
+ *
640
+ * Unlike sign-in, this does not require the provider to have verified the
641
+ * email, and the emails need not match: the active session already proves
642
+ * account ownership.
643
+ *
644
+ * Throws `IDENTITY_ALREADY_LINKED` (409) if that provider identity is
645
+ * attached to a different user. Succeeds idempotently (`alreadyLinked:
646
+ * true`) if it is already attached to the current one.
647
+ */
648
+ async function linkProvider(providerId, payload) {
649
+ return transport.request(authPath + "/link/" + providerId, {
650
+ method: "POST",
651
+ body: JSON.stringify(payload)
652
+ });
653
+ }
608
654
  async function sendVerificationEmail() {
609
655
  return transport.request(authPath + "/send-verification", { method: "POST" });
610
656
  }
@@ -726,6 +772,7 @@ function createAuth(transport, options) {
726
772
  resetPasswordForEmail,
727
773
  resetPassword,
728
774
  changePassword,
775
+ linkProvider,
729
776
  sendVerificationEmail,
730
777
  verifyEmail,
731
778
  sendMagicLink,
@@ -869,6 +916,32 @@ function createCron(transport, options) {
869
916
  };
870
917
  }
871
918
  //#endregion
919
+ //#region src/backups.ts
920
+ function createBackups(transport, options) {
921
+ const backupsPath = options?.backupsPath || "/admin/backups";
922
+ async function list() {
923
+ return transport.request(backupsPath, { method: "GET" });
924
+ }
925
+ /**
926
+ * Download a backup's bytes. Uses an authenticated fetch (not the JSON
927
+ * transport) so the octet-stream response comes back as a Blob.
928
+ */
929
+ async function download(key) {
930
+ const token = await transport.resolveToken();
931
+ const url = `${transport.baseUrl}${transport.apiPath}${backupsPath}/download?key=${encodeURIComponent(key)}`;
932
+ const res = await fetch(url, {
933
+ method: "GET",
934
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
935
+ });
936
+ if (!res.ok) throw new Error(`Failed to download backup (${res.status})`);
937
+ return res.blob();
938
+ }
939
+ return {
940
+ list,
941
+ download
942
+ };
943
+ }
944
+ //#endregion
872
945
  //#region src/api-keys.ts
873
946
  /**
874
947
  * Creates a client for managing API keys via the admin routes.
@@ -1009,7 +1082,7 @@ var SDKQueryBuilder = class {
1009
1082
  * Listen to realtime updates matching this query.
1010
1083
  */
1011
1084
  listen(onUpdate, onError) {
1012
- if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl.");
1085
+ 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.");
1013
1086
  return this.collection.listen(this.params, onUpdate, onError);
1014
1087
  }
1015
1088
  };
@@ -1044,6 +1117,17 @@ function createCollectionClient(transport, slug, ws) {
1044
1117
  body: JSON.stringify(body)
1045
1118
  });
1046
1119
  },
1120
+ async createMany(data, options) {
1121
+ if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
1122
+ if (data.length === 0) return [];
1123
+ return (await transport.request(`${basePath}/bulk`, {
1124
+ method: "POST",
1125
+ body: JSON.stringify({
1126
+ rows: data,
1127
+ ...options?.upsert ? { upsert: true } : {}
1128
+ })
1129
+ })).data || [];
1130
+ },
1047
1131
  async update(id, data) {
1048
1132
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
1049
1133
  method: "PUT",
@@ -1360,18 +1444,35 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
1360
1444
  function extractMessageError(message) {
1361
1445
  const payload = message.payload;
1362
1446
  const errPayload = payload?.error;
1447
+ const errorMessage = typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error";
1448
+ const errorCode = typeof errPayload === "object" ? errPayload.code : payload?.code;
1363
1449
  return {
1364
- errorMessage: typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error",
1365
- errorCode: typeof errPayload === "object" ? errPayload.code : payload?.code
1450
+ errorMessage: typeof errorMessage === "string" ? errorMessage : errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage),
1451
+ errorCode
1366
1452
  };
1367
1453
  }
1368
1454
  /**
1455
+ * Broadcast and presence frames.
1456
+ *
1457
+ * Fire-and-forget (the server sends no response envelope), and exempt from the
1458
+ * client-side auth gate — a public channel is usable without an account.
1459
+ */
1460
+ var CHANNEL_MESSAGE_TYPES = new Set([
1461
+ "join_channel",
1462
+ "leave_channel",
1463
+ "broadcast",
1464
+ "presence_track",
1465
+ "presence_untrack",
1466
+ "presence_state",
1467
+ "channel_history"
1468
+ ]);
1469
+ /**
1369
1470
  * Low-level realtime WebSocket client.
1370
1471
  *
1371
1472
  * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
1372
1473
  * manages this internally (exposed as `client.ws`, typed by the minimal
1373
1474
  * `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
1374
- * package root only because the `@rebasepro/client-postgresql` driver
1475
+ * package root only because the `@rebasepro/client-postgres` driver
1375
1476
  * instantiates it directly; its surface may change without a major bump.
1376
1477
  */
1377
1478
  var RebaseWebSocketClient = class {
@@ -1380,6 +1481,36 @@ var RebaseWebSocketClient = class {
1380
1481
  getAuthToken;
1381
1482
  subscriptions = /* @__PURE__ */ new Map();
1382
1483
  listeners = /* @__PURE__ */ new Map();
1484
+ /** Channel-name → handlers, for broadcast and presence frames. */
1485
+ channelHandlers = /* @__PURE__ */ new Map();
1486
+ /** Set by `close()`. Blocks any later operation from silently redialling. */
1487
+ closedByCaller = false;
1488
+ /**
1489
+ * Whether a socket exists at all (open or still opening).
1490
+ *
1491
+ * Lets callers distinguish "authenticate the live socket" from "there is
1492
+ * nothing to authenticate yet", without that question forcing a dial.
1493
+ */
1494
+ get hasSocket() {
1495
+ return this.ws !== null;
1496
+ }
1497
+ /** So the "no WebSocket in this environment" warning is said once, not per call. */
1498
+ warnedNoWebSocket = false;
1499
+ /** Subscribe to broadcast/presence frames for one channel. */
1500
+ onChannelMessage(channel, handler) {
1501
+ if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, /* @__PURE__ */ new Set());
1502
+ this.channelHandlers.get(channel).add(handler);
1503
+ return () => {
1504
+ const handlers = this.channelHandlers.get(channel);
1505
+ if (!handlers) return;
1506
+ handlers.delete(handler);
1507
+ if (handlers.size === 0) this.channelHandlers.delete(channel);
1508
+ };
1509
+ }
1510
+ /** Notified after the socket comes back, so channels can re-join. */
1511
+ onReconnect(handler) {
1512
+ return this.on("reconnect", handler);
1513
+ }
1383
1514
  on(event, cb) {
1384
1515
  if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
1385
1516
  this.listeners.get(event).add(cb);
@@ -1398,6 +1529,7 @@ var RebaseWebSocketClient = class {
1398
1529
  isConnected = false;
1399
1530
  messageQueue = [];
1400
1531
  requestTimeoutMs = 3e4;
1532
+ subscriptionTimeoutMs = 3e4;
1401
1533
  reconnectTimeout = null;
1402
1534
  isAuthenticated = false;
1403
1535
  authPromise = null;
@@ -1409,8 +1541,25 @@ var RebaseWebSocketClient = class {
1409
1541
  this.getAuthToken = config.getAuthToken;
1410
1542
  this.onUnauthorized = config.onUnauthorized;
1411
1543
  this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : void 0);
1412
- if (!this.WebSocketConstructor) console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
1413
- else this.initWebSocket();
1544
+ }
1545
+ /**
1546
+ * Open the socket if it is not open (or opening) already.
1547
+ *
1548
+ * Idempotent, synchronous, and safe to call on every operation that needs a
1549
+ * live socket — `initWebSocket` already no-ops on an open socket and is
1550
+ * re-entrant, since the reconnect path has always called it.
1551
+ */
1552
+ ensureConnected() {
1553
+ if (this.closedByCaller) return;
1554
+ if (!this.WebSocketConstructor) {
1555
+ if (!this.warnedNoWebSocket) {
1556
+ this.warnedNoWebSocket = true;
1557
+ console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
1558
+ }
1559
+ return;
1560
+ }
1561
+ if (this.ws || this.reconnectTimeout) return;
1562
+ this.initWebSocket();
1414
1563
  }
1415
1564
  /**
1416
1565
  * Authenticate the WebSocket connection
@@ -1460,7 +1609,16 @@ var RebaseWebSocketClient = class {
1460
1609
  });
1461
1610
  }
1462
1611
  }
1463
- disconnect() {
1612
+ /**
1613
+ * Drop the socket.
1614
+ *
1615
+ * `permanent` distinguishes the two callers. Signing out drops the socket
1616
+ * but the client stays usable — a later subscribe should reconnect
1617
+ * anonymously. `client.close()` is the caller saying they are done, and
1618
+ * must not be undone by a stray queued frame.
1619
+ */
1620
+ disconnect(permanent = false) {
1621
+ if (permanent) this.closedByCaller = true;
1464
1622
  this.isAuthenticated = false;
1465
1623
  this.authPromise = null;
1466
1624
  if (this.reconnectTimeout) {
@@ -1503,6 +1661,7 @@ var RebaseWebSocketClient = class {
1503
1661
  this.emit(wasReconnect ? "reconnect" : "connect");
1504
1662
  this.processMessageQueue();
1505
1663
  if (wasReconnect) this.resubscribeAll();
1664
+ this.armPendingSubscribeWatchdogs();
1506
1665
  };
1507
1666
  this.ws.onmessage = (event) => {
1508
1667
  try {
@@ -1517,6 +1676,7 @@ var RebaseWebSocketClient = class {
1517
1676
  this.isConnected = false;
1518
1677
  this.isAuthenticated = false;
1519
1678
  this.authPromise = null;
1679
+ this.suspendSubscribeWatchdogs();
1520
1680
  this.emit("disconnect");
1521
1681
  for (const [reqId, request] of this.pendingRequests.entries()) {
1522
1682
  if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
@@ -1548,6 +1708,7 @@ var RebaseWebSocketClient = class {
1548
1708
  attemptReconnect() {
1549
1709
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
1550
1710
  console.error("Max reconnection attempts reached");
1711
+ this.failAllPendingSubscriptions(new RebaseApiError$1("Connection lost", { code: "CONNECTION_LOST" }));
1551
1712
  return;
1552
1713
  }
1553
1714
  this.reconnectAttempts++;
@@ -1602,29 +1763,18 @@ var RebaseWebSocketClient = class {
1602
1763
  subscription.backendSubscriptionId = newBackendId;
1603
1764
  backendKeyMap.delete(oldBackendId);
1604
1765
  backendKeyMap.set(newBackendId, subscriptionKey);
1605
- this.sendMessage({
1606
- type: messageType,
1607
- payload: {
1608
- ...subscription.props,
1609
- subscriptionId: newBackendId
1610
- }
1611
- }).catch((error) => {
1612
- console.error(`[WS] Failed to re-subscribe ${idPrefix} after auth refresh:`, subscriptionKey, error);
1613
- subscription.callbacks.forEach((callback) => {
1614
- if (callback.onError) callback.onError(error);
1615
- });
1616
- });
1617
- } else {
1618
- const { errorMessage, errorCode } = extractMessageError(message);
1619
- const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1620
- subscription.callbacks.forEach((callback) => {
1621
- if (callback.onError) callback.onError(error);
1622
- });
1766
+ if (messageType === "subscribe_collection") this.sendCollectionSubscribe(subscriptionKey);
1767
+ else this.sendEntitySubscribe(subscriptionKey);
1768
+ return;
1623
1769
  }
1770
+ const { errorMessage, errorCode } = extractMessageError(message);
1771
+ const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1772
+ if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
1773
+ else this.failEntitySubscription(subscriptionKey, error);
1624
1774
  }).catch((err) => {
1625
- subscription.callbacks.forEach((callback) => {
1626
- if (callback.onError) callback.onError(err);
1627
- });
1775
+ const error = err instanceof Error ? err : new Error(String(err));
1776
+ if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
1777
+ else this.failEntitySubscription(subscriptionKey, error);
1628
1778
  });
1629
1779
  }
1630
1780
  handleWebSocketMessage(message) {
@@ -1653,16 +1803,30 @@ var RebaseWebSocketClient = class {
1653
1803
  }
1654
1804
  return;
1655
1805
  }
1806
+ if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
1807
+ const handlers = this.channelHandlers.get(message.channel);
1808
+ if (handlers) for (const handler of [...handlers]) try {
1809
+ handler(message);
1810
+ } catch (error) {
1811
+ console.error("Error in channel handler:", error);
1812
+ }
1813
+ return;
1814
+ }
1656
1815
  if (subscriptionId && type === "collection_update") {
1657
1816
  const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
1658
1817
  if (subscriptionKey) {
1659
1818
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
1660
1819
  if (collectionSub) {
1661
1820
  const incomingRows = message.rows || [];
1662
- const rows = this.mergeRows(collectionSub.latestData, incomingRows);
1821
+ const updatePks = message.pks;
1822
+ if (updatePks) collectionSub.pks = updatePks;
1823
+ const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
1663
1824
  collectionSub.latestData = rows;
1664
1825
  collectionSub.lastUpdated = Date.now();
1665
1826
  collectionSub.isInitialDataReceived = true;
1827
+ if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
1828
+ collectionSub.subscribeTimeout = void 0;
1829
+ collectionSub.subscribeInFlight = false;
1666
1830
  collectionSub.callbacks.forEach((callback) => {
1667
1831
  try {
1668
1832
  callback.onUpdate(rows);
@@ -1681,12 +1845,14 @@ var RebaseWebSocketClient = class {
1681
1845
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
1682
1846
  if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
1683
1847
  const patchWireEntity = message.row ?? null;
1684
- const patchEntityId = message.id;
1848
+ const patchMessage = message;
1849
+ const patchEntityId = patchMessage.id;
1850
+ if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
1685
1851
  const patchRow = patchWireEntity ? patchWireEntity : null;
1686
1852
  let updated;
1687
- if (patchRow === null) updated = collectionSub.latestData.filter((e) => String(e.id) !== String(patchEntityId));
1853
+ if (patchRow === null) updated = collectionSub.latestData.filter((e) => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId));
1688
1854
  else {
1689
- const idx = collectionSub.latestData.findIndex((e) => String(e.id) === String(patchRow.id));
1855
+ const idx = collectionSub.latestData.findIndex((e) => this.rowAddress(e, collectionSub.pks) === String(patchEntityId));
1690
1856
  if (idx >= 0) {
1691
1857
  updated = [...collectionSub.latestData];
1692
1858
  updated[idx] = patchRow;
@@ -1716,6 +1882,9 @@ var RebaseWebSocketClient = class {
1716
1882
  entitySub.latestData = row;
1717
1883
  entitySub.lastUpdated = Date.now();
1718
1884
  entitySub.isInitialDataReceived = true;
1885
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
1886
+ entitySub.subscribeTimeout = void 0;
1887
+ entitySub.subscribeInFlight = false;
1719
1888
  entitySub.callbacks.forEach((callback) => {
1720
1889
  try {
1721
1890
  callback.onUpdate(row);
@@ -1737,6 +1906,9 @@ var RebaseWebSocketClient = class {
1737
1906
  this.resubscribeAfterAuthRefresh(message, collectionSub, collectionKey, "collection", this.backendToCollectionKey, "subscribe_collection");
1738
1907
  return;
1739
1908
  }
1909
+ if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
1910
+ collectionSub.subscribeTimeout = void 0;
1911
+ collectionSub.subscribeInFlight = false;
1740
1912
  const { errorMessage, errorCode } = extractMessageError(message);
1741
1913
  const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1742
1914
  collectionSub.callbacks.forEach((callback) => {
@@ -1753,6 +1925,9 @@ var RebaseWebSocketClient = class {
1753
1925
  this.resubscribeAfterAuthRefresh(message, entitySub, entityKey, "row", this.backendToEntityKey, "subscribe_one");
1754
1926
  return;
1755
1927
  }
1928
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
1929
+ entitySub.subscribeTimeout = void 0;
1930
+ entitySub.subscribeInFlight = false;
1756
1931
  const { errorMessage, errorCode } = extractMessageError(message);
1757
1932
  const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1758
1933
  entitySub.callbacks.forEach((callback) => {
@@ -1825,21 +2000,28 @@ var RebaseWebSocketClient = class {
1825
2000
  throw error;
1826
2001
  }
1827
2002
  }
2003
+ /**
2004
+ * Public because `RebaseRealtimeChannel` sends channel frames through it.
2005
+ * Not part of the stable surface — prefer `client.realtime.channel(name)`.
2006
+ */
1828
2007
  sendMessage(message) {
1829
2008
  const queuedMsg = message;
1830
2009
  if (queuedMsg._queuedResolve && queuedMsg._queuedReject) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
1831
- if (!this.isConnected || !this.ws) return new Promise((resolve, reject) => {
1832
- const queueable = message;
1833
- queueable._queuedResolve = resolve;
1834
- queueable._queuedReject = reject;
1835
- this.messageQueue.push(message);
1836
- });
2010
+ if (!this.isConnected || !this.ws) {
2011
+ this.ensureConnected();
2012
+ return new Promise((resolve, reject) => {
2013
+ const queueable = message;
2014
+ queueable._queuedResolve = resolve;
2015
+ queueable._queuedReject = reject;
2016
+ this.messageQueue.push(message);
2017
+ });
2018
+ }
1837
2019
  return new Promise((resolve, reject) => {
1838
2020
  this.doSendMessage(message, resolve, reject);
1839
2021
  });
1840
2022
  }
1841
2023
  async doSendMessage(message, resolve, reject) {
1842
- if (message.type !== "AUTHENTICATE" && this.getAuthToken && !this.isAuthenticated) try {
2024
+ if (message.type !== "AUTHENTICATE" && !CHANNEL_MESSAGE_TYPES.has(message.type) && this.getAuthToken && !this.isAuthenticated) try {
1843
2025
  await this.ensureAuthenticated();
1844
2026
  } catch (error) {
1845
2027
  reject(new RebaseApiError$1(error instanceof Error ? error.message : "Authentication required"));
@@ -1847,17 +2029,7 @@ var RebaseWebSocketClient = class {
1847
2029
  }
1848
2030
  const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1849
2031
  message.requestId = requestId;
1850
- const expectsResponse = ![
1851
- "subscribe_collection",
1852
- "subscribe_one",
1853
- "unsubscribe",
1854
- "join_channel",
1855
- "leave_channel",
1856
- "broadcast",
1857
- "presence_track",
1858
- "presence_untrack",
1859
- "presence_state"
1860
- ].includes(message.type);
2032
+ const expectsResponse = !(message.type === "subscribe_collection" || message.type === "subscribe_one" || message.type === "unsubscribe" || CHANNEL_MESSAGE_TYPES.has(message.type));
1861
2033
  if (expectsResponse && !this.pendingRequests.has(requestId)) {
1862
2034
  const timeoutHandle = setTimeout(() => {
1863
2035
  if (this.pendingRequests.has(requestId)) {
@@ -1927,6 +2099,9 @@ var RebaseWebSocketClient = class {
1927
2099
  async fetchAvailableRoles() {
1928
2100
  return (await this.sendMessage({ type: "FETCH_ROLES" })).roles || [];
1929
2101
  }
2102
+ async fetchApplicationRoles() {
2103
+ return (await this.sendMessage({ type: "FETCH_APPLICATION_ROLES" })).roles || [];
2104
+ }
1930
2105
  async fetchCurrentDatabase() {
1931
2106
  return (await this.sendMessage({ type: "FETCH_CURRENT_DATABASE" })).database;
1932
2107
  }
@@ -2036,17 +2211,39 @@ var RebaseWebSocketClient = class {
2036
2211
  return val;
2037
2212
  }
2038
2213
  /**
2214
+ * The address of a row, for matching it against another copy of itself.
2215
+ *
2216
+ * A row is exactly its columns and carries no address, so it is derived
2217
+ * from the key columns the server named — including the ordinary case where
2218
+ * that key is `id`, which the server reports like any other.
2219
+ *
2220
+ * Undefined when there are no keys, which means the server could not
2221
+ * resolve any: such rows genuinely cannot be recognised, and guessing at a
2222
+ * column called `id` would be inventing an identity for a table that has
2223
+ * none.
2224
+ */
2225
+ rowAddress(row, pks) {
2226
+ if (!pks || pks.length === 0) return void 0;
2227
+ const address = buildCompositeId(row, pks);
2228
+ if (!address || address.split(COMPOSITE_ID_SEPARATOR).every((part) => part === "")) return void 0;
2229
+ return address;
2230
+ }
2231
+ /**
2039
2232
  * Merge incoming rows with cached data, preserving cached references
2040
2233
  * for rows whose values haven't changed. This avoids unnecessary
2041
2234
  * React re-renders when the server refetches all rows but most
2042
2235
  * haven't actually changed.
2043
2236
  */
2044
- mergeRows(cached, incoming) {
2237
+ mergeRows(cached, incoming, pks) {
2045
2238
  if (!cached || cached.length === 0) return incoming;
2046
2239
  const cachedById = /* @__PURE__ */ new Map();
2047
- for (const row of cached) cachedById.set(row.id, row);
2240
+ for (const row of cached) {
2241
+ const address = this.rowAddress(row, pks);
2242
+ if (address !== void 0) cachedById.set(address, row);
2243
+ }
2048
2244
  return incoming.map((incomingRow) => {
2049
- const cachedRow = cachedById.get(incomingRow.id);
2245
+ const address = this.rowAddress(incomingRow, pks);
2246
+ const cachedRow = address === void 0 ? void 0 : cachedById.get(address);
2050
2247
  if (!cachedRow) return incomingRow;
2051
2248
  const normCached = this.normalizeForComparison(cachedRow);
2052
2249
  const normIncoming = this.normalizeForComparison(incomingRow);
@@ -2058,12 +2255,13 @@ var RebaseWebSocketClient = class {
2058
2255
  cached: normCached[key],
2059
2256
  incoming: normIncoming[key]
2060
2257
  };
2061
- console.debug(`[RebaseWS] Row ${incomingRow.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
2258
+ console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
2062
2259
  }
2063
2260
  return incomingRow;
2064
2261
  });
2065
2262
  }
2066
2263
  listenCollection(props, onUpdate, onError) {
2264
+ this.ensureConnected();
2067
2265
  const subscriptionKey = this.createCollectionSubscriptionKey(props);
2068
2266
  const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
2069
2267
  const existingSubscription = this.collectionSubscriptions.get(subscriptionKey);
@@ -2079,9 +2277,12 @@ var RebaseWebSocketClient = class {
2079
2277
  console.error("Error in collection subscription callback:", error);
2080
2278
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
2081
2279
  }
2280
+ else if (!existingSubscription.subscribeInFlight) this.sendCollectionSubscribe(subscriptionKey);
2082
2281
  return () => {
2083
2282
  callbackMap.delete(callbackId);
2084
2283
  if (callbackMap.size === 0) {
2284
+ if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
2285
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
2085
2286
  this.collectionSubscriptions.delete(subscriptionKey);
2086
2287
  this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
2087
2288
  if (this.isConnected && this.ws) this.sendMessage({
@@ -2103,21 +2304,14 @@ var RebaseWebSocketClient = class {
2103
2304
  props
2104
2305
  });
2105
2306
  this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
2106
- this.sendMessage({
2107
- type: "subscribe_collection",
2108
- payload: {
2109
- ...props,
2110
- subscriptionId: backendSubscriptionId
2111
- }
2112
- }).catch((error) => {
2113
- if (onError) onError(error);
2114
- });
2307
+ this.sendCollectionSubscribe(subscriptionKey);
2115
2308
  return () => {
2116
2309
  const subscription = this.collectionSubscriptions.get(subscriptionKey);
2117
2310
  if (subscription) {
2118
2311
  const callbacks = subscription.callbacks;
2119
2312
  callbacks.delete(callbackId);
2120
2313
  if (callbacks.size === 0) {
2314
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2121
2315
  this.collectionSubscriptions.delete(subscriptionKey);
2122
2316
  this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
2123
2317
  if (this.isConnected && this.ws) this.sendMessage({
@@ -2129,6 +2323,7 @@ var RebaseWebSocketClient = class {
2129
2323
  };
2130
2324
  }
2131
2325
  listenOne(props, onUpdate, onError) {
2326
+ this.ensureConnected();
2132
2327
  const subscriptionKey = this.createSingleSubscriptionKey(props);
2133
2328
  const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
2134
2329
  const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
@@ -2144,9 +2339,12 @@ var RebaseWebSocketClient = class {
2144
2339
  console.error("Error in row subscription callback:", error);
2145
2340
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
2146
2341
  }
2342
+ else if (!existingSubscription.subscribeInFlight) this.sendEntitySubscribe(subscriptionKey);
2147
2343
  return () => {
2148
2344
  callbackMap.delete(callbackId);
2149
2345
  if (callbackMap.size === 0) {
2346
+ if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
2347
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
2150
2348
  this.singleSubscriptions.delete(subscriptionKey);
2151
2349
  this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
2152
2350
  if (this.isConnected && this.ws) this.sendMessage({
@@ -2168,15 +2366,7 @@ var RebaseWebSocketClient = class {
2168
2366
  props
2169
2367
  });
2170
2368
  this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
2171
- this.sendMessage({
2172
- type: "subscribe_one",
2173
- payload: {
2174
- ...props,
2175
- subscriptionId: backendSubscriptionId
2176
- }
2177
- }).catch((error) => {
2178
- if (onError) onError(error);
2179
- });
2369
+ this.sendEntitySubscribe(subscriptionKey);
2180
2370
  return () => {
2181
2371
  const subscription = this.singleSubscriptions.get(subscriptionKey);
2182
2372
  if (subscription) {
@@ -2194,6 +2384,157 @@ var RebaseWebSocketClient = class {
2194
2384
  };
2195
2385
  }
2196
2386
  /**
2387
+ * Send a `subscribe_collection` for an already-registered subscription and
2388
+ * arm its watchdog.
2389
+ *
2390
+ * Every path that registers a collection subscription goes through here, so
2391
+ * that a subscribe which never lands — a rejected send, or a server that
2392
+ * never answers — always ends up in `failCollectionSubscription` rather than
2393
+ * leaving the entry parked with `isInitialDataReceived === false` forever.
2394
+ */
2395
+ sendCollectionSubscribe(subscriptionKey) {
2396
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
2397
+ if (!subscription) return;
2398
+ const backendSubscriptionId = subscription.backendSubscriptionId;
2399
+ subscription.subscribeInFlight = true;
2400
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2401
+ subscription.subscribeTimeout = void 0;
2402
+ if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
2403
+ this.sendMessage({
2404
+ type: "subscribe_collection",
2405
+ payload: {
2406
+ ...subscription.props,
2407
+ subscriptionId: backendSubscriptionId
2408
+ }
2409
+ }).catch((error) => {
2410
+ const current = this.collectionSubscriptions.get(subscriptionKey);
2411
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
2412
+ this.failCollectionSubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
2413
+ });
2414
+ }
2415
+ /** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
2416
+ sendEntitySubscribe(subscriptionKey) {
2417
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
2418
+ if (!subscription) return;
2419
+ const backendSubscriptionId = subscription.backendSubscriptionId;
2420
+ subscription.subscribeInFlight = true;
2421
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2422
+ subscription.subscribeTimeout = void 0;
2423
+ if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
2424
+ this.sendMessage({
2425
+ type: "subscribe_one",
2426
+ payload: {
2427
+ ...subscription.props,
2428
+ subscriptionId: backendSubscriptionId
2429
+ }
2430
+ }).catch((error) => {
2431
+ const current = this.singleSubscriptions.get(subscriptionKey);
2432
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
2433
+ this.failEntitySubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
2434
+ });
2435
+ }
2436
+ /**
2437
+ * Report a subscribe failure to every listener and drop the registration.
2438
+ *
2439
+ * Dropping it is the point: the callbacks stay live (their components are
2440
+ * still mounted and have been told), but the next `listenCollection` for
2441
+ * these params finds no entry and issues a fresh subscribe instead of
2442
+ * silently attaching to a dead one.
2443
+ */
2444
+ failCollectionSubscription(subscriptionKey, error) {
2445
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
2446
+ if (!subscription) return;
2447
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2448
+ subscription.subscribeInFlight = false;
2449
+ this.collectionSubscriptions.delete(subscriptionKey);
2450
+ this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
2451
+ subscription.callbacks.forEach((callback) => {
2452
+ if (callback.onError) try {
2453
+ callback.onError(error);
2454
+ } catch (callbackError) {
2455
+ console.error("Error in collection subscription error callback:", callbackError);
2456
+ }
2457
+ });
2458
+ }
2459
+ /** The `listenOne` counterpart of {@link failCollectionSubscription}. */
2460
+ failEntitySubscription(subscriptionKey, error) {
2461
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
2462
+ if (!subscription) return;
2463
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2464
+ subscription.subscribeInFlight = false;
2465
+ this.singleSubscriptions.delete(subscriptionKey);
2466
+ this.backendToEntityKey.delete(subscription.backendSubscriptionId);
2467
+ subscription.callbacks.forEach((callback) => {
2468
+ if (callback.onError) try {
2469
+ callback.onError(error);
2470
+ } catch (callbackError) {
2471
+ console.error("Error in row subscription error callback:", callbackError);
2472
+ }
2473
+ });
2474
+ }
2475
+ /**
2476
+ * Stop the watchdogs without failing anything — used when the socket drops,
2477
+ * since the reconnect path re-subscribes everything anyway and a watchdog
2478
+ * firing mid-reconnect would tear down healthy subscriptions.
2479
+ */
2480
+ suspendSubscribeWatchdogs() {
2481
+ for (const sub of this.collectionSubscriptions.values()) {
2482
+ if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
2483
+ sub.subscribeTimeout = void 0;
2484
+ sub.subscribeInFlight = false;
2485
+ }
2486
+ for (const sub of this.singleSubscriptions.values()) {
2487
+ if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
2488
+ sub.subscribeTimeout = void 0;
2489
+ sub.subscribeInFlight = false;
2490
+ }
2491
+ }
2492
+ /**
2493
+ * Arm watchdogs for subscribes that were requested while offline and have
2494
+ * just been flushed to the socket. Their timers were deliberately not set at
2495
+ * request time, so without this they would have no timeout at all.
2496
+ */
2497
+ armPendingSubscribeWatchdogs() {
2498
+ for (const [key, sub] of this.collectionSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
2499
+ for (const [key, sub] of this.singleSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
2500
+ }
2501
+ sendCollectionSubscribeWatchdog(subscriptionKey) {
2502
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
2503
+ if (!subscription) return;
2504
+ const backendSubscriptionId = subscription.backendSubscriptionId;
2505
+ subscription.subscribeTimeout = setTimeout(() => {
2506
+ const current = this.collectionSubscriptions.get(subscriptionKey);
2507
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
2508
+ if (!current.subscribeInFlight) return;
2509
+ this.failCollectionSubscription(subscriptionKey, new RebaseApiError$1("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
2510
+ }, this.subscriptionTimeoutMs);
2511
+ }
2512
+ sendEntitySubscribeWatchdog(subscriptionKey) {
2513
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
2514
+ if (!subscription) return;
2515
+ const backendSubscriptionId = subscription.backendSubscriptionId;
2516
+ subscription.subscribeTimeout = setTimeout(() => {
2517
+ const current = this.singleSubscriptions.get(subscriptionKey);
2518
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
2519
+ if (!current.subscribeInFlight) return;
2520
+ this.failEntitySubscription(subscriptionKey, new RebaseApiError$1("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
2521
+ }, this.subscriptionTimeoutMs);
2522
+ }
2523
+ /**
2524
+ * Fail every subscription that never received data. Called when reconnection
2525
+ * is given up on, so views surface an error instead of spinning forever.
2526
+ */
2527
+ failAllPendingSubscriptions(error) {
2528
+ for (const key of [...this.collectionSubscriptions.keys()]) {
2529
+ const sub = this.collectionSubscriptions.get(key);
2530
+ if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
2531
+ }
2532
+ for (const key of [...this.singleSubscriptions.keys()]) {
2533
+ const sub = this.singleSubscriptions.get(key);
2534
+ if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
2535
+ }
2536
+ }
2537
+ /**
2197
2538
  * Re-send all active subscriptions to the backend after a reconnect.
2198
2539
  * The server wipes subscription state when a client disconnects, so
2199
2540
  * we need to re-register everything to resume receiving updates.
@@ -2206,15 +2547,7 @@ var RebaseWebSocketClient = class {
2206
2547
  sub.backendSubscriptionId = newBackendId;
2207
2548
  this.backendToCollectionKey.delete(oldBackendId);
2208
2549
  this.backendToCollectionKey.set(newBackendId, key);
2209
- this.sendMessage({
2210
- type: "subscribe_collection",
2211
- payload: {
2212
- ...sub.props,
2213
- subscriptionId: newBackendId
2214
- }
2215
- }).catch((error) => {
2216
- console.error("[WS] Failed to re-subscribe collection:", key, error);
2217
- });
2550
+ this.sendCollectionSubscribe(key);
2218
2551
  }
2219
2552
  for (const [key, sub] of this.singleSubscriptions.entries()) {
2220
2553
  const oldBackendId = sub.backendSubscriptionId;
@@ -2222,15 +2555,7 @@ var RebaseWebSocketClient = class {
2222
2555
  sub.backendSubscriptionId = newBackendId;
2223
2556
  this.backendToEntityKey.delete(oldBackendId);
2224
2557
  this.backendToEntityKey.set(newBackendId, key);
2225
- this.sendMessage({
2226
- type: "subscribe_one",
2227
- payload: {
2228
- ...sub.props,
2229
- subscriptionId: newBackendId
2230
- }
2231
- }).catch((error) => {
2232
- console.error("[WS] Failed to re-subscribe row:", key, error);
2233
- });
2558
+ this.sendEntitySubscribe(key);
2234
2559
  }
2235
2560
  }
2236
2561
  createCollectionSubscriptionKey(props) {
@@ -2257,6 +2582,379 @@ var RebaseWebSocketClient = class {
2257
2582
  }
2258
2583
  };
2259
2584
  //#endregion
2585
+ //#region src/realtime-channel.ts
2586
+ /**
2587
+ * Re-send presence comfortably inside the server's 30s expiry.
2588
+ *
2589
+ * Two-thirds of the window: one lost heartbeat still leaves time for the next
2590
+ * before the entry is reaped, so a single dropped frame is not a disappearance.
2591
+ */
2592
+ var PRESENCE_HEARTBEAT_MS = 2e4;
2593
+ /**
2594
+ * How long live messages are held back waiting for a catch-up response.
2595
+ *
2596
+ * Short, because the cost of waiting is visible — on a collaborative document
2597
+ * this is a stall in everyone else's edits appearing. Long enough that a slow
2598
+ * replay of a busy channel is not abandoned needlessly.
2599
+ */
2600
+ var CATCH_UP_TIMEOUT_MS = 1e4;
2601
+ var RebaseRealtimeChannel = class {
2602
+ name;
2603
+ transport;
2604
+ presenceHandlers = /* @__PURE__ */ new Set();
2605
+ broadcastHandlers = /* @__PURE__ */ new Set();
2606
+ unsubscribers = [];
2607
+ /** Last known roster, kept so handlers always get a full picture. */
2608
+ presences = {};
2609
+ /** What this client last tracked, replayed on reconnect and heartbeat. */
2610
+ trackedState = null;
2611
+ heartbeat = null;
2612
+ joined = false;
2613
+ /** Whether this handle asks the server to replay missed messages. */
2614
+ wantsHistory;
2615
+ /**
2616
+ * Highest sequence number delivered to handlers so far.
2617
+ *
2618
+ * This is the resume point sent as `sinceSeq`, and the watermark that makes
2619
+ * replay idempotent: catch-up ranges overlap with what arrived live, and
2620
+ * anything at or below this has already been seen.
2621
+ */
2622
+ lastSeq = 0;
2623
+ /**
2624
+ * Live messages that arrived while a catch-up was in flight.
2625
+ *
2626
+ * Without this they would be delivered ahead of the older messages being
2627
+ * fetched, and — worse — would advance {@link lastSeq} past them, so the
2628
+ * catch-up response would then be discarded as already-seen and those
2629
+ * messages would be lost for good. Held here and flushed, in order, once
2630
+ * the replay lands.
2631
+ */
2632
+ pendingLive = [];
2633
+ catchUpInFlight = false;
2634
+ /**
2635
+ * Deadline for a catch-up response.
2636
+ *
2637
+ * Buffering live messages is only safe because the wait is bounded. A
2638
+ * catch-up frame that never arrives — a server that dropped it, a socket
2639
+ * that died between request and reply — would otherwise leave the channel
2640
+ * silently holding every subsequent edit forever, which is a worse failure
2641
+ * than the one replay was added to fix.
2642
+ */
2643
+ catchUpTimeout = null;
2644
+ /**
2645
+ * Callers of {@link history} awaiting the next `channel_history` frame.
2646
+ *
2647
+ * These frames are addressed by channel rather than by request id, so they
2648
+ * are matched in arrival order. Requests on one channel are serialized by
2649
+ * the socket, so FIFO is the right correlation here.
2650
+ */
2651
+ historyWaiters = [];
2652
+ constructor(name, transport, options = {}) {
2653
+ this.name = name;
2654
+ this.transport = transport;
2655
+ this.wantsHistory = options.history ?? false;
2656
+ }
2657
+ /**
2658
+ * Turn on catch-up for a handle that was created without it.
2659
+ *
2660
+ * The client hands back the same channel object for a given name, so a
2661
+ * later `channel(name, { history: true })` has no new object to configure —
2662
+ * it upgrades this one instead. Idempotent, and never downgrades: one
2663
+ * caller asking for history must not be switched off by another that did
2664
+ * not ask.
2665
+ */
2666
+ enableHistory() {
2667
+ if (this.wantsHistory) return;
2668
+ this.wantsHistory = true;
2669
+ if (this.joined) this.requestHistory();
2670
+ }
2671
+ /**
2672
+ * Join the channel and ask for the current roster.
2673
+ *
2674
+ * Called automatically by `track`, `broadcast`, `onPresence` and
2675
+ * `onBroadcast`; calling it directly is only needed to start receiving
2676
+ * before there is anything to send.
2677
+ */
2678
+ /**
2679
+ * Send a channel message.
2680
+ *
2681
+ * Every channel message is read by the server out of a `payload` envelope
2682
+ * (`payload?.channel`, `payload?.state`, `payload?.event`). Sending those
2683
+ * fields flat does not error: `payload?.channel` simply reads as
2684
+ * `undefined`, so the client is registered into channel `undefined` with
2685
+ * empty state, and the echo comes back with no `channel` for
2686
+ * `onChannelMessage` to match — presence and broadcast both go quiet with
2687
+ * nothing logged. Funnelled through one place so a new message type cannot
2688
+ * reintroduce that.
2689
+ */
2690
+ send(type, fields = {}) {
2691
+ return this.transport.sendMessage({
2692
+ type,
2693
+ payload: {
2694
+ channel: this.name,
2695
+ ...fields
2696
+ }
2697
+ });
2698
+ }
2699
+ async join() {
2700
+ if (this.joined) return;
2701
+ this.joined = true;
2702
+ this.unsubscribers.push(this.transport.onChannelMessage(this.name, (message) => this.handle(message)));
2703
+ this.unsubscribers.push(this.transport.onReconnect(() => {
2704
+ this.rejoin();
2705
+ }));
2706
+ await this.send("join_channel");
2707
+ await this.send("presence_state");
2708
+ if (this.wantsHistory) await this.requestHistory();
2709
+ }
2710
+ async rejoin() {
2711
+ try {
2712
+ await this.send("join_channel");
2713
+ await this.send("presence_state");
2714
+ if (this.trackedState) await this.send("presence_track", { state: this.trackedState });
2715
+ if (this.wantsHistory) await this.requestHistory();
2716
+ } catch {}
2717
+ }
2718
+ /**
2719
+ * Ask the server for everything after {@link lastSeq}.
2720
+ *
2721
+ * Live messages are buffered from here until the answer arrives — see
2722
+ * {@link pendingLive}.
2723
+ */
2724
+ async requestHistory(limit) {
2725
+ this.catchUpInFlight = true;
2726
+ if (this.catchUpTimeout) clearTimeout(this.catchUpTimeout);
2727
+ this.catchUpTimeout = setTimeout(() => this.abandonCatchUp(), CATCH_UP_TIMEOUT_MS);
2728
+ this.catchUpTimeout.unref?.();
2729
+ try {
2730
+ await this.send("channel_history", {
2731
+ sinceSeq: this.lastSeq,
2732
+ ...limit !== void 0 ? { limit } : {}
2733
+ });
2734
+ } catch {
2735
+ this.abandonCatchUp();
2736
+ }
2737
+ }
2738
+ /**
2739
+ * Give up waiting for a catch-up and release what was held back.
2740
+ *
2741
+ * The buffered messages are still the freshest thing this client has, so
2742
+ * they are delivered rather than dropped. Callers of {@link history} are
2743
+ * answered with `retained: false` — accurate in the sense that matters:
2744
+ * this client has no history to work from and has to resync.
2745
+ */
2746
+ abandonCatchUp() {
2747
+ if (this.catchUpTimeout) {
2748
+ clearTimeout(this.catchUpTimeout);
2749
+ this.catchUpTimeout = null;
2750
+ }
2751
+ if (!this.catchUpInFlight) return;
2752
+ this.catchUpInFlight = false;
2753
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
2754
+ messages: [],
2755
+ retained: false
2756
+ });
2757
+ this.flushPendingLive();
2758
+ }
2759
+ /**
2760
+ * Publish this client's presence state, and keep publishing it.
2761
+ *
2762
+ * Calling `track` again replaces the state (and restarts the heartbeat),
2763
+ * which is how you update e.g. a cursor position.
2764
+ */
2765
+ async track(state) {
2766
+ await this.join();
2767
+ this.trackedState = state;
2768
+ await this.send("presence_track", { state });
2769
+ if (!this.heartbeat) {
2770
+ this.heartbeat = setInterval(() => {
2771
+ if (!this.trackedState) return;
2772
+ this.send("presence_track", { state: this.trackedState }).catch(() => {});
2773
+ }, PRESENCE_HEARTBEAT_MS);
2774
+ this.heartbeat.unref?.();
2775
+ }
2776
+ }
2777
+ /** Stop publishing presence, without leaving the channel. */
2778
+ async untrack() {
2779
+ this.stopHeartbeat();
2780
+ this.trackedState = null;
2781
+ if (this.joined) await this.send("presence_untrack");
2782
+ }
2783
+ /**
2784
+ * Observe the roster. The handler fires immediately with what is already
2785
+ * known, then on every change.
2786
+ */
2787
+ onPresence(handler) {
2788
+ this.presenceHandlers.add(handler);
2789
+ this.join();
2790
+ if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
2791
+ return () => this.presenceHandlers.delete(handler);
2792
+ }
2793
+ /** Send a broadcast. The sender does not receive its own message. */
2794
+ async broadcast(event, payload) {
2795
+ await this.join();
2796
+ await this.send("broadcast", {
2797
+ event,
2798
+ payload
2799
+ });
2800
+ }
2801
+ onBroadcast(eventOrHandler, maybeHandler) {
2802
+ const wrapped = typeof eventOrHandler === "string" ? (e) => {
2803
+ if (e.event === eventOrHandler) maybeHandler(e.payload);
2804
+ } : eventOrHandler;
2805
+ this.broadcastHandlers.add(wrapped);
2806
+ this.join();
2807
+ return () => this.broadcastHandlers.delete(wrapped);
2808
+ }
2809
+ /**
2810
+ * The last sequence number this channel has delivered.
2811
+ *
2812
+ * Zero on a channel that retains nothing. Persist it if you want catch-up
2813
+ * to survive a page reload as well as a reconnect, and pass it back via
2814
+ * {@link history}.
2815
+ */
2816
+ get sequence() {
2817
+ return this.lastSeq;
2818
+ }
2819
+ /**
2820
+ * Fetch retained messages explicitly, instead of waiting for join or
2821
+ * reconnect to do it.
2822
+ *
2823
+ * Defaults to resuming from {@link sequence}. Messages are delivered to
2824
+ * `onBroadcast` handlers as usual — the returned value is for callers that
2825
+ * want to inspect the batch, or to learn from `retained` that the channel
2826
+ * keeps no history at all.
2827
+ */
2828
+ async history(options = {}) {
2829
+ await this.join();
2830
+ if (options.sinceSeq !== void 0) this.lastSeq = options.sinceSeq;
2831
+ const result = new Promise((resolve) => {
2832
+ this.historyWaiters.push(resolve);
2833
+ });
2834
+ await this.requestHistory(options.limit);
2835
+ return result;
2836
+ }
2837
+ /** Leave the channel and release every listener and timer. */
2838
+ async leave() {
2839
+ this.stopHeartbeat();
2840
+ this.trackedState = null;
2841
+ this.presences = {};
2842
+ this.presenceHandlers.clear();
2843
+ this.broadcastHandlers.clear();
2844
+ this.lastSeq = 0;
2845
+ this.pendingLive = [];
2846
+ this.catchUpInFlight = false;
2847
+ if (this.catchUpTimeout) {
2848
+ clearTimeout(this.catchUpTimeout);
2849
+ this.catchUpTimeout = null;
2850
+ }
2851
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
2852
+ messages: [],
2853
+ retained: false
2854
+ });
2855
+ for (const off of this.unsubscribers) off();
2856
+ this.unsubscribers = [];
2857
+ if (this.joined) {
2858
+ this.joined = false;
2859
+ await this.send("leave_channel");
2860
+ }
2861
+ }
2862
+ stopHeartbeat() {
2863
+ if (this.heartbeat) {
2864
+ clearInterval(this.heartbeat);
2865
+ this.heartbeat = null;
2866
+ }
2867
+ }
2868
+ /** Fold an incoming frame into the roster and fan it out. */
2869
+ handle(message) {
2870
+ switch (message.type) {
2871
+ case "presence_state":
2872
+ this.presences = message.presences ?? {};
2873
+ this.emitPresence();
2874
+ break;
2875
+ case "presence_diff": {
2876
+ const joins = message.joins ?? {};
2877
+ const leaves = message.leaves ?? {};
2878
+ for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
2879
+ for (const id of Object.keys(leaves)) delete this.presences[id];
2880
+ this.emitPresence({
2881
+ joins,
2882
+ leaves
2883
+ });
2884
+ break;
2885
+ }
2886
+ case "broadcast": {
2887
+ const seq = typeof message.seq === "number" ? message.seq : void 0;
2888
+ const event = {
2889
+ event: message.event,
2890
+ payload: message.payload,
2891
+ ...seq !== void 0 ? { seq } : {}
2892
+ };
2893
+ if (seq === void 0) {
2894
+ this.deliver(event);
2895
+ break;
2896
+ }
2897
+ if (this.catchUpInFlight) {
2898
+ this.pendingLive.push(event);
2899
+ break;
2900
+ }
2901
+ if (seq <= this.lastSeq) break;
2902
+ this.lastSeq = seq;
2903
+ this.deliver(event);
2904
+ break;
2905
+ }
2906
+ case "channel_history": {
2907
+ this.catchUpInFlight = false;
2908
+ if (this.catchUpTimeout) {
2909
+ clearTimeout(this.catchUpTimeout);
2910
+ this.catchUpTimeout = null;
2911
+ }
2912
+ const entries = message.messages ?? [];
2913
+ const retained = message.retained === true;
2914
+ const latestSeq = typeof message.latestSeq === "number" ? message.latestSeq : void 0;
2915
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
2916
+ messages: entries,
2917
+ retained,
2918
+ latestSeq
2919
+ });
2920
+ for (const entry of entries) {
2921
+ if (entry.seq <= this.lastSeq) continue;
2922
+ this.lastSeq = entry.seq;
2923
+ this.deliver({
2924
+ event: entry.event,
2925
+ payload: entry.payload,
2926
+ seq: entry.seq,
2927
+ replayed: true
2928
+ });
2929
+ }
2930
+ this.flushPendingLive();
2931
+ break;
2932
+ }
2933
+ }
2934
+ }
2935
+ /** Deliver everything held back during a catch-up, in sequence order. */
2936
+ flushPendingLive() {
2937
+ if (this.pendingLive.length === 0) return;
2938
+ const buffered = this.pendingLive.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
2939
+ this.pendingLive = [];
2940
+ for (const event of buffered) {
2941
+ const seq = event.seq;
2942
+ if (seq !== void 0) {
2943
+ if (seq <= this.lastSeq) continue;
2944
+ this.lastSeq = seq;
2945
+ }
2946
+ this.deliver(event);
2947
+ }
2948
+ }
2949
+ deliver(event) {
2950
+ for (const handler of [...this.broadcastHandlers]) handler(event);
2951
+ }
2952
+ emitPresence(diff) {
2953
+ const snapshot = { ...this.presences };
2954
+ for (const handler of this.presenceHandlers) handler(snapshot, diff);
2955
+ }
2956
+ };
2957
+ //#endregion
2260
2958
  //#region src/index.ts
2261
2959
  /**
2262
2960
  * Derive a WebSocket URL from an HTTP base URL.
@@ -2284,6 +2982,7 @@ function createRebaseClient(options) {
2284
2982
  const auth = createAuth(transport, options.auth);
2285
2983
  const admin = createAdmin(transport, options.admin);
2286
2984
  const cron = createCron(transport, options.cron);
2985
+ const backups = createBackups(transport);
2287
2986
  const apiKeys = createApiKeys(transport, options.apiKeys);
2288
2987
  const storage = createStorage(transport);
2289
2988
  const functions = createFunctionsClient(transport);
@@ -2304,8 +3003,10 @@ function createRebaseClient(options) {
2304
3003
  });
2305
3004
  return storageSourcesPromise;
2306
3005
  };
2307
- const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
3006
+ const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
2308
3007
  let ws;
3008
+ /** One channel object per name — see `realtime.channel`. */
3009
+ const realtimeChannels = /* @__PURE__ */ new Map();
2309
3010
  if (resolvedWsUrl) {
2310
3011
  ws = new RebaseWebSocketClient({
2311
3012
  websocketUrl: resolvedWsUrl,
@@ -2329,7 +3030,7 @@ function createRebaseClient(options) {
2329
3030
  if (!ws) return;
2330
3031
  if (event === "SIGNED_OUT") ws.disconnect();
2331
3032
  else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
2332
- if (session?.accessToken) ws.authenticate(session.accessToken).catch(console.warn);
3033
+ if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
2333
3034
  }
2334
3035
  });
2335
3036
  }
@@ -2408,6 +3109,7 @@ function createRebaseClient(options) {
2408
3109
  auth,
2409
3110
  admin,
2410
3111
  cron,
3112
+ backups,
2411
3113
  apiKeys,
2412
3114
  functions,
2413
3115
  storage,
@@ -2415,6 +3117,36 @@ function createRebaseClient(options) {
2415
3117
  createStorageSource,
2416
3118
  fetchStorageSources,
2417
3119
  ws,
3120
+ realtime: {
3121
+ /**
3122
+ * Join a broadcast/presence channel.
3123
+ *
3124
+ * Repeated calls with the same name return the same channel, so
3125
+ * separate components can attach handlers without each opening its
3126
+ * own membership — and `leave()` from one would otherwise silently
3127
+ * cut off the others.
3128
+ */
3129
+ channel: (name, options) => {
3130
+ if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
3131
+ let existing = realtimeChannels.get(name);
3132
+ if (!existing) {
3133
+ existing = new RebaseRealtimeChannel(name, ws, options);
3134
+ realtimeChannels.set(name, existing);
3135
+ } else if (options?.history) existing.enableHistory();
3136
+ return existing;
3137
+ } },
3138
+ /**
3139
+ * Release the realtime socket and its reconnect timer.
3140
+ *
3141
+ * Until this returns, the open socket keeps the Node event loop alive
3142
+ * and the process will not exit on its own. Safe to call when realtime
3143
+ * was never started, and safe to call twice.
3144
+ */
3145
+ close: () => {
3146
+ for (const channel of realtimeChannels.values()) channel.leave();
3147
+ realtimeChannels.clear();
3148
+ ws?.disconnect(true);
3149
+ },
2418
3150
  setToken: transport.setToken,
2419
3151
  setAuthTokenGetter: transport.setAuthTokenGetter,
2420
3152
  setOnUnauthorized: transport.setOnUnauthorized,
@@ -2433,6 +3165,6 @@ function createRebaseClient(options) {
2433
3165
  };
2434
3166
  }
2435
3167
  //#endregion
2436
- export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseWebSocketClient, and, cond, createCookieStorage, createMemoryStorage, createRebaseClient, or };
3168
+ export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, or };
2437
3169
 
2438
3170
  //# sourceMappingURL=index.es.js.map