@rebasepro/client 0.12.1-canary.gf5f1d39 → 0.13.0

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/auth.d.ts CHANGED
@@ -1,10 +1,6 @@
1
1
  import { Transport } from "./transport";
2
- import type { AuthChangeEvent, RebaseSession, AuthTokens, DeviceSession, User } from "@rebasepro/types";
2
+ import type { AuthChangeEvent, RebaseSession, DeviceSession, User } from "@rebasepro/types";
3
3
  export type { RebaseSession, AuthTokens, AuthChangeEvent, DeviceSession } from "@rebasepro/types";
4
- /** @deprecated Use `User` from `@rebasepro/types` instead. */
5
- export type RebaseUser = User;
6
- /** @deprecated Use `AuthTokens` from `@rebasepro/types` instead. */
7
- export type RebaseTokens = AuthTokens;
8
4
  /** Minimal, non-sensitive user profile returned by {@link findUserByEmail}. */
9
5
  export interface PublicUserProfile {
10
6
  uid: string;
@@ -128,6 +124,7 @@ export declare function createAuth(transport: Transport, options?: CreateAuthOpt
128
124
  refreshToken: string;
129
125
  }>;
130
126
  signOut: () => Promise<void>;
127
+ stopAutoRefresh: () => void;
131
128
  refreshSession: () => Promise<RebaseSession>;
132
129
  handleUnauthorized: () => Promise<boolean>;
133
130
  getUser: () => Promise<User>;
@@ -180,6 +177,7 @@ export declare function createAuth(transport: Transport, options?: CreateAuthOpt
180
177
  getAuthConfig: () => Promise<AuthConfig>;
181
178
  getSession: () => RebaseSession | null;
182
179
  onAuthStateChange: (callback: (event: AuthChangeEvent, session: RebaseSession | null) => void) => () => boolean;
180
+ canRestoreSession: () => boolean;
183
181
  isInitialized: () => Promise<void>;
184
182
  };
185
183
  export interface CookieStorageOptions {
package/dist/index.d.ts CHANGED
@@ -21,9 +21,7 @@ export type { PaginationErrorCode } from "@rebasepro/common";
21
21
  export { QueryBuilder, or, and, cond } from "@rebasepro/common";
22
22
  export { createCookieStorage, createMemoryStorage } from "./auth";
23
23
  export type { AuthConfig, AuthStorage, CookieStorageOptions, CreateAuthOptions } from "./auth";
24
- export type { RebaseSession, AuthTokens, AuthChangeEvent, DeviceSession } from "@rebasepro/types";
25
- /** @deprecated Import `User` / `AuthTokens` from `@rebasepro/types` instead. */
26
- export type { RebaseUser, RebaseTokens } from "./auth";
24
+ export type { User, RebaseSession, AuthTokens, AuthChangeEvent, DeviceSession } from "@rebasepro/types";
27
25
  export type { CreateAdminOptions } from "./admin";
28
26
  export type { AdminUser } from "./admin";
29
27
  export type { CreateCronOptions } from "./cron";
@@ -120,11 +118,17 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
120
118
  channel: (name: string, options?: ChannelOptions) => RebaseRealtimeChannel;
121
119
  };
122
120
  /**
123
- * Release the realtime socket and its reconnect timer.
121
+ * Release everything this client holds that can keep a process alive: the
122
+ * realtime socket and its reconnect timer, channel presence heartbeats, the
123
+ * offline manager, and the scheduled token refresh.
124
124
  *
125
- * An open socket keeps the Node event loop alive, so a script that does not
126
- * call this will not exit on its own. Safe when realtime was never started
127
- * (`realtime: false`), and safe to call twice.
125
+ * Each of those keeps the Node event loop alive on its own, so a script
126
+ * that does not call this will not exit and, until the refresh timer was
127
+ * included, one that *did* call it still would not if it had signed in.
128
+ *
129
+ * Safe when realtime was never started (`realtime: false`), safe when
130
+ * signed out, and safe to call twice. It does not sign the user out: a
131
+ * persisted session survives for the next client to restore.
128
132
  */
129
133
  close: () => void;
130
134
  storage: StorageSource;
package/dist/index.es.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, Vector, isPublicStoragePath, toCanonicalOp } from "@rebasepro/types";
1
+ import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, RebaseClientError as RebaseClientError$1, Vector, isPublicStoragePath, toCanonicalOp } from "@rebasepro/types";
2
2
  import { COMPOSITE_ID_SEPARATOR, QueryBuilder, RebasePaginationError, and, buildCompositeId, collectAllPages, cond, or, paginateFind, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
3
3
  import { toSnakeCase } from "@rebasepro/utils";
4
4
  //#region src/reviver.ts
@@ -49,6 +49,38 @@ function isServerLikeEnvironment() {
49
49
  * greppable — this is the string a user will paste into a search.
50
50
  */
51
51
  var ANONYMOUS_SERVER_CLIENT_WARNING = "[rebase] This client was created outside a browser with no credential — no `token`, no auth token getter, and no cookie auth flow — so every request runs as an anonymous caller. Row-level security will return only publicly readable rows, which is usually nothing and occasionally the wrong thing. Inside a cron or function handler, use the `client` you were handed instead of building a new one: its data plane is already admin-scoped. In a standalone script or job, pass the service key as `token`. If you really do want anonymous access, pass `anonymous: true` to silence this.";
52
+ /**
53
+ * Refuse a filter whose *value* is missing.
54
+ *
55
+ * `where: { status: ["==", undefined] }` used to serialize to the literal
56
+ * string, so `status=eq.undefined` went out on the wire and the server dutifully
57
+ * looked for rows whose status is the four-letter word "undefined". The caller
58
+ * saw an empty page, not an error — the classic shape of a variable that was
59
+ * never set.
60
+ *
61
+ * Dropping the condition instead would be worse than sending it: the query
62
+ * would come back *unfiltered*, which for an ownership or tenant filter means
63
+ * returning rows the caller never asked to see. So this is a hard error, and
64
+ * both correct spellings are named in the message: omit the key to skip the
65
+ * filter, or use `["is-null", null]` to match SQL NULL (which still
66
+ * serializes — `null` is a value, `undefined` is the absence of one).
67
+ */
68
+ function assertNoUndefinedFilterValues(where) {
69
+ const reject = (field, op) => {
70
+ throw new RebaseClientError$1(`Filter on "${field}" has an undefined value (["${String(op)}", undefined]). Omit "${field}" from \`where\` to skip the filter, or use ["is-null", null] to match SQL NULL.`);
71
+ };
72
+ for (const [field, condition] of Object.entries(where)) {
73
+ if (condition === void 0) continue;
74
+ if (!Array.isArray(condition)) continue;
75
+ const tuples = Array.isArray(condition[0]) ? condition : [condition];
76
+ for (const tuple of tuples) {
77
+ if (!Array.isArray(tuple) || tuple.length !== 2) continue;
78
+ const [op, value] = tuple;
79
+ if (value === void 0) reject(field, op);
80
+ if (Array.isArray(value) && value.some((v) => v === void 0)) reject(field, op);
81
+ }
82
+ }
83
+ }
52
84
  function buildQueryString(params) {
53
85
  if (!params) return "";
54
86
  const parts = [];
@@ -67,6 +99,7 @@ function buildQueryString(params) {
67
99
  parts.push(`${root.type}=${encodeURIComponent(`(${serialized})`)}`);
68
100
  }
69
101
  if (params.where) {
102
+ assertNoUndefinedFilterValues(params.where);
70
103
  const serialized = serializeFilter(params.where);
71
104
  for (const [field, value] of Object.entries(serialized)) if (Array.isArray(value)) for (const v of value) parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(v)}`);
72
105
  else parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(value)}`);
@@ -418,6 +451,29 @@ function createAuth(transport, options) {
418
451
  attemptScheduledRefresh(0);
419
452
  }, delay);
420
453
  }
