@rebasepro/client 0.9.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
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) {
@@ -869,6 +869,32 @@ function createCron(transport, options) {
869
869
  };
870
870
  }
871
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
872
898
  //#region src/api-keys.ts
873
899
  /**
874
900
  * Creates a client for managing API keys via the admin routes.
@@ -1009,7 +1035,7 @@ var SDKQueryBuilder = class {
1009
1035
  * Listen to realtime updates matching this query.
1010
1036
  */
1011
1037
  listen(onUpdate, onError) {
1012
- if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl.");
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.");
1013
1039
  return this.collection.listen(this.params, onUpdate, onError);
1014
1040
  }
1015
1041
  };
@@ -1044,6 +1070,17 @@ function createCollectionClient(transport, slug, ws) {
1044
1070
  body: JSON.stringify(body)
1045
1071
  });
1046
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 || [];
1083
+ },
1047
1084
  async update(id, data) {
1048
1085
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
1049
1086
  method: "PUT",
@@ -1360,9 +1397,11 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
1360
1397
  function extractMessageError(message) {
1361
1398
  const payload = message.payload;
1362
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;
1363
1402
  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
1403
+ errorMessage: typeof errorMessage === "string" ? errorMessage : errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage),
1404
+ errorCode
1366
1405
  };
1367
1406
  }
1368
1407
  /**
@@ -1371,7 +1410,7 @@ function extractMessageError(message) {
1371
1410
  * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
1372
1411
  * manages this internally (exposed as `client.ws`, typed by the minimal
1373
1412
  * `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
1374
- * package root only because the `@rebasepro/client-postgresql` driver
1413
+ * package root only because the `@rebasepro/client-postgres` driver
1375
1414
  * instantiates it directly; its surface may change without a major bump.
1376
1415
  */
