@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 +3 -5
- package/dist/index.d.ts +11 -7
- package/dist/index.es.js +121 -20
- package/dist/index.es.js.map +1 -1
- package/dist/websocket.d.ts +19 -0
- package/package.json +8 -8
- package/src/auth.ts +30 -5
- package/src/client-close.test.ts +80 -0
- package/src/collection.test.ts +50 -10
- package/src/collection.ts +7 -1
- package/src/data-proxy.test.ts +22 -6
- package/src/functions.ts +9 -1
- package/src/index.ts +25 -11
- package/src/offline-idb-store.test.ts +23 -4
- package/src/realtime-concurrent-subscribe.test.ts +102 -0
- package/src/realtime-optout.test.ts +38 -4
- package/src/transport.ts +43 -0
- package/src/websocket.ts +82 -12
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
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);
|