454
+ /**
455
+ * Stop the scheduled token refresh, leaving the session itself alone.
456
+ *
457
+ * This is teardown, not sign-out. `scheduleRefresh` arms an ordinary
458
+ * `setTimeout` up to a token lifetime away, and it is not `unref`'d — so on
459
+ * Node it holds the event loop open by itself. `client.close()` promised
460
+ * that "a script that does not call this will not exit on its own", which
461
+ * was true, while the converse it plainly implies was not: a signed-in
462
+ * client that closed its socket still hung, because this timer outlived it.
463
+ * Any script, cron handler or job that signs in hit that.
464
+ *
465
+ * Deliberately does NOT clear the session, touch storage, or emit
466
+ * SIGNED_OUT. Closing a client is not the user signing out — `signOut()`
467
+ * POSTs /logout and revokes the whole sign-in, which is the wrong hammer
468
+ * (see `abandonSessionLocally`) — and a persisted session must still be
469
+ * there for the next client to restore.
470
+ */
471
+ function stopAutoRefresh() {
472
+ if (refreshTimeout) {
473
+ clearTimeout(refreshTimeout);
474
+ refreshTimeout = null;
475
+ }
476
+ }
421
477
  function handleAuthResponse(data, event) {
422
478
  const user = mapRawUser(data.user);
423
479
  const session = {
@@ -888,6 +944,7 @@ function createAuth(transport, options) {
888
944
  signInWithSlack,
889
945
  signInWithSpotify,
890
946
  signOut,
947
+ stopAutoRefresh,
891
948
  refreshSession,
892
949
  handleUnauthorized,
893
950
  getUser,
@@ -907,6 +964,7 @@ function createAuth(transport, options) {
907
964
  getAuthConfig,
908
965
  getSession,
909
966
  onAuthStateChange,
967
+ canRestoreSession: () => persistSession || authFlowMode === "cookie",
910
968
  isInitialized: () => isInitialized
911
969
  };
912
970
  }
@@ -1272,7 +1330,8 @@ function createCollectionClient(transport, slug, ws) {
1272
1330
  const qs = buildQueryString({
1273
1331
  ...params,
1274
1332
  limit: void 0,
1275
- offset: void 0
1333
+ offset: void 0,
1334
+ include: void 0
1276
1335
  });
1277
1336
  return (await transport.request(basePath + "/count" + qs, { method: "GET" })).count ?? 0;
1278
1337
  },
@@ -1418,7 +1477,8 @@ function createCollectionClient(transport, slug, ws) {
1418
1477
  function createFunctionsClient(transport) {
1419
1478
  return { async invoke(name, payload, options) {
1420
1479
  const method = options?.method ?? "POST";
1421
- const subPath = options?.path ? `/${options.path.replace(/^\//, "")}` : "";
1480
+ const rawPath = options?.path;
1481
+ const subPath = rawPath ? /^[?#]/.test(rawPath) ? rawPath : `/${rawPath.replace(/^\//, "")}` : "";
1422
1482
  const routePath = `/functions/${encodeURIComponent(name)}${subPath}`;
1423
1483
  const init = { method };
1424
1484
  if (payload !== void 0 && method !== "GET") init.body = JSON.stringify(payload);
@@ -1632,7 +1692,7 @@ function extractMessageError(message) {
1632
1692
  * Fire-and-forget (the server sends no response envelope), and exempt from the
1633
1693
  * client-side auth gate — a public channel is usable without an account.
1634
1694
  */
1635
- var CHANNEL_MESSAGE_TYPES = new Set([
1695
+ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
1636
1696
  "join_channel",
1637
1697
  "leave_channel",
1638
1698
  "broadcast",
@@ -1661,6 +1721,17 @@ var RebaseWebSocketClient = class {
1661
1721
  /** Set by `close()`. Blocks any later operation from silently redialling. */
1662
1722
  closedByCaller = false;
1663
1723
  /**
1724
+ * Set when the backoff budget ran out, cleared by anything that earns a
1725
+ * fresh one.
1726
+ *
1727
+ * Unlike {@link closedByCaller} this is not final — nobody *asked* for the
1728
+ * socket to stay down. Five attempts with exponential backoff is about a
1729
+ * minute, which a laptop lid, a wifi handover or a backend rollout all
1730
+ * exceed routinely; treating that as permanent meant realtime silently
1731
+ * stopped for the rest of the page's life, with a reload the only cure.
1732
+ */
1733
+ gaveUp = false;
1734
+ /**
1664
1735
  * Whether a socket exists at all (open or still opening).
1665
1736
  *
1666
1737
  * Lets callers distinguish "authenticate the live socket" from "there is
@@ -1733,18 +1804,37 @@ var RebaseWebSocketClient = class {
1733
1804
  }
1734
1805
  return;
1735
1806
  }
1807
+ this.installOnlineListener();
1736
1808
  if (this.ws || this.reconnectTimeout) return;
1809
+ if (this.gaveUp) {
1810
+ this.gaveUp = false;
1811
+ this.reconnectAttempts = 0;
1812
+ }
1737
1813
  this.initWebSocket();
1738
1814
  }
1739
1815
  /**
1816
+ * The browser says the network is back — the usual reason the budget ran
1817
+ * out in the first place. Registered lazily so a Node client, or a page
1818
+ * that never subscribes, adds no listener.
1819
+ */
1820
+ installOnlineListener() {
1821
+ if (this.onlineListener || typeof window === "undefined" || typeof window.addEventListener !== "function") return;
1822
+ this.onlineListener = () => {
1823
+ if (this.closedByCaller || !this.gaveUp) return;
1824
+ console.debug("Network is back — retrying the realtime connection");
1825
+ this.ensureConnected();
1826
+ };
1827
+ window.addEventListener("online", this.onlineListener);
1828
+ }
1829
+ onlineListener = null;
1830
+ /**
1740
1831
  * Authenticate the WebSocket connection
1741
1832
  */
1742
1833
  async authenticate(token) {
1743
1834
  return new Promise((resolve, reject) => {
1744
- const requestId = `auth_${Date.now()}`;
1835
+ const requestId = `auth_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1745
1836
  const timeout = setTimeout(() => {
1746
1837
  this.pendingRequests.delete(requestId);
1747
- this.authPromise = null;
1748
1838
  reject(/* @__PURE__ */ new Error("Authentication timeout"));
1749
1839
  }, 3e4);
1750
1840
  this.pendingRequests.set(requestId, {
@@ -1794,6 +1884,10 @@ var RebaseWebSocketClient = class {
1794
1884
  */
1795
1885
  disconnect(permanent = false) {
1796
1886
  if (permanent) this.closedByCaller = true;
1887
+ if (permanent && this.onlineListener && typeof window !== "undefined") {
1888
+ window.removeEventListener("online", this.onlineListener);
1889
+ this.onlineListener = null;
1890
+ }
1797
1891
  this.isAuthenticated = false;
1798
1892
  this.authPromise = null;
1799
1893
  if (this.reconnectTimeout) {
@@ -1818,7 +1912,8 @@ var RebaseWebSocketClient = class {
1818
1912
  this.ws = null;
1819
1913
  }
1820
1914
  try {
1821
- this.ws = new this.WebSocketConstructor(this.websocketUrl);
1915
+ const socket = new this.WebSocketConstructor(this.websocketUrl);
1916
+ this.ws = socket;
1822
1917
  this.ws.onopen = async () => {
1823
1918
  console.debug("Connected to PostgreSQL backend");
1824
1919
  const wasReconnect = this.reconnectAttempts > 0;
@@ -1848,6 +1943,7 @@ var RebaseWebSocketClient = class {
1848
1943
  };
1849
1944
  this.ws.onclose = () => {
1850
1945
  console.debug("Disconnected from PostgreSQL backend");
1946
+ if (this.ws === socket) this.ws = null;
1851
1947
  this.isConnected = false;
1852
1948
  this.isAuthenticated = false;
1853
1949
  this.authPromise = null;
@@ -1883,6 +1979,7 @@ var RebaseWebSocketClient = class {
1883
1979
  attemptReconnect() {
1884
1980
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
1885
1981
  console.error("Max reconnection attempts reached");
1982
+ this.gaveUp = true;
1886
1983
  this.failAllPendingSubscriptions(new RebaseApiError$1("Connection lost", { code: "CONNECTION_LOST" }));
1887
1984
  return;
1888
1985
  }
@@ -2125,21 +2222,23 @@ var RebaseWebSocketClient = class {
2125
2222
  }
2126
2223
  async ensureAuthenticated(retryCount = 3) {
2127
2224
  if (this.isAuthenticated || !this.getAuthToken) return;
2128
- if (this.authPromise) {
2129
- await this.authPromise;
2130
- return;
2225
+ if (!this.authPromise) {
2226
+ this.authPromise = this.runAuthentication(retryCount);
2227
+ this.authPromise.finally(() => {
2228
+ this.authPromise = null;
2229
+ }).catch(() => void 0);
2131
2230
  }
2231
+ await this.authPromise;
2232
+ }
2233
+ async runAuthentication(retryCount) {
2132
2234
  let lastError = null;
2133
2235
  for (let attempt = 0; attempt < retryCount; attempt++) try {
2134
2236
  const token = await this.getAuthToken();
2135
2237
  if (!token) throw new Error("user not logged in");
2136
- this.authPromise = this.authenticate(token);
2137
- await this.authPromise;
2138
- this.authPromise = null;
2238
+ await this.authenticate(token);
2139
2239
  console.debug("WebSocket authenticated on demand");
2140
2240
  return;
2141
2241
  } catch (error) {
2142
- this.authPromise = null;
2143
2242
  lastError = error;
2144
2243
  const errMsg = error instanceof Error ? error.message : String(error);
2145
2244
  if (errMsg.includes("not logged in") || errMsg.includes("Session expired")) {
@@ -2425,7 +2524,7 @@ var RebaseWebSocketClient = class {
2425
2524
  if (this.deepEqual(normCached, normIncoming)) return cachedRow;
2426
2525
  else {
2427
2526
  const mismatches = {};
2428
- const allKeys = new Set([...Object.keys(normCached), ...Object.keys(normIncoming)]);
2527
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(normCached), ...Object.keys(normIncoming)]);
2429
2528
  for (const key of allKeys) if (!this.deepEqual(normCached[key], normIncoming[key])) mismatches[key] = {
2430
2529
  cached: normCached[key],
2431
2530
  incoming: normIncoming[key]
@@ -3228,7 +3327,7 @@ function isNetworkError(error) {
3228
3327
  * bug the same payload will hit again than a blip, and retrying it forever
3229
3328
  * jams every write behind it.
3230
3329
  */
3231
- var RETRYABLE_STATUSES = new Set([
3330
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
3232
3331
  408,
3233
3332
  425,
3234
3333
  429,
@@ -5278,23 +5377,25 @@ channel: (name, options) => {
5278
5377
  return existing;
5279
5378
  } },
5280
5379
  /**
5281
- * Release the realtime socket and its reconnect timer.
5380
+ * Release every handle that can keep a process alive — see the
5381
+ * `close` docblock on the client interface.
5282
5382
  *
5283
- * Until this returns, the open socket keeps the Node event loop alive
5284
- * and the process will not exit on its own. Safe to call when realtime
5285
- * was never started, and safe to call twice.
5383
+ * Safe to call when realtime was never started, safe when signed out,
5384
+ * and safe to call twice.
5286
5385
  */
5287
5386
  close: () => {
5288
5387
  for (const channel of realtimeChannels.values()) channel.leave();
5289
5388
  realtimeChannels.clear();
5290
5389
  ws?.disconnect(true);
5291
5390
  offlineManager?.dispose();
5391
+ auth.stopAutoRefresh();
5292
5392
  },
5293
5393
  setToken: transport.setToken,
5294
5394
  setAuthTokenGetter: transport.setAuthTokenGetter,
5295
5395
  setOnUnauthorized: transport.setOnUnauthorized,
5296
5396
  resolveToken: transport.resolveToken,
5297
5397
  baseUrl: transport.baseUrl,
5398
+ apiPath: transport.apiPath,
5298
5399
  collection,
5299
5400
  call: async (endpoint, payload) => {
5300
5401
  const prefix = endpoint.startsWith("/") ? "" : "/";