@rebasepro/client 0.9.1-canary.1d2d8b5 → 0.9.1-canary.5809e39

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.d.ts CHANGED
@@ -74,11 +74,20 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
74
74
  apiKeys: ReturnType<typeof createApiKeys>;
75
75
  functions: ReturnType<typeof createFunctionsClient>;
76
76
  ws?: RebaseWebSocketClient;
77
+ /**
78
+ * Release the realtime socket and its reconnect timer.
79
+ *
80
+ * An open socket keeps the Node event loop alive, so a script that does not
81
+ * call this will not exit on its own. Safe when realtime was never started
82
+ * (`realtime: false`), and safe to call twice.
83
+ */
84
+ close: () => void;
77
85
  storage: StorageSource;
78
86
  storageRegistry: StorageSourceRegistry;
79
87
  createStorageSource: (storageId: string) => StorageSource;
80
88
  fetchStorageSources: () => Promise<StorageSourceDefinition[]>;
81
89
  call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
90
+ collection: <M extends Record<string, unknown> = Record<string, unknown>>(slug: string) => CollectionClient<M>;
82
91
  data: TypedDataLayer<DB>;
83
92
  };
84
93
  export declare function createRebaseClient<DB = Record<string, unknown>>(options: CreateRebaseClientOptions): CreateRebaseClientResult<DB>;
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) {
@@ -1035,7 +1035,7 @@ var SDKQueryBuilder = class {
1035
1035
  * Listen to realtime updates matching this query.
1036
1036
  */
1037
1037
  listen(onUpdate, onError) {
1038
- 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.");
1039
1039
  return this.collection.listen(this.params, onUpdate, onError);
1040
1040
  }
1041
1041
  };
@@ -1070,6 +1070,17 @@ function createCollectionClient(transport, slug, ws) {
1070
1070
  body: JSON.stringify(body)
1071
1071
  });
1072
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
+ },
1073
1084
  async update(id, data) {
1074
1085
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
1075
1086
  method: "PUT",
@@ -1386,9 +1397,11 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
1386
1397
  function extractMessageError(message) {
1387
1398
  const payload = message.payload;
1388
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;
1389
1402
  return {
1390
- errorMessage: typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error",
1391
- errorCode: typeof errPayload === "object" ? errPayload.code : payload?.code
1403
+ errorMessage: typeof errorMessage === "string" ? errorMessage : errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage),
1404
+ errorCode
1392
1405
  };
1393
1406
  }
