@rebasepro/client 0.25.0 → 0.26.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/README.md CHANGED
@@ -34,7 +34,7 @@ of it resolves too: Node has supported `require(esm)` since 22.12.
34
34
  |---|---|
35
35
  | `createRebaseClient<DB>(options)` | Create a client instance. Generic `DB` parameter enables type-safe `client.data.*` access. |
36
36
  | `CreateRebaseClientResult<DB>` | The client type it returns (`RebaseClient<DB>` from `@rebasepro/types`, narrowed) — includes `auth`, `admin`, `cron`, `functions`, `storage`, `ws`, `data`, `call`, and token management methods. |
37
- | `CreateRebaseClientOptions` | Extends `RebaseClientConfig` with the `auth`, `admin`, `cron`, `apiKeys` and `personalKeys` sub-configs, plus `storageSources`, `collections` and `offline`. |
37
+ | `CreateRebaseClientOptions` | Extends `RebaseClientConfig` with the `auth`, `admin`, `cron`, `apiKeys` and `personalKeys` sub-configs, plus `storageSources`, `collections`, `offline` and `debug`. |
38
38
 
39
39
  ### Config
40
40
 
@@ -49,6 +49,7 @@ of it resolves too: Node has supported `require(esm)` since 22.12.
49
49
  | `realtime` | `boolean` | `true` | Open the WebSocket — `false` lets a one-shot script exit |
50
50
  | `collections` | `Record<string, string>` | — | Maps accessor names to collection slugs |
