@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/src/transport.ts CHANGED
@@ -7,6 +7,7 @@ import { rebaseReviver } from "./reviver";
7
7
  // `import { RebaseApiError } from ".../transport"` path used across the SDK.
8
8
  export { RebaseApiError } from "@rebasepro/types";
9
9
  export type { RebaseErrorInit } from "@rebasepro/types";
10
+ import { RebaseClientError } from "@rebasepro/types";
10
11
 
11
12
  export interface RebaseClientConfig {
12
13
  /**
@@ -133,6 +134,47 @@ export const ANONYMOUS_SERVER_CLIENT_WARNING =
133
134
  export type FindParams<M extends Record<string, unknown> = Record<string, unknown>> = TypesFindParams<M>;
134
135
  export type FindResponse<T> = TypesFindResponse<T extends Record<string, unknown> ? T : Record<string, unknown>>;
135
136
 
137
+ /**
138
+ * Refuse a filter whose *value* is missing.
139
+ *
140
+ * `where: { status: ["==", undefined] }` used to serialize to the literal
141
+ * string, so `status=eq.undefined` went out on the wire and the server dutifully
142
+ * looked for rows whose status is the four-letter word "undefined". The caller
143
+ * saw an empty page, not an error — the classic shape of a variable that was
144
+ * never set.
145
+ *
146
+ * Dropping the condition instead would be worse than sending it: the query
147
+ * would come back *unfiltered*, which for an ownership or tenant filter means
148
+ * returning rows the caller never asked to see. So this is a hard error, and
149
+ * both correct spellings are named in the message: omit the key to skip the
150
+ * filter, or use `["is-null", null]` to match SQL NULL (which still
151
+ * serializes — `null` is a value, `undefined` is the absence of one).
152
+ */
153
+ function assertNoUndefinedFilterValues(where: Record<string, unknown>): void {
154
+ const reject = (field: string, op: unknown): never => {
155
+ throw new RebaseClientError(
156
+ `Filter on "${field}" has an undefined value (["${String(op)}", undefined]). `
157
+ + `Omit "${field}" from \`where\` to skip the filter, or use ["is-null", null] to match SQL NULL.`
158
+ );
159
+ };
160
+
161
+ for (const [field, condition] of Object.entries(where)) {
162
+ // An entirely absent condition is the documented way to skip a filter.
163
+ if (condition === undefined) continue;
164
+ if (!Array.isArray(condition)) continue;
165
+
166
+ // Either one `[op, value]` tuple or an array of them.
167
+ const tuples = Array.isArray(condition[0]) ? condition as unknown[][] : [condition as unknown[]];
168
+ for (const tuple of tuples) {
169
+ if (!Array.isArray(tuple) || tuple.length !== 2) continue;
170
+ const [op, value] = tuple;
171
+ if (value === undefined) reject(field, op);
172
+ // `["in", [...]]` — a hole in the list is the same mistake.
173
+ if (Array.isArray(value) && value.some(v => v === undefined)) reject(field, op);
174
+ }
175
+ }
176
+ }
177
+
136
178
  export function buildQueryString(params?: FindParams): string {
137
179
  if (!params) return "";
138
180
  const parts: string[] = [];
@@ -161,6 +203,7 @@ export function buildQueryString(params?: FindParams): string {
161
203
  }
162
204
 
163
205
  if (params.where) {
206
+ assertNoUndefinedFilterValues(params.where);
164
207
  const serialized = serializeFilter(params.where);
165
208
  for (const [field, value] of Object.entries(serialized)) {
166
209
  if (Array.isArray(value)) {
package/src/websocket.ts CHANGED
@@ -97,6 +97,18 @@ export class RebaseWebSocketClient {
97
97
  /** Set by `close()`. Blocks any later operation from silently redialling. */
98
98
  private closedByCaller = false;
99
99
 
100
+ /**
101
+ * Set when the backoff budget ran out, cleared by anything that earns a
102
+ * fresh one.
103
+ *
104
+ * Unlike {@link closedByCaller} this is not final — nobody *asked* for the
105
+ * socket to stay down. Five attempts with exponential backoff is about a
106
+ * minute, which a laptop lid, a wifi handover or a backend rollout all
107
+ * exceed routinely; treating that as permanent meant realtime silently
108
+ * stopped for the rest of the page's life, with a reload the only cure.
109
+ */
110
+ private gaveUp = false;
111
+
100
112
  /**
101
113
  * Whether a socket exists at all (open or still opening).
102
114
  *
@@ -249,20 +261,52 @@ export class RebaseWebSocketClient {
249
261
  }
250
262
  return;
251
263
  }
264
+ this.installOnlineListener();
252
265
  if (this.ws || this.reconnectTimeout) return;
266
+ // A caller asking for a connection is a fresh reason to try, so it also
267
+ // buys a fresh backoff budget. Without this, the first `subscribe`
268
+ // after a give-up would exhaust the counter on attempt one.
269
+ if (this.gaveUp) {
270
+ this.gaveUp = false;
271
+ this.reconnectAttempts = 0;
272
+ }
253
273
  this.initWebSocket();
254
274
  }
255
275
 
276
+ /**
277
+ * The browser says the network is back — the usual reason the budget ran
278
+ * out in the first place. Registered lazily so a Node client, or a page
279
+ * that never subscribes, adds no listener.
280
+ */
281
+ private installOnlineListener() {
282
+ if (this.onlineListener || typeof window === "undefined" || typeof window.addEventListener !== "function") return;
283
+ this.onlineListener = () => {
284
+ if (this.closedByCaller || !this.gaveUp) return;
285
+ console.debug("Network is back — retrying the realtime connection");
286
+ this.ensureConnected();
287
+ };
288
+ window.addEventListener("online", this.onlineListener);
289
+ }
290
+
291
+ private onlineListener: (() => void) | null = null;
292
+
256
293
  /**
257
294
  * Authenticate the WebSocket connection
258
295
  */
259
296
  async authenticate(token: string): Promise<void> {
260
297
  return new Promise((resolve, reject) => {
261
- const requestId = `auth_${Date.now()}`;
298
+ // Random suffix, like every other request id here. Two auth
299
+ // attempts started in the same millisecond produced the same id,
300
+ // and `pendingRequests` is a Map: the second registration replaced
301
+ // the first, so one caller's promise was never settled either way.
302
+ const requestId = `auth_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
262
303
 
263
304
  const timeout = setTimeout(() => {
264
305
  this.pendingRequests.delete(requestId);
265
- this.authPromise = null; // Clear promise so we can retry later
306
+ // `authPromise` belongs to `ensureAuthenticated`, which clears
307
+ // it when the whole attempt — retries included — settles.
308
+ // Clearing it here let a second attempt start while this one
309
+ // was still retrying.
266
310
  reject(new Error("Authentication timeout"));
267
311
  }, 30000);
268
312
 
@@ -325,6 +369,10 @@ export class RebaseWebSocketClient {
325
369
  */
326
370
  public disconnect(permanent = false): void {
327
371
  if (permanent) this.closedByCaller = true;
372
+ if (permanent && this.onlineListener && typeof window !== "undefined") {
373
+ window.removeEventListener("online", this.onlineListener);
374
+ this.onlineListener = null;
375
+ }
328
376
  this.isAuthenticated = false;
329
377
  this.authPromise = null;
330
378
  if (this.reconnectTimeout) {
@@ -354,7 +402,10 @@ export class RebaseWebSocketClient {
354
402
  }
355
403
 
356
404
  try {
357
- this.ws = new this.WebSocketConstructor(this.websocketUrl);
405
+ // Captured so each handler can tell "my socket" from a later one:
406
+ // a close arriving after a redial must not clear the new socket.
407
+ const socket = new this.WebSocketConstructor(this.websocketUrl);
408
+ this.ws = socket;
358
409
 
359
410
  this.ws!.onopen = async () => {
360
411
  console.debug("Connected to PostgreSQL backend");
@@ -403,6 +454,11 @@ export class RebaseWebSocketClient {
403
454
 
404
455
  this.ws!.onclose = () => {
405
456
  console.debug("Disconnected from PostgreSQL backend");
457
+ // Release the dead socket. `ensureConnected` returns early
458
+ // while `this.ws` is set, so holding a closed one made the
459
+ // "give up after N attempts" state permanent: nothing could
460
+ // ever redial, not even a fresh `subscribe`.
461
+ if (this.ws === socket) this.ws = null;
406
462
  this.isConnected = false;
407
463
  this.isAuthenticated = false;
408
464
  this.authPromise = null;
@@ -451,6 +507,7 @@ export class RebaseWebSocketClient {
451
507
  console.error("Max reconnection attempts reached");
452
508
  // Nothing will re-subscribe now, so stop every subscription that
453
509
  // never loaded from spinning forever.
510
+ this.gaveUp = true;
454
511
  this.failAllPendingSubscriptions(
455
512
  new RebaseApiError("Connection lost", { code: "CONNECTION_LOST" })
456
513
  );
@@ -845,26 +902,39 @@ export class RebaseWebSocketClient {
845
902
  // If already authenticated or no token getter, skip
846
903
  if (this.isAuthenticated || !this.getAuthToken) return;
847
904
 
848
- // If auth is in progress, wait for it
849
- if (this.authPromise) {
850
- await this.authPromise;
851
- return;
905
+ // If auth is in progress, wait for it.
906
+ //
907
+ // The share has to be published *before* the first await, which is why
908
+ // the work lives in its own method. The guard used to be read here and
909
+ // the promise only assigned after `await this.getAuthToken()` — so
910
+ // every caller that arrived during that gap saw `null` and started an
911
+ // attempt of its own. A queue flushing six subscriptions on connect
912
+ // does exactly that, and each extra attempt raced the others through a
913
+ // single `pendingRequests` slot: one settled, the rest hung until their
914
+ // own 30s timeout, and the frames waiting behind them were never sent.
915
+ // Their subscriptions then reported "Subscription timed out" — the
916
+ // board's columns loading one at a time, or not at all.
917
+ if (!this.authPromise) {
918
+ this.authPromise = this.runAuthentication(retryCount);
919
+ this.authPromise.finally(() => {
920
+ this.authPromise = null;
921
+ }).catch(() => undefined);
852
922
  }
923
+ await this.authPromise;
924
+ }
853
925
 
926
+ private async runAuthentication(retryCount: number): Promise<void> {
854
927
  // Try to authenticate with retries
855
928
  let lastError: unknown = null;
856
929
 
857
930
  for (let attempt = 0; attempt < retryCount; attempt++) {
858
931
  try {
859
- const token = await this.getAuthToken();
932
+ const token = await this.getAuthToken!();
860
933
  if (!token) throw new Error("user not logged in");
861
- this.authPromise = this.authenticate(token);
862
- await this.authPromise;
863
- this.authPromise = null;
934
+ await this.authenticate(token);
864
935
  console.debug("WebSocket authenticated on demand");
865
936
  return; // Success
866
937
  } catch (error: unknown) {
867
- this.authPromise = null;
868
938
  lastError = error;
869
939
 
870
940
  const errMsg = error instanceof Error ? error.message : String(error);