1394
1407
  /**
@@ -1424,6 +1437,7 @@ var RebaseWebSocketClient = class {
1424
1437
  isConnected = false;
1425
1438
  messageQueue = [];
1426
1439
  requestTimeoutMs = 3e4;
1440
+ subscriptionTimeoutMs = 3e4;
1427
1441
  reconnectTimeout = null;
1428
1442
  isAuthenticated = false;
1429
1443
  authPromise = null;
@@ -1529,6 +1543,7 @@ var RebaseWebSocketClient = class {
1529
1543
  this.emit(wasReconnect ? "reconnect" : "connect");
1530
1544
  this.processMessageQueue();
1531
1545
  if (wasReconnect) this.resubscribeAll();
1546
+ this.armPendingSubscribeWatchdogs();
1532
1547
  };
1533
1548
  this.ws.onmessage = (event) => {
1534
1549
  try {
@@ -1543,6 +1558,7 @@ var RebaseWebSocketClient = class {
1543
1558
  this.isConnected = false;
1544
1559
  this.isAuthenticated = false;
1545
1560
  this.authPromise = null;
1561
+ this.suspendSubscribeWatchdogs();
1546
1562
  this.emit("disconnect");
1547
1563
  for (const [reqId, request] of this.pendingRequests.entries()) {
1548
1564
  if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
@@ -1574,6 +1590,7 @@ var RebaseWebSocketClient = class {
1574
1590
  attemptReconnect() {
1575
1591
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
1576
1592
  console.error("Max reconnection attempts reached");
1593
+ this.failAllPendingSubscriptions(new RebaseApiError$1("Connection lost", { code: "CONNECTION_LOST" }));
1577
1594
  return;
1578
1595
  }
1579
1596
  this.reconnectAttempts++;
@@ -1628,29 +1645,18 @@ var RebaseWebSocketClient = class {
1628
1645
  subscription.backendSubscriptionId = newBackendId;
1629
1646
  backendKeyMap.delete(oldBackendId);
1630
1647
  backendKeyMap.set(newBackendId, subscriptionKey);
1631
- this.sendMessage({
1632
- type: messageType,
1633
- payload: {
1634
- ...subscription.props,
1635
- subscriptionId: newBackendId
1636
- }
1637
- }).catch((error) => {
1638
- console.error(`[WS] Failed to re-subscribe ${idPrefix} after auth refresh:`, subscriptionKey, error);
1639
- subscription.callbacks.forEach((callback) => {
1640
- if (callback.onError) callback.onError(error);
1641
- });
1642
- });
1643
- } else {
1644
- const { errorMessage, errorCode } = extractMessageError(message);
1645
- const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1646
- subscription.callbacks.forEach((callback) => {
1647
- if (callback.onError) callback.onError(error);
1648
- });
1648
+ if (messageType === "subscribe_collection") this.sendCollectionSubscribe(subscriptionKey);
1649
+ else this.sendEntitySubscribe(subscriptionKey);
1650
+ return;
1649
1651
  }
1652
+ const { errorMessage, errorCode } = extractMessageError(message);
1653
+ const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1654
+ if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
1655
+ else this.failEntitySubscription(subscriptionKey, error);
1650
1656
  }).catch((err) => {
1651
- subscription.callbacks.forEach((callback) => {
1652
- if (callback.onError) callback.onError(err);
1653
- });
1657
+ const error = err instanceof Error ? err : new Error(String(err));
1658
+ if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
1659
+ else this.failEntitySubscription(subscriptionKey, error);
1654
1660
  });
1655
1661
  }
1656
1662
  handleWebSocketMessage(message) {
@@ -1685,10 +1691,15 @@ var RebaseWebSocketClient = class {
1685
1691
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
1686
1692
  if (collectionSub) {
1687
1693
  const incomingRows = message.rows || [];
1688
- const rows = this.mergeRows(collectionSub.latestData, incomingRows);
1694
+ const updatePks = message.pks;
1695
+ if (updatePks) collectionSub.pks = updatePks;
1696
+ const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
1689
1697
  collectionSub.latestData = rows;
1690
1698
  collectionSub.lastUpdated = Date.now();
1691
1699
  collectionSub.isInitialDataReceived = true;
1700
+ if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
1701
+ collectionSub.subscribeTimeout = void 0;
1702
+ collectionSub.subscribeInFlight = false;
1692
1703
  collectionSub.callbacks.forEach((callback) => {
1693
1704
  try {
1694
1705
  callback.onUpdate(rows);
@@ -1707,12 +1718,14 @@ var RebaseWebSocketClient = class {
1707
1718
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
1708
1719
  if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
1709
1720
  const patchWireEntity = message.row ?? null;
1710
- const patchEntityId = message.id;
1721
+ const patchMessage = message;
1722
+ const patchEntityId = patchMessage.id;
1723
+ if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
1711
1724
  const patchRow = patchWireEntity ? patchWireEntity : null;
1712
1725
  let updated;
1713
- if (patchRow === null) updated = collectionSub.latestData.filter((e) => String(e.id) !== String(patchEntityId));
1726
+ if (patchRow === null) updated = collectionSub.latestData.filter((e) => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId));
1714
1727
  else {
1715
- const idx = collectionSub.latestData.findIndex((e) => String(e.id) === String(patchRow.id));
1728
+ const idx = collectionSub.latestData.findIndex((e) => this.rowAddress(e, collectionSub.pks) === String(patchEntityId));
1716
1729
  if (idx >= 0) {
1717
1730
  updated = [...collectionSub.latestData];
1718
1731
  updated[idx] = patchRow;
@@ -1742,6 +1755,9 @@ var RebaseWebSocketClient = class {
1742
1755
  entitySub.latestData = row;
1743
1756
  entitySub.lastUpdated = Date.now();
1744
1757
  entitySub.isInitialDataReceived = true;
1758
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
1759
+ entitySub.subscribeTimeout = void 0;
1760
+ entitySub.subscribeInFlight = false;
1745
1761
  entitySub.callbacks.forEach((callback) => {
1746
1762
  try {
1747
1763
  callback.onUpdate(row);
@@ -1763,6 +1779,9 @@ var RebaseWebSocketClient = class {
1763
1779
  this.resubscribeAfterAuthRefresh(message, collectionSub, collectionKey, "collection", this.backendToCollectionKey, "subscribe_collection");
1764
1780
  return;
1765
1781
  }
1782
+ if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
1783
+ collectionSub.subscribeTimeout = void 0;
1784
+ collectionSub.subscribeInFlight = false;
1766
1785
  const { errorMessage, errorCode } = extractMessageError(message);
1767
1786
  const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1768
1787
  collectionSub.callbacks.forEach((callback) => {
@@ -1779,6 +1798,9 @@ var RebaseWebSocketClient = class {
1779
1798
  this.resubscribeAfterAuthRefresh(message, entitySub, entityKey, "row", this.backendToEntityKey, "subscribe_one");
1780
1799
  return;
1781
1800
  }
1801
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
1802
+ entitySub.subscribeTimeout = void 0;
1803
+ entitySub.subscribeInFlight = false;
1782
1804
  const { errorMessage, errorCode } = extractMessageError(message);
1783
1805
  const error = new RebaseApiError$1(errorMessage, { code: errorCode });
1784
1806
  entitySub.callbacks.forEach((callback) => {
@@ -2062,17 +2084,39 @@ var RebaseWebSocketClient = class {
2062
2084
  return val;
2063
2085
  }
2064
2086
  /**
2087
+ * The address of a row, for matching it against another copy of itself.
2088
+ *
2089
+ * A row is exactly its columns and carries no address, so it is derived
2090
+ * from the key columns the server named — including the ordinary case where
2091
+ * that key is `id`, which the server reports like any other.
2092
+ *
2093
+ * Undefined when there are no keys, which means the server could not
2094
+ * resolve any: such rows genuinely cannot be recognised, and guessing at a
2095
+ * column called `id` would be inventing an identity for a table that has
2096
+ * none.
2097
+ */
2098
+ rowAddress(row, pks) {
2099
+ if (!pks || pks.length === 0) return void 0;
2100
+ const address = buildCompositeId(row, pks);
2101
+ if (!address || address.split(COMPOSITE_ID_SEPARATOR).every((part) => part === "")) return void 0;
2102
+ return address;
2103
+ }
2104
+ /**
2065
2105
  * Merge incoming rows with cached data, preserving cached references
2066
2106
  * for rows whose values haven't changed. This avoids unnecessary
2067
2107
  * React re-renders when the server refetches all rows but most
2068
2108
  * haven't actually changed.
2069
2109
  */