51
51
  | `offline` | `boolean \| OfflineConfig` | `false` | Local-first sync — see [the docs](https://rebase.pro/docs/sdk/offline) |
52
+ | `debug` | `boolean` | `false` | Log the realtime connection's progress with `console.debug`. One browser can turn it on with `localStorage.setItem("rebase_debug", "1")` — see [Connection logging](https://rebase.pro/docs/sdk#connection-logging) |
52
53
 
53
54
  ### Tokens
54
55
 
package/dist/admin.d.ts CHANGED
@@ -11,6 +11,14 @@ export interface ListUsersOptions {
11
11
  offset?: number;
12
12
  orderBy?: string;
13
13
  orderDir?: "asc" | "desc";
14
+ /** Only the users holding this role id (`?role=`). */
15
+ role?: string;
16
+ /**
17
+ * Resolve exactly these users in one round trip (`?ids=`), for turning
18
+ * stored ids into names. The window, search and role are ignored when it
19
+ * is given, and ids that name no user are left out of the answer.
20
+ */
21
+ ids?: readonly string[];
14
22
  }
15
23
  export declare function createAdmin(transport: Transport, options?: CreateAdminOptions): {
16
24
  listUsers: (options?: ListUsersOptions) => Promise<{
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ import { createFunctionsClient } from "./functions.js";
9
9
  import { RebaseWebSocketClient } from "./websocket.js";
10
10
  import { RebaseRealtimeChannel, type ChannelOptions } from "./realtime-channel.js";
11
11
  import type { OfflineApi, OfflineConfig } from "./offline.js";
12
- import { type BatchOperation, type BatchResult, type IncludeOf, InsertOf, RebaseClient, RebaseSdkData, RowOf, StorageSource, StorageSourceDefinition, StorageSourceRegistry, UpdateOf, type WriteOptions } from "@rebasepro/types";
12
+ import { type AccessSimulation, type BatchOperation, type BatchResult, type IncludeOf, InsertOf, RebaseClient, RebaseSdkData, RowOf, StorageSource, StorageSourceDefinition, StorageSourceRegistry, UpdateOf, type WriteOptions } from "@rebasepro/types";
13
13
  export { RebaseApiError } from "./transport.js";
14
14
  export { RebaseClientError } from "./errors.js";
15
15
  export type { RebaseErrorCode } from "@rebasepro/types";
@@ -57,10 +57,17 @@ export interface CreateRebaseClientOptions extends RebaseClientConfig {
57
57
  */
58
58
  storageSources?: StorageSourceDefinition[];
59
59
  /**
60
- * Maps camelCase property names / safe identifiers to the actual
61
- * collection slugs on the server (e.g. `{ companyMembers: "company-members" }`).
62
- * If provided, the data layer proxy will resolve property accessors to their
63
- * correct slugs via this map before falling back to automatic snake_casing.
60
+ * Maps each `client.data` accessor to the collection slug the server
61
+ * serves (e.g. `{ companyMembers: "company-members" }`): the generated
62
+ * `collectionsDictionary`.
63
+ *
64
+ * **Required for a typed client** (`createRebaseClient<Database>`). The
65
+ * types offer every accessor, but without the map the proxy sends
66
+ * `client.data.companyMembers` to `/data/company_members`, so a kebab-case
67
+ * slug or a renamed accessor (`collectionCollection`) answers 404; a
68
+ * one-time warning names the accessor. With the map, an unknown accessor
69
+ * throws `UNKNOWN_COLLECTION` with a suggestion. A later version will make
70
+ * it required in the types when `Database` is supplied.
64
71
  */
65
72
  collections?: Record<string, string>;
66
73
  /**
@@ -78,6 +85,17 @@ export interface CreateRebaseClientOptions extends RebaseClientConfig {
78
85
  * shared across tabs. Off by default.
79
86
  */
80
87
  offline?: boolean | OfflineConfig;
88
+ /**
89
+ * Log the realtime connection's progress — opened, signed in, closed,
90
+ * redialling — with `console.debug`. Off by default, so a public page says
91
+ * nothing about its backend in a visitor's console; warnings and errors
92
+ * are written either way.
93
+ *
94
+ * Without a code change, one browser can turn it on from its console with
95
+ * `localStorage.setItem("rebase_debug", "1")` (remove the key to stop), and
96
+ * any runtime with `globalThis.__REBASE_DEBUG__ = true`.
97
+ */
98
+ debug?: boolean;
81
99
  }
82
100
  type KebabToCamelCase<S extends string> = S extends `${infer T}-${infer U}` ? `${T}${Capitalize<KebabToCamelCase<U>>}` : S;
83
101
  /** The accessor whose generated entry declares `Slug: S`, or `never`. */
@@ -101,7 +119,19 @@ type TypedDataLayer<DB> = {
101
119
  * capabilities populated and the `data` layer narrowed to provide
102
120
  * typed collection accessors when a `DB` schema generic is supplied.
103
121
  */
104
- export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<RebaseClient<DB>, "data" | "email"> & {
122
+ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<RebaseClient<DB>, "data" | "email" | "simulate"> & {
123
+ /**
124
+ * A client whose data requests run with simulated access — as a role, a
125
+ * uid holding a role, or a service key — for an administrator checking
126
+ * what row-level security lets that identity read. See
127
+ * {@link RebaseClient.simulate} for what the server honours and refuses.
128
+ *
129
+ * Always present here, typed by the same `Database` (it is built with the
130
+ * same `collections` dictionary), and closable: it is a client of its own,
131
+ * so release it with `close()` when done. On `RebaseClient` it is optional
132
+ * and untyped, because a server-side client has none.
133
+ */
134
+ simulate: (simulation: AccessSimulation) => CreateRebaseClientResult<DB>;
105
135
  /**
106
136
  * Send this token from now on, over HTTP and on the realtime socket. An
107
137
  * open socket is rebuilt to authenticate with it. A token getter, when one
package/dist/index.es.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { c as invalidBulkBody, d as RebaseApiError, h as parseRebaseJson, i as isOfflineError, l as SDKQueryBuilder, m as defaultCodeForStatus, n as MemoryOfflineStore, o as createCollectionClient, p as createTransport, s as etagOf, u as RebaseClientError } from "./offline-store-BhYXViha.js";
2
- import { DEFAULT_STORAGE_SOURCE_KEY, PUBLIC_STORAGE_PREFIX, RebaseApiError as RebaseApiError$1, RebaseClientError as RebaseClientError$1, STORAGE_REFERENCE_SCHEME, isPublicStoragePath, isStorageReference, isUnsupported, parseStorageReference, resolveStorageReferences, storageObjectPath, storageReference, storageReferencesForDownloadUrls, storageTargetOfDownloadUrl, storageTargetsInText } from "@rebasepro/types";
2
+ import { DEFAULT_STORAGE_SOURCE_KEY, PUBLIC_STORAGE_PREFIX, RebaseApiError as RebaseApiError$1, RebaseClientError as RebaseClientError$1, STORAGE_REFERENCE_SCHEME, isPublicStoragePath, isStorageReference, isUnsupported, parseStorageReference, resolveStorageReferences, simulationHeaders, storageObjectPath, storageReference, storageReferencesForDownloadUrls, storageTargetOfDownloadUrl, storageTargetsInText } from "@rebasepro/types";
3
3
  import { COMPOSITE_ID_SEPARATOR, DOWNLOAD_URL_CONCURRENCY, RESUMABLE_UPLOAD_THRESHOLD, RebasePaginationError, and, buildCompositeId, cond, createBatchLoader, createConcurrencyLimit, createDownloadUrlCache, not, or, warnDeprecated, withETag } from "@rebasepro/common";
4
4
  import { toSnakeCase, unref } from "@rebasepro/utils";
5
5
  //#region src/auth.ts
@@ -1364,6 +1364,8 @@ function createAdmin(transport, options) {
1364
1364
  if (options?.search) params.set("search", options.search);
1365
1365
  if (options?.orderBy) params.set("orderBy", options.orderBy);
1366
1366
  if (options?.orderDir) params.set("orderDir", options.orderDir);
1367
+ if (options?.role) params.set("role", options.role);
1368
+ if (options?.ids) params.set("ids", options.ids.join(","));
1367
1369
  const qs = params.toString();
1368
1370
  return transport.request(adminPath + "/users" + (qs ? "?" + qs : ""), { method: "GET" });
1369
1371
  }
@@ -2139,10 +2141,23 @@ function requestTimeoutError(written) {
2139
2141
  * connection has been down for longer than a blip. The registration is kept and
2140
2142
  * resumes when the socket is back.
2141
2143
  */
2142
- function realtimeLostError() {
2144
+ function realtimeLostError(refusal) {
2145
+ if (refusal) return new RebaseApiError$1(`The server refused this realtime connection (${refusal.code}: ${refusal.message}), so live data is not updating. The client keeps retrying, more slowly each time, and refreshes this subscription once it is let in.`, {
2146
+ code: "CONNECTION_LOST",
2147
+ details: { reason: refusal.code },
2148
+ cause: refusal
2149
+ });
2143
2150
  return new RebaseApiError$1("The realtime connection is down, so live data is not updating. The client keeps reconnecting and refreshes this subscription when the connection is back.", { code: "CONNECTION_LOST" });
2144
2151
  }
2145
2152
  /**
2153
+ * Whether `error` is the server refusing this socket for its per-user ceiling
2154
+ * (`REALTIME_MAX_SOCKETS_PER_USER`): it answers AUTHENTICATE with
2155
+ * `TOO_MANY_SOCKETS` and closes the socket.
2156
+ */
2157
+ function isSocketCeilingRefusal(error) {
2158
+ return error instanceof RebaseApiError$1 && error.code === "TOO_MANY_SOCKETS";
2159
+ }
2160
+ /**
2146
2161
  * Whether realtime debugging is on: `globalThis.__REBASE_DEBUG_REALTIME__ = true`,
2147
2162
  * set from a console or a test. While it is, each refetched row that differs
2148
2163
  * from the one held is explained key by key with `console.debug`.
@@ -2150,6 +2165,48 @@ function realtimeLostError() {
2150
2165
  function realtimeDebugOn() {
2151
2166
  return Reflect.get(globalThis, "__REBASE_DEBUG_REALTIME__") === true;
2152
2167
  }
2168
+ /**
2169
+ * The `localStorage` key that turns connection logging on for one browser,
2170
+ * without a code change or a rebuild: `localStorage.setItem("rebase_debug", "1")`
2171
+ * in its console. For a page that loads the client as a plain script, where
2172
+ * `createRebaseClient({ debug: true })` means editing and redeploying the site.
2173
+ */
2174
+ var DEBUG_STORAGE_KEY = "rebase_debug";
2175
+ /**
2176
+ * Whether connection logging was switched on from outside the code:
2177
+ * `globalThis.__REBASE_DEBUG__`, `globalThis.__REBASE_DEBUG_REALTIME__` (which
2178
+ * also explains refetched rows), or {@link DEBUG_STORAGE_KEY} in this
2179
+ * browser's `localStorage`. Read at each line, so it can be switched on in a
2180
+ * console without reloading a page whose socket is already open.
2181
+ */
2182
+ function debugSwitchedOn() {
2183
+ if (Reflect.get(globalThis, "__REBASE_DEBUG__") === true || realtimeDebugOn()) return true;
2184
+ if (typeof window === "undefined" || typeof localStorage === "undefined") return false;
2185
+ try {
2186
+ const value = localStorage.getItem(DEBUG_STORAGE_KEY);
2187
+ return value === "1" || value === "true";
2188
+ } catch {
2189
+ return false;
2190
+ }
2191
+ }
2192
+ /**
2193
+ * The codes an `AUTHENTICATE` is refused with when the server will not honour
2194
+ * the session: the token does not verify or has expired, the session was
2195
+ * revoked or its account is gone (`SESSION_REVOKED`), the account is disabled
2196
+ * (`ACCOUNT_DISABLED`) — the codes the HTTP routes answer the same token with.
2197
+ * An answer, not a failure: asking again with the same token gets the same one.
2198
+ */
2199
+ var SESSION_REFUSAL_CODES = /* @__PURE__ */ new Set([
2200
+ "INVALID_TOKEN",
2201
+ "TOKEN_EXPIRED",
2202
+ "JWT_EXPIRED",
2203
+ "SESSION_REVOKED",
2204
+ "ACCOUNT_DISABLED",
2205
+ "SESSION_ENDED"
2206
+ ]);
2207
+ function isSessionRefusal(error) {
2208
+ return error instanceof RebaseApiError$1 && typeof error.code === "string" && SESSION_REFUSAL_CODES.has(error.code);
2209
+ }
2153
2210
  /** The ceiling of the reconnect backoff. */
2154
2211
  var MAX_RECONNECT_DELAY_MS = 3e4;
2155
2212
  /**
@@ -2452,11 +2509,28 @@ var RebaseWebSocketClient = class {
2452
2509
  onUnauthorized;
2453
2510
  refreshInProgress = null;
2454
2511
  impersonate;
2512
+ debug;
2513
+ /**
2514
+ * Connection chatter: the socket opened, signed in, closed, is redialling.
2515
+ *
2516
+ * Silent unless asked for, with the `debug` option or
2517
+ * {@link debugSwitchedOn}. It used to go to `console.debug` in every
2518
+ * browser: a public page printed "Connected to PostgreSQL backend" and
2519
+ * "WebSocket auto-authenticated" to each visitor's console, naming the
2520
+ * backend on every load. In Node, where `console.debug` is `console.log`,
2521
+ * it opened a script's stdout ahead of what the script prints. Warnings and
2522
+ * errors are not chatter and are always written.
2523
+ */
2524
+ connectionDebug(...args) {
2525
+ if (!this.debug && !debugSwitchedOn()) return;
2526
+ console.debug(...args);
2527
+ }
2455
2528
  constructor(config) {
2456
2529
  this.websocketUrl = config.websocketUrl;
2457
2530
  this.getAuthToken = config.getAuthToken;
2458
2531
  this.onUnauthorized = config.onUnauthorized;
2459
2532
  this.impersonate = config.impersonate;
2533
+ this.debug = config.debug === true;
2460
2534
  this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : void 0);
2461
2535
  }
2462
2536
  /**
@@ -2495,7 +2569,7 @@ var RebaseWebSocketClient = class {
2495
2569
  if (this.closedByCaller) return;
2496
2570
  if (this.connectionState !== "reconnecting" && this.connectionState !== "disconnected") return;
2497
2571
  if (this.ws) return;
2498
- console.debug(`${reason} — retrying the realtime connection`);
2572
+ this.connectionDebug(`${reason} — retrying the realtime connection`);
2499
2573
  if (this.reconnectTimeout) {
2500
2574
  clearTimeout(this.reconnectTimeout);
2501
2575
  this.reconnectTimeout = null;
@@ -2527,6 +2601,39 @@ var RebaseWebSocketClient = class {
2527
2601
  }
2528
2602
  onlineListener = null;
2529
2603
  visibilityListener = null;
2604
+ /** The session this socket's server refused, and why. Cleared when a socket opens. */
2605
+ refusedSession = null;
2606
+ /**
2607
+ * Whether `error` is the server refusing the session `token` signs in as —
2608
+ * revoked, its account gone or disabled, a token that does not verify. Said
2609
+ * once per socket and token, as a warning that names the code.
2610
+ */
2611
+ noteSessionRefused(token, error) {
2612
+ if (!isSessionRefusal(error)) return false;
2613
+ if (this.refusedSession?.token !== token) {
2614
+ this.refusedSession = {
2615
+ token,
2616
+ error
2617
+ };
2618
+ console.warn(`[Rebase] Realtime: the server refused this session (${error.code}: ${error.message}). The socket is connected but not signed in, and what needs an account is refused with that code.`);
2619
+ }
2620
+ return true;
2621
+ }
2622
+ /** Whether the current run of ceiling refusals has been said in the console. */
2623
+ socketCeilingWarned = false;
2624
+ /**
2625
+ * The server refused this socket for its per-user ceiling. Said once per
2626
+ * run of refusals, as a warning, and reported to every registration at
2627
+ * once as an outage — a refusal is not a blip that the next dial fixes.
2628
+ */
2629
+ noteSocketCeiling(error) {
2630
+ if (!this.socketCeilingWarned) {
2631
+ this.socketCeilingWarned = true;
2632
+ console.warn(`[Rebase] Realtime: the server refused this connection (${error.code}: ${error.message}). Live data is not updating; the client retries, more slowly each time, until another connection closes.`);
2633
+ }
2634
+ this.clearOutageTimer();
2635
+ this.reportConnectionLost(error);
2636
+ }
2530
2637
  /**
2531
2638
  * Authenticate the WebSocket connection
2532
2639
  */
@@ -2575,10 +2682,10 @@ var RebaseWebSocketClient = class {
2575
2682
  if (this.isConnected && !this.isAuthenticated && !this.authPromise) this.getAuthToken().then((token) => {
2576
2683
  if (!this.ws) return;
2577
2684
  if (token) this.authenticate(token).catch((e) => {
2578
- if (this.ws) console.debug("WebSocket auto-auth skipped:", e?.message || e);
2685
+ if (this.ws) this.connectionDebug("WebSocket auto-auth skipped:", e?.message || e);
2579
2686
  });
2580
2687
  }).catch((e) => {
2581
- if (this.ws) console.debug("WebSocket auto-auth skipped:", e?.message || e);
2688
+ if (this.ws) this.connectionDebug("WebSocket auto-auth skipped:", e?.message || e);
2582
2689
  });
2583
2690
  }
2584
2691
  /**
@@ -2594,6 +2701,7 @@ var RebaseWebSocketClient = class {
2594
2701
  if (permanent) this.removeNetworkListeners();
2595
2702
  this.isAuthenticated = false;
2596
2703
  this.authPromise = null;
2704
+ this.refusedSession = null;
2597
2705
  if (this.reconnectTimeout) {
2598
2706
  clearTimeout(this.reconnectTimeout);
2599
2707
  this.reconnectTimeout = null;
@@ -2628,24 +2736,35 @@ var RebaseWebSocketClient = class {
2628
2736
  const socket = new this.WebSocketConstructor(withOptIns(this.websocketUrl));
2629
2737
  this.ws = socket;
2630
2738
  this.ws.onopen = async () => {
2631
- console.debug("Connected to PostgreSQL backend");
2739
+ this.connectionDebug("Connected to PostgreSQL backend");
2632
2740
  const wasReconnect = this.hadConnection;
2633
2741
  this.hadConnection = true;
2634
2742
  this.isConnected = true;
2743
+ this.gaveUp = false;
2744
+ if (!this.outageReported) this.setState("connected");
2745
+ this.refusedSession = null;
2746
+ if (this.getAuthToken && !this.isAuthenticated) {
2747
+ let token = null;
2748
+ try {
2749
+ token = await this.getAuthToken();
2750
+ if (token) {
2751
+ await this.authenticate(token);
2752
+ this.connectionDebug("WebSocket auto-authenticated");
2753
+ }
2754
+ } catch (error) {
2755
+ if (isSocketCeilingRefusal(error)) {
2756
+ this.noteSocketCeiling(error);
2757
+ return;
2758
+ }
2759
+ if (!(token && this.noteSessionRefused(token, error))) this.connectionDebug("WebSocket connected, not signed in yet:", error?.message || error);
2760
+ }
2761
+ }
2762
+ if (this.ws !== socket) return;
2635
2763
  this.reconnectAttempts = 0;
2636
2764
  this.clearOutageTimer();
2637
2765
  this.outageReported = false;
2638
- this.gaveUp = false;
2766
+ this.socketCeilingWarned = false;
2639
2767
  this.setState("connected");
2640
- if (this.getAuthToken && !this.isAuthenticated) try {
2641
- const token = await this.getAuthToken();
2642
- if (token) {
2643
- await this.authenticate(token);
2644
- console.debug("WebSocket auto-authenticated");
2645
- }
2646
- } catch (error) {
2647
- console.debug("WebSocket connected without auth:", error?.message || error);
2648
- }
2649
2768
  this.emit(wasReconnect ? "reconnect" : "connect");
2650
2769
  this.processMessageQueue(wasReconnect);
2651
2770
  if (wasReconnect) this.resubscribeAll();
@@ -2661,7 +2780,7 @@ var RebaseWebSocketClient = class {
2661
2780
  }
2662
2781
  };
2663
2782
  const onClosed = () => {
2664
- console.debug("Disconnected from PostgreSQL backend");
2783
+ this.connectionDebug("Disconnected from PostgreSQL backend");
2665
2784
  this.stopLivenessWatch();
2666
2785
  this.forgetServerOptIns();
2667
2786
  if (this.ws === socket) this.ws = null;
@@ -2791,7 +2910,7 @@ var RebaseWebSocketClient = class {
2791
2910
  this.reconnectAttempts = Math.min(this.reconnectAttempts + 1, 30);
2792
2911
  const ceiling = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), MAX_RECONNECT_DELAY_MS);
2793
2912
  const delay = MIN_RECONNECT_DELAY_MS + Math.floor(Math.random() * (ceiling - MIN_RECONNECT_DELAY_MS));
2794
- console.debug(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
2913
+ this.connectionDebug(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
2795
2914
  if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
2796
2915
  this.reconnectTimeout = setTimeout(() => {
2797
2916
  this.reconnectTimeout = null;
@@ -2817,11 +2936,11 @@ var RebaseWebSocketClient = class {
2817
2936
  if (this.outageTimer) clearTimeout(this.outageTimer);
2818
2937
  this.outageTimer = null;
2819
2938
  }
2820
- reportConnectionLost() {
2939
+ reportConnectionLost(refusal) {
2821
2940
  if (this.outageReported) return;
2822
2941
  this.outageReported = true;
2823
2942
  this.setState("disconnected");
2824
- const error = realtimeLostError();
2943
+ const error = realtimeLostError(refusal);
2825
2944
  for (const subscription of this.collectionSubscriptions.values()) this.notifyError(subscription.callbacks, error);
2826
2945
  for (const subscription of this.singleSubscriptions.values()) this.notifyError(subscription.callbacks, error);
2827
2946
  for (const handler of [...this.connectionLostHandlers]) try {
@@ -3060,31 +3179,36 @@ var RebaseWebSocketClient = class {
3060
3179
  }
3061
3180
  async runAuthentication(retryCount) {
3062
3181
  let lastError = null;
3063
- for (let attempt = 0; attempt < retryCount; attempt++) try {
3064
- const token = await this.getAuthToken();
3065
- if (!token) throw new Error("user not logged in");
3066
- await this.authenticate(token);
3067
- console.debug("WebSocket authenticated on demand");
3068
- return;
3069
- } catch (error) {
3070
- lastError = error;
3071
- const errMsg = error instanceof Error ? error.message : String(error);
3072
- if (errMsg.includes("not logged in") || errMsg.includes("Session expired")) {
3073
- console.warn("WebSocket auth failed: user not logged in");
3074
- throw error;
3075
- }
3076
- if (errMsg.includes("still loading")) {
3182
+ for (let attempt = 0; attempt < retryCount; attempt++) {
3183
+ let token = null;
3184
+ try {
3185
+ token = await this.getAuthToken();
3186
+ if (!token) throw new Error("user not logged in");
3187
+ if (this.refusedSession?.token === token) throw this.refusedSession.error;
3188
+ await this.authenticate(token);
3189
+ this.connectionDebug("WebSocket authenticated on demand");
3190
+ return;
3191
+ } catch (error) {
3192
+ lastError = error;
3193
+ if (token && this.noteSessionRefused(token, error)) throw error;
3194
+ const errMsg = error instanceof Error ? error.message : String(error);
3195
+ if (errMsg.includes("not logged in") || errMsg.includes("Session expired")) {
3196
+ console.warn("WebSocket auth failed: user not logged in");
3197
+ throw error;
3198
+ }
3199
+ if (errMsg.includes("still loading")) {
3200
+ if (attempt < retryCount - 1) {
3201
+ const delay = Math.min(500 * (attempt + 1), 2e3);
3202
+ await new Promise((resolve) => setTimeout(resolve, delay));
3203
+ continue;
3204
+ }
3205
+ }
3077
3206
  if (attempt < retryCount - 1) {
3078
- const delay = Math.min(500 * (attempt + 1), 2e3);
3207
+ const delay = Math.min(1e3 * (attempt + 1), 3e3);
3208
+ this.connectionDebug(`WebSocket auth attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
3079
3209
  await new Promise((resolve) => setTimeout(resolve, delay));
3080
- continue;
3081
3210
  }
3082
3211
  }
3083
- if (attempt < retryCount - 1) {
3084
- const delay = Math.min(1e3 * (attempt + 1), 3e3);
3085
- console.debug(`WebSocket auth attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
3086
- await new Promise((resolve) => setTimeout(resolve, delay));
3087
- }
3088
3212
  }
3089
3213
  console.warn("WebSocket on-demand auth failed after retries:", lastError);
3090
3214
  throw lastError;
@@ -3096,7 +3220,7 @@ var RebaseWebSocketClient = class {
3096
3220
  const token = await this.getAuthToken();
3097
3221
  if (!token) throw new Error("user not logged in");
3098
3222
  await this.authenticate(token);
3099
- console.debug("WebSocket reauthenticated successfully");
3223
+ this.connectionDebug("WebSocket reauthenticated successfully");
3100
3224
  } catch (error) {
3101
3225
  console.error("WebSocket reauthentication failed:", error);
3102
3226
  throw error;
@@ -3170,6 +3294,10 @@ var RebaseWebSocketClient = class {
3170
3294
  await this.ensureAuthenticated();
3171
3295
  } catch (error) {
3172
3296
  if (!(isSubscriptionFrame && isNotSignedIn(error))) {
3297
+ if (error instanceof RebaseApiError$1) {
3298
+ reject(error);
3299
+ return;
3300
+ }
3173
3301
  reject(new RebaseApiError$1(error instanceof Error ? error.message : "Authentication required"));
3174
3302
  return;
3175
3303
  }
@@ -3782,7 +3910,7 @@ var RebaseWebSocketClient = class {
3782
3910
  * we need to re-register everything to resume receiving updates.
3783
3911
  */
3784
3912
  resubscribeAll() {
3785
- console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.singleSubscriptions.size} row(ies)`);
3913
+ this.connectionDebug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.singleSubscriptions.size} row(ies)`);
3786
3914
  for (const [key, sub] of this.collectionSubscriptions.entries()) {
3787
3915
  if (sub.subscribeInFlight) continue;
3788
3916
  const oldBackendId = sub.backendSubscriptionId;
@@ -4072,12 +4200,28 @@ var RebaseRealtimeChannel = class {
4072
4200
  */
4073
4201
  async broadcast(event, payload) {
4074
4202
  await this.join();
4203
+ this.rememberOwnSender(this.transport.socketClientId);
4075
4204
  await this.send("broadcast", {
4076
4205
  event,
4077
4206
  payload
4078
4207
  });
4079
4208
  }
4080
4209
  /**
4210
+ * The socket ids this channel has broadcast from — the current one and the
4211
+ * ones before a reconnect. A message in history from any of them is this
4212
+ * client's own, and a sender is never sent its own messages: replayed after
4213
+ * a reconnect whose `broadcast_ack` was lost, it was applied twice.
4214
+ */
4215
+ ownSenderIds = /* @__PURE__ */ new Set();
4216
+ rememberOwnSender(id) {
4217
+ if (id === void 0 || this.ownSenderIds.has(id)) return;
4218
+ this.ownSenderIds.add(id);
4219
+ if (this.ownSenderIds.size > 16) {
4220
+ const oldest = this.ownSenderIds.values().next().value;
4221
+ if (oldest !== void 0) this.ownSenderIds.delete(oldest);
4222
+ }
4223
+ }
4224
+ /**
4081
4225
  * Observe refusals about this channel.
4082
4226
  *
4083
4227
  * `CHANNEL_FORBIDDEN` when an authorizer refuses a join, a broadcast or a
@@ -4250,7 +4394,7 @@ var RebaseRealtimeChannel = class {
4250
4394
  for (const entry of entries) {
4251
4395
  if (entry.seq <= this.lastSeq) continue;
4252
4396
  this.lastSeq = entry.seq;
4253
- if (ownId !== void 0 && entry.senderId === ownId) continue;
4397
+ if (ownId !== void 0 && (entry.senderId === ownId || entry.senderId !== void 0 && this.ownSenderIds.has(entry.senderId))) continue;
4254
4398
  this.deliver({
4255
4399
  event: entry.event,
4256
4400
  payload: entry.payload,
@@ -4399,7 +4543,7 @@ var LazyOfflineManager = class {
4399
4543
  keepsLastUser;
4400
4544
  constructor(config, createInner) {
4401
4545
  this.keepsLastUser = config.keepLastUser === true;
4402
- import("./offline-BrqCRgih.js").then(({ OfflineManager: Engine }) => {
4546
+ import("./offline-AvuEcg7u.js").then(({ OfflineManager: Engine }) => {
4403
4547
  const engine = new Engine(config, createInner);
4404
4548
  this.engine = engine;
4405
4549
  const queued = this.queued;
@@ -4474,6 +4618,9 @@ var LazyOfflineManager = class {
4474
4618
  setScope(uid, options) {
4475
4619
  this.run((engine) => engine.setScope(uid, options)).catch(() => void 0);
4476
4620
  }
4621
+ onReachedUnconfirmed(listener) {
4622
+ return this.subscribe((engine) => engine.onReachedUnconfirmed(listener));
4623
+ }
4477
4624
  async resumeLastUser() {
4478
4625
  await await this.run((engine) => engine.resumeLastUser());
4479
4626
  }
@@ -4631,7 +4778,8 @@ function createRebaseClient(options) {
4631
4778
  websocketUrl: resolvedWsUrl,
4632
4779
  getAuthToken: () => transport.resolveToken(),
4633
4780
  onUnauthorized: () => onUnauthorized(),
4634
- impersonate: options.impersonate
4781
+ impersonate: options.impersonate,
4782
+ debug: options.debug
4635
4783
  });
4636
4784
  let realtimeUid = auth.getSession()?.user?.uid ?? null;
4637
4785
  auth.onAuthStateChange((event, session) => {
@@ -4708,15 +4856,19 @@ function createRebaseClient(options) {
4708
4856
  });
4709
4857
  if (offlineManager.keepsLastUser) {
4710
4858
  let confirming = false;
4711
- offlineManager.api.onStatusChange((status) => {
4712
- if (!status.online || !status.unverifiedUser || confirming || auth.getSession()) return;
4859
+ const confirm = () => {
4860
+ if (confirming || auth.getSession()) return;
4713
4861
  confirming = true;
4714
4862
  auth.refreshSession().catch((error) => {
4715
4863
  if (error instanceof RebaseApiError && error.status !== void 0 && error.status >= 400 && error.status < 500 && !auth.getSession()) offlineManager.setScope(void 0);
4716
4864
  }).finally(() => {
4717
4865
  confirming = false;
4718
4866
  });
4867
+ };
4868
+ offlineManager.api.onStatusChange((status) => {
4869
+ if (status.online && status.unverifiedUser) confirm();
4719
4870
  });
4871
+ offlineManager.onReachedUnconfirmed(confirm);
4720
4872
  }
4721
4873
  }
4722
4874
  /**
@@ -4777,11 +4929,12 @@ function createRebaseClient(options) {
4777
4929
  }
4778
4930
  });
4779
4931
  }
4932
+ const slug = toSnakeCase(prop);
4780
4933
  if (!untypedWarned) {
4781
4934
  untypedWarned = true;
4782
- console.warn(`[Rebase] Untyped data access detected (client.data.${prop}). Collection names are resolved via snake_case conversion, which may cause silent 404s at request time. Pass a \`collections\` dictionary to createRebaseClient() or use the generated SDK for type-safe access.`);
4935
+ console.warn(`[Rebase] Untyped data access detected: client.data.${prop} was sent to /data/${slug}, its name in snake_case, because this client has no \`collections\` dictionary. A collection whose slug is not that (a kebab-case slug, a renamed accessor) answers 404. With generated types, pass \`collections: collectionsDictionary\` (from generated/sdk/database.types) to createRebaseClient(); a typed client needs it. Otherwise use client.data.collection("<slug>").`);
4783
4936
  }
4784
- return collection(toSnakeCase(prop));
4937
+ return collection(slug);
4785
4938
  },
4786
4939
  has(_target, prop) {
4787
4940
  if (prop === "collection") return true;
@@ -4848,6 +5001,28 @@ channel: (name, options) => {
4848
5001
  onUnauthorized = handler;
4849
5002
  },
4850
5003
  resolveToken: transport.resolveToken,
5004
+ simulate: (simulation) => {
5005
+ const derived = createRebaseClient({
5006
+ baseUrl: options.baseUrl,
5007
+ apiPath: options.apiPath,
5008
+ fetch: options.fetch,
5009
+ collections: options.collections,
5010
+ schemaVersion: options.schemaVersion,
5011
+ headers: {
5012
+ ...options.headers ?? {},
5013
+ ...simulationHeaders(simulation)
5014
+ },
5015
+ realtime: false,
5016
+ auth: {
5017
+ storage: createMemoryStorage(),
5018
+ persistSession: false,
5019
+ autoRefresh: false
5020
+ }
5021
+ });
5022
+ derived.setAuthTokenGetter(() => transport.resolveToken());
5023
+ derived.setOnUnauthorized(() => onUnauthorized());
5024
+ return derived;
5025
+ },
4851
5026
  baseUrl: transport.baseUrl,
4852
5027
  apiPath: transport.apiPath,
4853
5028
  collection,