1377
1416
  var RebaseWebSocketClient = class {
@@ -1380,6 +1419,23 @@ var RebaseWebSocketClient = class {
1380
1419
  getAuthToken;
1381
1420
  subscriptions = /* @__PURE__ */ new Map();
1382
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
+ }
1383
1439
  on(event, cb) {
1384
1440
  if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
1385
1441
  this.listeners.get(event).add(cb);
@@ -1398,6 +1454,7 @@ var RebaseWebSocketClient = class {
1398
1454
  isConnected = false;
1399
1455
  messageQueue = [];
1400
1456
  requestTimeoutMs = 3e4;
1457
+ subscriptionTimeoutMs = 3e4;
1401
1458
  reconnectTimeout = null;
1402
1459
  isAuthenticated = false;
1403
1460
  authPromise = null;
@@ -1503,6 +1560,7 @@ var RebaseWebSocketClient = class {
1503
1560
  this.emit(wasReconnect ? "reconnect" : "connect");
1504
1561
  this.processMessageQueue();
1505
1562
  if (wasReconnect) this.resubscribeAll();
1563
+ this.armPendingSubscribeWatchdogs();
1506
1564
  };
1507
1565
  this.ws.onmessage = (event) => {
1508
1566
  try {
@@ -1517,6 +1575,7 @@ var RebaseWebSocketClient = class {
1517
1575
  this.isConnected = false;
1518
1576
  this.isAuthenticated = false;
1519
1577
  this.authPromise = null;
1578
+ this.suspendSubscribeWatchdogs();
1520
1579
  this.emit("disconnect");
1521
1580
  for (const [reqId, request] of this.pendingRequests.entries()) {
1522
1581
  if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
@@ -1548,6 +1607,7 @@ var RebaseWebSocketClient = class {
1548
1607
  attemptReconnect() {
1549
1608
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
1550
1609
  console.error("Max reconnection attempts reached");
1610
+ this.failAllPendingSubscriptions(new RebaseApiError$1("Connection lost", { code: "CONNECTION_LOST" }));
1551
1611
  return;
1552
1612
  }
1553
1613
  this.reconnectAttempts++;
@@ -1602,29 +1662,18 @@ var RebaseWebSocketClient = class {
1602
1662
  subscription.backendSubscriptionId = newBackendId;
1603
1663
  backendKeyMap.delete(oldBackendId);
1604
1664
  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
- });
1665
+ if (messageType === "subscribe_collection") this.sendCollectionSubscribe(subscriptionKey);
1666
+ else this.sendEntitySubscribe(subscriptionKey);
1667
+ return;
1623
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);
1624
1673
  }).catch((err) => {
1625
- subscription.callbacks.forEach((callback) => {
1626
- if (callback.onError) callback.onError(err);
1627
- });
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);
1628
1677
  });
1629
1678
  }
1630
1679
  handleWebSocketMessage(message) {
@@ -1653,16 +1702,30 @@ var RebaseWebSocketClient = class {
1653
1702
  }
1654
1703
  return;
1655
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
+ }
1656
1714
  if (subscriptionId && type === "collection_update") {
1657
1715
  const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
1658
1716
  if (subscriptionKey) {
1659
1717
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
1660
1718
  if (collectionSub) {
1661
1719
  const incomingRows = message.rows || [];
1662
- const rows = this.mergeRows(collectionSub.latestData, incomingRows);
1720
+ const updatePks = message.pks;
1721
+ if (updatePks) collectionSub.pks = updatePks;
1722
+ const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
1663
1723
  collectionSub.latestData = rows;
1664
1724
  collectionSub.lastUpdated = Date.now();
1665
1725
  collectionSub.isInitialDataReceived = true;
1726
+ if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
1727
+ collectionSub.subscribeTimeout = void 0;
1728
+ collectionSub.subscribeInFlight = false;
1666
1729
  collectionSub.callbacks.forEach((callback) => {
1667
1730
  try {
1668
1731
  callback.onUpdate(rows);
@@ -1681,12 +1744,14 @@ var RebaseWebSocketClient = class {
1681
1744
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
1682
1745
  if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
1683
1746
  const patchWireEntity = message.row ?? null;
1684
- const patchEntityId = message.id;
1747
+ const patchMessage = message;
1748
+ const patchEntityId = patchMessage.id;
1749
+ if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
1685
1750
  const patchRow = patchWireEntity ? patchWireEntity : null;
1686
1751
  let updated;
1687
- if (patchRow === null) 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));
1688
1753
  else {
1689
- const idx = collectionSub.latestData.findIndex((e) => String(e.id) === String(patchRow.id));
1754
+ const idx = collectionSub.latestData.findIndex((e) => this.rowAddress(e, collectionSub.pks) === String(patchEntityId));
1690
1755
  if (idx >= 0) {
1691
1756
  updated = [...collectionSub.latestData];
1692
1757
  updated[idx] = patchRow;
@@ -1716,6 +1781,9 @@ var RebaseWebSocketClient = class {
1716
1781
  entitySub.latestData = row;
1717
1782
  entitySub.lastUpdated = Date.now();
1718
1783
  entitySub.isInitialDataReceived = true;
1784
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
1785
+ entitySub.subscribeTimeout = void 0;
1786
+ entitySub.subscribeInFlight = false;
1719
1787
  entitySub.callbacks.forEach((callback) => {
1720
1788
  try {
1721
1789
  callback.onUpdate(row);
@@ -1737,6 +1805,9 @@ var RebaseWebSocketClient = class {
1737
1805
  this.resubscribeAfterAuthRefresh(message, collectionSub, collectionKey, "collection", this.backendToCollectionKey, "subscribe_collection");
1738
1806
  return;
1739
1807
  }
1808
+ if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
1809
+ collectionSub.subscribeTimeout = void 0;
1810
+ collectionSub.subscribeInFlight = false;
1740
1811
  const { errorMessage, errorCode } = extractMessageError(message);
1741
1812
  const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1742
1813
  collectionSub.callbacks.forEach((callback) => {
@@ -1753,6 +1824,9 @@ var RebaseWebSocketClient = class {
1753
1824
  this.resubscribeAfterAuthRefresh(message, entitySub, entityKey, "row", this.backendToEntityKey, "subscribe_one");
1754
1825
  return;
1755
1826
  }
1827
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
1828
+ entitySub.subscribeTimeout = void 0;
1829
+ entitySub.subscribeInFlight = false;
1756
1830
  const { errorMessage, errorCode } = extractMessageError(message);
1757
1831
  const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1758
1832
  entitySub.callbacks.forEach((callback) => {
@@ -1825,6 +1899,10 @@ var RebaseWebSocketClient = class {
1825
1899
  throw error;
1826
1900
  }
1827
1901
  }
1902
+ /**
1903
+ * Public because `RebaseRealtimeChannel` sends channel frames through it.
1904
+ * Not part of the stable surface — prefer `client.realtime.channel(name)`.
1905
+ */
1828
1906
  sendMessage(message) {
1829
1907
  const queuedMsg = message;
1830
1908
  if (queuedMsg._queuedResolve && queuedMsg._queuedReject) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
@@ -2036,17 +2114,39 @@ var RebaseWebSocketClient = class {
2036
2114
  return val;
2037
2115
  }
2038
2116
  /**
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
+ /**
2039
2135
  * Merge incoming rows with cached data, preserving cached references
2040
2136
  * for rows whose values haven't changed. This avoids unnecessary
2041
2137
  * React re-renders when the server refetches all rows but most
2042
2138
  * haven't actually changed.
2043
2139
  */
2044
- mergeRows(cached, incoming) {
2140
+ mergeRows(cached, incoming, pks) {
2045
2141
  if (!cached || cached.length === 0) return incoming;
2046
2142
  const cachedById = /* @__PURE__ */ new Map();
2047
- for (const row of cached) cachedById.set(row.id, row);
2143
+ for (const row of cached) {
2144
+ const address = this.rowAddress(row, pks);
2145
+ if (address !== void 0) cachedById.set(address, row);
2146
+ }
2048
2147
  return incoming.map((incomingRow) => {
2049
- const cachedRow = cachedById.get(incomingRow.id);
2148
+ const address = this.rowAddress(incomingRow, pks);
2149
+ const cachedRow = address === void 0 ? void 0 : cachedById.get(address);
2050
2150
  if (!cachedRow) return incomingRow;
2051
2151
  const normCached = this.normalizeForComparison(cachedRow);
2052
2152
  const normIncoming = this.normalizeForComparison(incomingRow);
@@ -2058,7 +2158,7 @@ var RebaseWebSocketClient = class {
2058
2158
  cached: normCached[key],
2059
2159
  incoming: normIncoming[key]
2060
2160
  };
2061
- console.debug(`[RebaseWS] Row ${incomingRow.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
2161
+ console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
2062
2162
  }
2063
2163
  return incomingRow;
2064
2164
  });
@@ -2079,9 +2179,12 @@ var RebaseWebSocketClient = class {
2079
2179
  console.error("Error in collection subscription callback:", error);
2080
2180
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
2081
2181
  }
2182
+ else if (!existingSubscription.subscribeInFlight) this.sendCollectionSubscribe(subscriptionKey);
2082
2183
  return () => {
2083
2184
  callbackMap.delete(callbackId);
2084
2185
  if (callbackMap.size === 0) {
2186
+ if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
2187
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
2085
2188
  this.collectionSubscriptions.delete(subscriptionKey);
2086
2189
  this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
2087
2190
  if (this.isConnected && this.ws) this.sendMessage({
@@ -2103,21 +2206,14 @@ var RebaseWebSocketClient = class {
2103
2206
  props
2104
2207
  });
2105
2208
  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
- });
2209
+ this.sendCollectionSubscribe(subscriptionKey);
2115
2210
  return () => {
2116
2211
  const subscription = this.collectionSubscriptions.get(subscriptionKey);
2117
2212
  if (subscription) {
2118
2213
  const callbacks = subscription.callbacks;
2119
2214
  callbacks.delete(callbackId);
2120
2215
  if (callbacks.size === 0) {
2216
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2121
2217
  this.collectionSubscriptions.delete(subscriptionKey);
2122
2218
  this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
2123
2219
  if (this.isConnected && this.ws) this.sendMessage({
@@ -2144,9 +2240,12 @@ var RebaseWebSocketClient = class {
2144
2240
  console.error("Error in row subscription callback:", error);
2145
2241
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
2146
2242
  }
2243
+ else if (!existingSubscription.subscribeInFlight) this.sendEntitySubscribe(subscriptionKey);
2147
2244
  return () => {
2148
2245
  callbackMap.delete(callbackId);
2149
2246
  if (callbackMap.size === 0) {
2247
+ if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
2248
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
2150
2249
  this.singleSubscriptions.delete(subscriptionKey);
2151
2250
  this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
2152
2251
  if (this.isConnected && this.ws) this.sendMessage({
@@ -2168,15 +2267,7 @@ var RebaseWebSocketClient = class {
2168
2267
  props
2169
2268
  });
2170
2269
  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
- });
2270
+ this.sendEntitySubscribe(subscriptionKey);
2180
2271
  return () => {
2181
2272
  const subscription = this.singleSubscriptions.get(subscriptionKey);
2182
2273
  if (subscription) {
@@ -2194,6 +2285,157 @@ var RebaseWebSocketClient = class {
2194
2285
  };
2195
2286
  }
2196
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
+ /**
2197
2439
  * Re-send all active subscriptions to the backend after a reconnect.
2198
2440
  * The server wipes subscription state when a client disconnects, so
2199
2441
  * we need to re-register everything to resume receiving updates.
@@ -2206,15 +2448,7 @@ var RebaseWebSocketClient = class {
2206
2448
  sub.backendSubscriptionId = newBackendId;
2207
2449
  this.backendToCollectionKey.delete(oldBackendId);
2208
2450
  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
- });
2451
+ this.sendCollectionSubscribe(key);
2218
2452
  }
2219
2453
  for (const [key, sub] of this.singleSubscriptions.entries()) {
2220
2454
  const oldBackendId = sub.backendSubscriptionId;
@@ -2222,15 +2456,7 @@ var RebaseWebSocketClient = class {
2222
2456
  sub.backendSubscriptionId = newBackendId;
2223
2457
  this.backendToEntityKey.delete(oldBackendId);
2224
2458
  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
- });
2459
+ this.sendEntitySubscribe(key);
2234
2460
  }
2235
2461
  }
2236
2462
  createCollectionSubscriptionKey(props) {
@@ -2257,6 +2483,190 @@ var RebaseWebSocketClient = class {
2257
2483
  }
2258
2484
  };
2259
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);
2667
+ }
2668
+ };
2669
+ //#endregion
2260
2670
  //#region src/index.ts
2261
2671
  /**
2262
2672
  * Derive a WebSocket URL from an HTTP base URL.
@@ -2284,6 +2694,7 @@ function createRebaseClient(options) {
2284
2694
  const auth = createAuth(transport, options.auth);
2285
2695
  const admin = createAdmin(transport, options.admin);
2286
2696
  const cron = createCron(transport, options.cron);
2697
+ const backups = createBackups(transport);
2287
2698
  const apiKeys = createApiKeys(transport, options.apiKeys);
2288
2699
  const storage = createStorage(transport);
2289
2700
  const functions = createFunctionsClient(transport);
@@ -2304,8 +2715,10 @@ function createRebaseClient(options) {
2304
2715
  });
2305
2716
  return storageSourcesPromise;
2306
2717
  };
2307
- const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
2718
+ const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
2308
2719
  let ws;
2720
+ /** One channel object per name — see `realtime.channel`. */
2721
+ const realtimeChannels = /* @__PURE__ */ new Map();
2309
2722
  if (resolvedWsUrl) {
2310
2723
  ws = new RebaseWebSocketClient({
2311
2724
  websocketUrl: resolvedWsUrl,
@@ -2408,6 +2821,7 @@ function createRebaseClient(options) {
2408
2821
  auth,
2409
2822
  admin,
2410
2823
  cron,
2824
+ backups,
2411
2825
  apiKeys,
2412
2826
  functions,
2413
2827
  storage,
@@ -2415,6 +2829,36 @@ function createRebaseClient(options) {
2415
2829
  createStorageSource,
2416
2830
  fetchStorageSources,
2417
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
+ },
2418
2862
  setToken: transport.setToken,
2419
2863
  setAuthTokenGetter: transport.setAuthTokenGetter,
2420
2864
  setOnUnauthorized: transport.setOnUnauthorized,
@@ -2433,6 +2877,6 @@ function createRebaseClient(options) {
2433
2877
  };
2434
2878
  }
2435
2879
  //#endregion
2436
- export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseWebSocketClient, and, cond, createCookieStorage, createMemoryStorage, createRebaseClient, or };
2880
+ export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, or };
2437
2881
 
2438
2882
  //# sourceMappingURL=index.es.js.map