2070
- mergeRows(cached, incoming) {
2110
+ mergeRows(cached, incoming, pks) {
2071
2111
  if (!cached || cached.length === 0) return incoming;
2072
2112
  const cachedById = /* @__PURE__ */ new Map();
2073
- for (const row of cached) cachedById.set(row.id, row);
2113
+ for (const row of cached) {
2114
+ const address = this.rowAddress(row, pks);
2115
+ if (address !== void 0) cachedById.set(address, row);
2116
+ }
2074
2117
  return incoming.map((incomingRow) => {
2075
- const cachedRow = cachedById.get(incomingRow.id);
2118
+ const address = this.rowAddress(incomingRow, pks);
2119
+ const cachedRow = address === void 0 ? void 0 : cachedById.get(address);
2076
2120
  if (!cachedRow) return incomingRow;
2077
2121
  const normCached = this.normalizeForComparison(cachedRow);
2078
2122
  const normIncoming = this.normalizeForComparison(incomingRow);
@@ -2084,7 +2128,7 @@ var RebaseWebSocketClient = class {
2084
2128
  cached: normCached[key],
2085
2129
  incoming: normIncoming[key]
2086
2130
  };
2087
- console.debug(`[RebaseWS] Row ${incomingRow.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
2131
+ console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
2088
2132
  }
2089
2133
  return incomingRow;
2090
2134
  });
@@ -2105,9 +2149,12 @@ var RebaseWebSocketClient = class {
2105
2149
  console.error("Error in collection subscription callback:", error);
2106
2150
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
2107
2151
  }
2152
+ else if (!existingSubscription.subscribeInFlight) this.sendCollectionSubscribe(subscriptionKey);
2108
2153
  return () => {
2109
2154
  callbackMap.delete(callbackId);
2110
2155
  if (callbackMap.size === 0) {
2156
+ if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
2157
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
2111
2158
  this.collectionSubscriptions.delete(subscriptionKey);
2112
2159
  this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
2113
2160
  if (this.isConnected && this.ws) this.sendMessage({
@@ -2129,21 +2176,14 @@ var RebaseWebSocketClient = class {
2129
2176
  props
2130
2177
  });
2131
2178
  this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
2132
- this.sendMessage({
2133
- type: "subscribe_collection",
2134
- payload: {
2135
- ...props,
2136
- subscriptionId: backendSubscriptionId
2137
- }
2138
- }).catch((error) => {
2139
- if (onError) onError(error);
2140
- });
2179
+ this.sendCollectionSubscribe(subscriptionKey);
2141
2180
  return () => {
2142
2181
  const subscription = this.collectionSubscriptions.get(subscriptionKey);
2143
2182
  if (subscription) {
2144
2183
  const callbacks = subscription.callbacks;
2145
2184
  callbacks.delete(callbackId);
2146
2185
  if (callbacks.size === 0) {
2186
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2147
2187
  this.collectionSubscriptions.delete(subscriptionKey);
2148
2188
  this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
2149
2189
  if (this.isConnected && this.ws) this.sendMessage({
@@ -2170,9 +2210,12 @@ var RebaseWebSocketClient = class {
2170
2210
  console.error("Error in row subscription callback:", error);
2171
2211
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
2172
2212
  }
2213
+ else if (!existingSubscription.subscribeInFlight) this.sendEntitySubscribe(subscriptionKey);
2173
2214
  return () => {
2174
2215
  callbackMap.delete(callbackId);
2175
2216
  if (callbackMap.size === 0) {
2217
+ if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
2218
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
2176
2219
  this.singleSubscriptions.delete(subscriptionKey);
2177
2220
  this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
2178
2221
  if (this.isConnected && this.ws) this.sendMessage({
@@ -2194,15 +2237,7 @@ var RebaseWebSocketClient = class {
2194
2237
  props
2195
2238
  });
2196
2239
  this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
2197
- this.sendMessage({
2198
- type: "subscribe_one",
2199
- payload: {
2200
- ...props,
2201
- subscriptionId: backendSubscriptionId
2202
- }
2203
- }).catch((error) => {
2204
- if (onError) onError(error);
2205
- });
2240
+ this.sendEntitySubscribe(subscriptionKey);
2206
2241
  return () => {
2207
2242
  const subscription = this.singleSubscriptions.get(subscriptionKey);
2208
2243
  if (subscription) {
@@ -2220,6 +2255,157 @@ var RebaseWebSocketClient = class {
2220
2255
  };
2221
2256
  }
2222
2257
  /**
2258
+ * Send a `subscribe_collection` for an already-registered subscription and
2259
+ * arm its watchdog.
2260
+ *
2261
+ * Every path that registers a collection subscription goes through here, so
2262
+ * that a subscribe which never lands — a rejected send, or a server that
2263
+ * never answers — always ends up in `failCollectionSubscription` rather than
2264
+ * leaving the entry parked with `isInitialDataReceived === false` forever.
2265
+ */
2266
+ sendCollectionSubscribe(subscriptionKey) {
2267
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
2268
+ if (!subscription) return;
2269
+ const backendSubscriptionId = subscription.backendSubscriptionId;
2270
+ subscription.subscribeInFlight = true;
2271
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2272
+ subscription.subscribeTimeout = void 0;
2273
+ if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
2274
+ this.sendMessage({
2275
+ type: "subscribe_collection",
2276
+ payload: {
2277
+ ...subscription.props,
2278
+ subscriptionId: backendSubscriptionId
2279
+ }
2280
+ }).catch((error) => {
2281
+ const current = this.collectionSubscriptions.get(subscriptionKey);
2282
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
2283
+ this.failCollectionSubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
2284
+ });
2285
+ }
2286
+ /** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
2287
+ sendEntitySubscribe(subscriptionKey) {
2288
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
2289
+ if (!subscription) return;
2290
+ const backendSubscriptionId = subscription.backendSubscriptionId;
2291
+ subscription.subscribeInFlight = true;
2292
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2293
+ subscription.subscribeTimeout = void 0;
2294
+ if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
2295
+ this.sendMessage({
2296
+ type: "subscribe_one",
2297
+ payload: {
2298
+ ...subscription.props,
2299
+ subscriptionId: backendSubscriptionId
2300
+ }
2301
+ }).catch((error) => {
2302
+ const current = this.singleSubscriptions.get(subscriptionKey);
2303
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
2304
+ this.failEntitySubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
2305
+ });
2306
+ }
2307
+ /**
2308
+ * Report a subscribe failure to every listener and drop the registration.
2309
+ *
2310
+ * Dropping it is the point: the callbacks stay live (their components are
2311
+ * still mounted and have been told), but the next `listenCollection` for
2312
+ * these params finds no entry and issues a fresh subscribe instead of
2313
+ * silently attaching to a dead one.
2314
+ */
2315
+ failCollectionSubscription(subscriptionKey, error) {
2316
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
2317
+ if (!subscription) return;
2318
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2319
+ subscription.subscribeInFlight = false;
2320
+ this.collectionSubscriptions.delete(subscriptionKey);
2321
+ this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
2322
+ subscription.callbacks.forEach((callback) => {
2323
+ if (callback.onError) try {
2324
+ callback.onError(error);
2325
+ } catch (callbackError) {
2326
+ console.error("Error in collection subscription error callback:", callbackError);
2327
+ }
2328
+ });
2329
+ }
2330
+ /** The `listenOne` counterpart of {@link failCollectionSubscription}. */
2331
+ failEntitySubscription(subscriptionKey, error) {
2332
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
2333
+ if (!subscription) return;
2334
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
2335
+ subscription.subscribeInFlight = false;
2336
+ this.singleSubscriptions.delete(subscriptionKey);
2337
+ this.backendToEntityKey.delete(subscription.backendSubscriptionId);
2338
+ subscription.callbacks.forEach((callback) => {
2339
+ if (callback.onError) try {
2340
+ callback.onError(error);
2341
+ } catch (callbackError) {
2342
+ console.error("Error in row subscription error callback:", callbackError);
2343
+ }
2344
+ });
2345
+ }
2346
+ /**
2347
+ * Stop the watchdogs without failing anything — used when the socket drops,
2348
+ * since the reconnect path re-subscribes everything anyway and a watchdog
2349
+ * firing mid-reconnect would tear down healthy subscriptions.
2350
+ */
2351
+ suspendSubscribeWatchdogs() {
2352
+ for (const sub of this.collectionSubscriptions.values()) {
2353
+ if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
2354
+ sub.subscribeTimeout = void 0;
2355
+ sub.subscribeInFlight = false;
2356
+ }
2357
+ for (const sub of this.singleSubscriptions.values()) {
2358
+ if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
2359
+ sub.subscribeTimeout = void 0;
2360
+ sub.subscribeInFlight = false;
2361
+ }
2362
+ }
2363
+ /**
2364
+ * Arm watchdogs for subscribes that were requested while offline and have
2365
+ * just been flushed to the socket. Their timers were deliberately not set at
2366
+ * request time, so without this they would have no timeout at all.
2367
+ */
2368
+ armPendingSubscribeWatchdogs() {
2369
+ for (const [key, sub] of this.collectionSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
2370
+ for (const [key, sub] of this.singleSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
2371
+ }
2372
+ sendCollectionSubscribeWatchdog(subscriptionKey) {
2373
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
2374
+ if (!subscription) return;
2375
+ const backendSubscriptionId = subscription.backendSubscriptionId;
2376
+ subscription.subscribeTimeout = setTimeout(() => {
2377
+ const current = this.collectionSubscriptions.get(subscriptionKey);
2378
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
2379
+ if (!current.subscribeInFlight) return;
2380
+ this.failCollectionSubscription(subscriptionKey, new RebaseApiError$1("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
2381
+ }, this.subscriptionTimeoutMs);
2382
+ }
2383
+ sendEntitySubscribeWatchdog(subscriptionKey) {
2384
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
2385
+ if (!subscription) return;
2386
+ const backendSubscriptionId = subscription.backendSubscriptionId;
2387
+ subscription.subscribeTimeout = setTimeout(() => {
2388
+ const current = this.singleSubscriptions.get(subscriptionKey);
2389
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
2390
+ if (!current.subscribeInFlight) return;
2391
+ this.failEntitySubscription(subscriptionKey, new RebaseApiError$1("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
2392
+ }, this.subscriptionTimeoutMs);
2393
+ }
2394
+ /**
2395
+ * Fail every subscription that never received data. Called when reconnection
2396
+ * is given up on, so views surface an error instead of spinning forever.
2397
+ */
2398
+ failAllPendingSubscriptions(error) {
2399
+ for (const key of [...this.collectionSubscriptions.keys()]) {
2400
+ const sub = this.collectionSubscriptions.get(key);
2401
+ if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
2402
+ }
2403
+ for (const key of [...this.singleSubscriptions.keys()]) {
2404
+ const sub = this.singleSubscriptions.get(key);
2405
+ if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
2406
+ }
2407
+ }
2408
+ /**
2223
2409
  * Re-send all active subscriptions to the backend after a reconnect.
2224
2410
  * The server wipes subscription state when a client disconnects, so
2225
2411
  * we need to re-register everything to resume receiving updates.
@@ -2232,15 +2418,7 @@ var RebaseWebSocketClient = class {
2232
2418
  sub.backendSubscriptionId = newBackendId;
2233
2419
  this.backendToCollectionKey.delete(oldBackendId);
2234
2420
  this.backendToCollectionKey.set(newBackendId, key);
2235
- this.sendMessage({
2236
- type: "subscribe_collection",
2237
- payload: {
2238
- ...sub.props,
2239
- subscriptionId: newBackendId
2240
- }
2241
- }).catch((error) => {
2242
- console.error("[WS] Failed to re-subscribe collection:", key, error);
2243
- });
2421
+ this.sendCollectionSubscribe(key);
2244
2422
  }
2245
2423
  for (const [key, sub] of this.singleSubscriptions.entries()) {
2246
2424
  const oldBackendId = sub.backendSubscriptionId;
@@ -2248,15 +2426,7 @@ var RebaseWebSocketClient = class {
2248
2426
  sub.backendSubscriptionId = newBackendId;
2249
2427
  this.backendToEntityKey.delete(oldBackendId);
2250
2428
  this.backendToEntityKey.set(newBackendId, key);
2251
- this.sendMessage({
2252
- type: "subscribe_one",
2253
- payload: {
2254
- ...sub.props,
2255
- subscriptionId: newBackendId
2256
- }
2257
- }).catch((error) => {
2258
- console.error("[WS] Failed to re-subscribe row:", key, error);
2259
- });
2429
+ this.sendEntitySubscribe(key);
2260
2430
  }
2261
2431
  }
2262
2432
  createCollectionSubscriptionKey(props) {
@@ -2331,7 +2501,7 @@ function createRebaseClient(options) {
2331
2501
  });
2332
2502
  return storageSourcesPromise;
2333
2503
  };
2334
- const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
2504
+ const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
2335
2505
  let ws;
2336
2506
  if (resolvedWsUrl) {
2337
2507
  ws = new RebaseWebSocketClient({
@@ -2443,6 +2613,16 @@ function createRebaseClient(options) {
2443
2613
  createStorageSource,
2444
2614
  fetchStorageSources,
2445
2615
  ws,
2616
+ /**
2617
+ * Release the realtime socket and its reconnect timer.
2618
+ *
2619
+ * Until this returns, the open socket keeps the Node event loop alive
2620
+ * and the process will not exit on its own. Safe to call when realtime
2621
+ * was never started, and safe to call twice.
2622
+ */
2623
+ close: () => {
2624
+ ws?.disconnect();
2625
+ },
2446
2626
  setToken: transport.setToken,
2447
2627
  setAuthTokenGetter: transport.setAuthTokenGetter,
2448
2628
  setOnUnauthorized: transport.setOnUnauthorized,