@rebasepro/client 0.17.3 → 0.18.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.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/dist/auth.d.ts +80 -0
  3. package/dist/functions.d.ts +6 -1
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.es.js +471 -94
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/offline-connectivity.d.ts +12 -1
  8. package/dist/offline.d.ts +23 -1
  9. package/dist/query-contract.types.d.ts +30 -0
  10. package/dist/realtime-channel.d.ts +29 -1
  11. package/dist/sdk_query_builder.d.ts +21 -2
  12. package/dist/transport.d.ts +24 -0
  13. package/package.json +28 -15
  14. package/src/admin.ts +0 -90
  15. package/src/anonymous-client-guard.test.ts +0 -190
  16. package/src/api-keys.ts +0 -87
  17. package/src/auth-listener-errors.test.ts +0 -57
  18. package/src/auth-refresh-overflow.test.ts +0 -89
  19. package/src/auth.ts +0 -982
  20. package/src/backups.ts +0 -40
  21. package/src/client-close.test.ts +0 -80
  22. package/src/collection-listen-meta.test.ts +0 -105
  23. package/src/collection-observe.test.ts +0 -138
  24. package/src/collection.test.ts +0 -293
  25. package/src/collection.ts +0 -525
  26. package/src/cron.test.ts +0 -164
  27. package/src/cron.ts +0 -62
  28. package/src/data-proxy.test.ts +0 -183
  29. package/src/errors.ts +0 -9
  30. package/src/functions.ts +0 -82
  31. package/src/index.ts +0 -639
  32. package/src/like-pattern-redos.test.ts +0 -61
  33. package/src/offline-codec.ts +0 -79
  34. package/src/offline-connectivity.test.ts +0 -191
  35. package/src/offline-connectivity.ts +0 -255
  36. package/src/offline-idb-store.test.ts +0 -340
  37. package/src/offline-integration.test.ts +0 -180
  38. package/src/offline-query.test.ts +0 -431
  39. package/src/offline-query.ts +0 -529
  40. package/src/offline-store.ts +0 -357
  41. package/src/offline-sync-engine.test.ts +0 -857
  42. package/src/offline.test.ts +0 -897
  43. package/src/offline.ts +0 -1928
  44. package/src/query-contract.types.ts +0 -206
  45. package/src/query_builder.ts +0 -1
  46. package/src/realtime-channel.test.ts +0 -542
  47. package/src/realtime-channel.ts +0 -539
  48. package/src/realtime-concurrent-subscribe.test.ts +0 -102
  49. package/src/realtime-error-surfacing.test.ts +0 -105
  50. package/src/realtime-optout.test.ts +0 -279
  51. package/src/realtime-row-identity.test.ts +0 -254
  52. package/src/realtime-subscription-key.test.ts +0 -92
  53. package/src/reviver.ts +0 -39
  54. package/src/sdk_query_builder.ts +0 -206
  55. package/src/storage-key-encoding.test.ts +0 -65
  56. package/src/storage-registry.ts +0 -102
  57. package/src/storage.ts +0 -253
  58. package/src/transport-baseurl.test.ts +0 -101
  59. package/src/transport.ts +0 -505
  60. package/src/vector-search-listen.test.ts +0 -42
  61. package/src/vector-search-query.test.ts +0 -55
  62. package/src/websocket-url.test.ts +0 -97
  63. package/src/websocket.ts +0 -1837
package/src/websocket.ts DELETED
@@ -1,1837 +0,0 @@
1
- import {
2
- DeleteProps,
3
- CollectionConfig,
4
- FetchCollectionProps,
5
- FetchOneProps,
6
- SaveProps,
7
- WebSocketMessage,
8
- WebSocketErrorPayload,
9
- CollectionUpdateMessage,
10
- SingleUpdateMessage,
11
- TableMetadata,
12
- BranchInfo,
13
- RebaseApiError
14
- } from "@rebasepro/types";
15
- import { buildCompositeId, COMPOSITE_ID_SEPARATOR, type PrimaryKeyInfo } from "@rebasepro/common";
16
- import { rebaseReviver } from "./reviver";
17
-
18
-
19
-
20
- /**
21
- * Extract error message and code from a WebSocket message payload.
22
- * Handles both `{ error: string }` and `{ error: { message, code } }` shapes.
23
- */
24
- function extractMessageError(message: WebSocketMessage): { errorMessage: string; errorCode?: string } {
25
- const payload = message.payload as WebSocketErrorPayload | undefined;
26
- const errPayload = payload?.error;
27
- const errorMessage = typeof errPayload === "object"
28
- ? errPayload.message
29
- : payload?.message || (typeof errPayload === "string" ? errPayload : undefined) || message.error || "Unknown error";
30
- const errorCode = typeof errPayload === "object"
31
- ? errPayload.code
32
- : payload?.code;
33
- // Callers treat this as a string (`.toLowerCase()` in isAuthError). A frame
34
- // carrying a non-string here would throw inside the message handler, where
35
- // the surrounding try/catch would swallow it — and a subscription error that
36
- // never reaches its listener is a view stuck loading forever.
37
- const safeMessage = typeof errorMessage === "string"
38
- ? errorMessage
39
- : (errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage));
40
- return { errorMessage: safeMessage,
41
- errorCode };
42
- }
43
-
44
- export interface RebaseWebSocketConfig {
45
- websocketUrl: string;
46
- /** Optional auth token getter for WebSocket authentication */
47
- getAuthToken?: () => Promise<string | null>;
48
- /** Optional WebSocket constructor to override globalThis.WebSocket (e.g. for Node environments) */
49
- WebSocket?: typeof WebSocket;
50
- /** Callback to handle unauthorized requests or token expiration (refreshes auth session) */
51
- onUnauthorized?: () => Promise<boolean>;
52
- }
53
-
54
-
55
- /**
56
- * Broadcast and presence frames.
57
- *
58
- * Fire-and-forget (the server sends no response envelope), and exempt from the
59
- * client-side auth gate — a public channel is usable without an account.
60
- */
61
- const CHANNEL_MESSAGE_TYPES = new Set([
62
- "join_channel",
63
- "leave_channel",
64
- "broadcast",
65
- "presence_track",
66
- "presence_untrack",
67
- "presence_state",
68
- // The catch-up request. Like `presence_state`, its answer comes back as a
69
- // channel-addressed frame rather than a response envelope, so it must not
70
- // be given a pending request to wait on.
71
- "channel_history"
72
- ]);
73
-
74
- /**
75
- * Low-level realtime WebSocket client.
76
- *
77
- * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
78
- * manages this internally (exposed as `client.ws`, typed by the minimal
79
- * `RebaseWebSocket` contract in `@rebasepro/types`). It stays exported from the
80
- * package root for the same reason it always was — a data-source driver may
81
- * instantiate it directly — but nothing in this repo does since
82
- * `@rebasepro/client-postgres` was removed; its surface may change without a
83
- * major bump.
84
- */
85
- export class RebaseWebSocketClient {
86
- private websocketUrl: string;
87
- private ws: WebSocket | null = null;
88
- public getAuthToken?: () => Promise<string | null>;
89
- private listeners = new Map<string, Set<(...args: unknown[]) => void>>();
90
-
91
- /** Channel-name → handlers, for broadcast and presence frames. */
92
- private channelHandlers = new Map<string, Set<(message: Record<string, unknown>) => void>>();
93
-
94
- /** Set by `close()`. Blocks any later operation from silently redialling. */
95
- private closedByCaller = false;
96
-
97
- /**
98
- * Set when the backoff budget ran out, cleared by anything that earns a
99
- * fresh one.
100
- *
101
- * Unlike {@link closedByCaller} this is not final — nobody *asked* for the
102
- * socket to stay down. Five attempts with exponential backoff is about a
103
- * minute, which a laptop lid, a wifi handover or a backend rollout all
104
- * exceed routinely; treating that as permanent meant realtime silently
105
- * stopped for the rest of the page's life, with a reload the only cure.
106
- */
107
- private gaveUp = false;
108
-
109
- /**
110
- * Whether a socket exists at all (open or still opening).
111
- *
112
- * Lets callers distinguish "authenticate the live socket" from "there is
113
- * nothing to authenticate yet", without that question forcing a dial.
114
- */
115
- public get hasSocket(): boolean {
116
- return this.ws !== null;
117
- }
118
-
119
- /** So the "no WebSocket in this environment" warning is said once, not per call. */
120
- private warnedNoWebSocket = false;
121
-
122
- /** Subscribe to broadcast/presence frames for one channel. */
123
- public onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void {
124
- if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, new Set());
125
- this.channelHandlers.get(channel)!.add(handler);
126
- return () => {
127
- const handlers = this.channelHandlers.get(channel);
128
- if (!handlers) return;
129
- handlers.delete(handler);
130
- if (handlers.size === 0) this.channelHandlers.delete(channel);
131
- };
132
- }
133
-
134
- /** Notified after the socket comes back, so channels can re-join. */
135
- public onReconnect(handler: () => void): () => void {
136
- return this.on("reconnect", handler);
137
- }
138
-
139
- public on(event: "connect" | "disconnect" | "reconnect" | "error", cb: (...args: unknown[]) => void) {
140
- if (!this.listeners.has(event)) {
141
- this.listeners.set(event, new Set());
142
- }
143
- this.listeners.get(event)!.add(cb);
144
- return () => this.listeners.get(event)!.delete(cb);
145
- }
146
-
147
- private emit(event: string, ...args: unknown[]) {
148
- if (this.listeners.has(event)) {
149
- this.listeners.get(event)!.forEach(cb => cb(...args));
150
- }
151
- }
152
-
153
- // New: Subscription deduplication management with optimizations
154
- private collectionSubscriptions = new Map<string, {
155
- backendSubscriptionId: string;
156
- callbacks: Map<string, {
157
- onUpdate: (rows: Record<string, unknown>[]) => void;
158
- onError?: (error: Error) => void;
159
- }>;
160
- props: FetchCollectionProps;
161
- latestData?: Record<string, unknown>[]; // Cache the latest flat rows
162
- lastUpdated?: number; // Timestamp for cache invalidation
163
- isInitialDataReceived?: boolean; // Track if we got initial data
164
- /**
165
- * A `subscribe_collection` frame is on the wire and its initial payload
166
- * has not arrived yet. Without this, a subscription whose subscribe
167
- * failed is indistinguishable from one still loading, and every later
168
- * listener attaches to it and waits forever.
169
- */
170
- subscribeInFlight?: boolean;
171
- /**
172
- * Watchdog for the above. `subscribe_collection` expects no response
173
- * envelope, so it is not covered by `pendingRequests`' timeout — a lost
174
- * initial payload would otherwise hang the subscription indefinitely.
175
- */
176
- subscribeTimeout?: ReturnType<typeof setTimeout>;
177
- /**
178
- * The key columns of this collection, as told by the server on a patch.
179
- * Rows are columns only, and the SDK holds no collection config, so
180
- * without this there is nothing to derive an address from.
181
- */
182
- pks?: PrimaryKeyInfo[];
183
- }>();
184
-
185
- private singleSubscriptions = new Map<string, {
186
- backendSubscriptionId: string;
187
- callbacks: Map<string, {
188
- onUpdate: (row: Record<string, unknown> | null) => void;
189
- onError?: (error: Error) => void;
190
- }>;
191
- props: FetchOneProps;
192
- latestData?: Record<string, unknown> | null; // Cache the latest flat row
193
- lastUpdated?: number; // Timestamp for cache invalidation
194
- isInitialDataReceived?: boolean; // Track if we got initial data
195
- /** See the collection subscription counterparts. */
196
- subscribeInFlight?: boolean;
197
- subscribeTimeout?: ReturnType<typeof setTimeout>;
198
- }>();
199
-
200
- // Maps to quickly find subscription by backend subscription ID
201
- private backendToCollectionKey = new Map<string, string>();
202
- private backendToEntityKey = new Map<string, string>();
203
-
204
-
205
- private pendingRequests = new Map<string, {
206
- resolve: (p: unknown) => void;
207
- reject: (p: Error) => void;
208
- message?: Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void }
209
- }>();
210
- private reconnectAttempts = 0;
211
- private maxReconnectAttempts = 5;
212
- private isConnected = false;
213
- private messageQueue: Record<string, unknown>[] = [];
214
- private requestTimeoutMs = 30000;
215
- private subscriptionTimeoutMs = 30000;
216
- private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
217
-
218
- private isAuthenticated = false;
219
- private authPromise: Promise<void> | null = null;
220
- private WebSocketConstructor: typeof WebSocket | undefined;
221
- public onUnauthorized?: () => Promise<boolean>;
222
- private refreshInProgress: Promise<boolean> | null = null;
223
-
224
- constructor(config: RebaseWebSocketConfig) {
225
- this.websocketUrl = config.websocketUrl;
226
- this.getAuthToken = config.getAuthToken;
227
- this.onUnauthorized = config.onUnauthorized;
228
- this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : undefined);
229
-
230
- // Deliberately does NOT dial here. Constructing the client is not a
231
- // statement that the app wants a socket — `createRebaseClient` builds
232
- // one whenever realtime is not explicitly disabled, so connecting here
233
- // opened a socket on every page load of every app that merely *might*
234
- // subscribe later. Anonymous-first apps paid that on every visit, to
235
- // authenticate with nothing, which left them choosing between "socket
236
- // on every page load" and "no channels at all".
237
- //
238
- // The environment warning is also deferred: an app that never
239
- // subscribes should say nothing at all. See `ensureConnected`.
240
- }
241
-
242
- /**
243
- * Open the socket if it is not open (or opening) already.
244
- *
245
- * Idempotent, synchronous, and safe to call on every operation that needs a
246
- * live socket — `initWebSocket` already no-ops on an open socket and is
247
- * re-entrant, since the reconnect path has always called it.
248
- */
249
- public ensureConnected(): void {
250
- // An explicit `close()` is final. Without this, one queued frame could
251
- // redial a socket the caller just released and keep a Node process
252
- // alive forever.
253
- if (this.closedByCaller) return;
254
- if (!this.WebSocketConstructor) {
255
- if (!this.warnedNoWebSocket) {
256
- this.warnedNoWebSocket = true;
257
- console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
258
- }
259
- return;
260
- }
261
- this.installOnlineListener();
262
- if (this.ws || this.reconnectTimeout) return;
263
- // A caller asking for a connection is a fresh reason to try, so it also
264
- // buys a fresh backoff budget. Without this, the first `subscribe`
265
- // after a give-up would exhaust the counter on attempt one.
266
- if (this.gaveUp) {
267
- this.gaveUp = false;
268
- this.reconnectAttempts = 0;
269
- }
270
- this.initWebSocket();
271
- }
272
-
273
- /**
274
- * The browser says the network is back — the usual reason the budget ran
275
- * out in the first place. Registered lazily so a Node client, or a page
276
- * that never subscribes, adds no listener.
277
- */
278
- private installOnlineListener() {
279
- if (this.onlineListener || typeof window === "undefined" || typeof window.addEventListener !== "function") return;
280
- this.onlineListener = () => {
281
- if (this.closedByCaller || !this.gaveUp) return;
282
- console.debug("Network is back — retrying the realtime connection");
283
- this.ensureConnected();
284
- };
285
- window.addEventListener("online", this.onlineListener);
286
- }
287
-
288
- private onlineListener: (() => void) | null = null;
289
-
290
- /**
291
- * Authenticate the WebSocket connection
292
- */
293
- async authenticate(token: string): Promise<void> {
294
- return new Promise((resolve, reject) => {
295
- // Random suffix, like every other request id here. Two auth
296
- // attempts started in the same millisecond produced the same id,
297
- // and `pendingRequests` is a Map: the second registration replaced
298
- // the first, so one caller's promise was never settled either way.
299
- const requestId = `auth_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
300
-
301
- const timeout = setTimeout(() => {
302
- this.pendingRequests.delete(requestId);
303
- // `authPromise` belongs to `ensureAuthenticated`, which clears
304
- // it when the whole attempt — retries included — settles.
305
- // Clearing it here let a second attempt start while this one
306
- // was still retrying.
307
- reject(new Error("Authentication timeout"));
308
- }, 30000);
309
-
310
- this.pendingRequests.set(requestId, {
311
- resolve: () => {
312
- clearTimeout(timeout);
313
- this.isAuthenticated = true;
314
- resolve();
315
- },
316
- reject: (error) => {
317
- clearTimeout(timeout);
318
- reject(error);
319
- }
320
- });
321
-
322
- const message = {
323
- type: "AUTHENTICATE",
324
- requestId,
325
- payload: { token }
326
- };
327
-
328
- if (!this.isConnected || !this.ws) {
329
- this.messageQueue.unshift(message); // Auth should be first
330
- } else {
331
- this.ws.send(JSON.stringify(message));
332
- }
333
- });
334
- }
335
-
336
- /**
337
- * Set the auth token getter function
338
- */
339
- setAuthTokenGetter(getAuthToken: () => Promise<string | null>): void {
340
- this.getAuthToken = getAuthToken;
341
- // Auto-authenticate if we are already connected but didn't have the token getter yet
342
- if (this.isConnected && !this.isAuthenticated && !this.authPromise) {
343
- console.debug("WebSocket auto-authenticating after token getter set");
344
- this.getAuthToken().then(token => {
345
- if (!this.ws) return; // Prevent memory leaks / actions after disconnect
346
- if (token) {
347
- this.authenticate(token).catch(e => {
348
- if (this.ws) console.debug("WebSocket auto-auth skipped:", e?.message || e);
349
- });
350
- }
351
- }).catch(e => {
352
- // User not logged in or auth still loading — this is expected,
353
- // the WebSocket will authenticate on-demand when a request is made.
354
- if (this.ws) console.debug("WebSocket auto-auth skipped:", e?.message || e);
355
- });
356
- }
357
- }
358
-
359
- /**
360
- * Drop the socket.
361
- *
362
- * `permanent` distinguishes the two callers. Signing out drops the socket
363
- * but the client stays usable — a later subscribe should reconnect
364
- * anonymously. `client.close()` is the caller saying they are done, and
365
- * must not be undone by a stray queued frame.
366
- */
367
- public disconnect(permanent = false): void {
368
- if (permanent) this.closedByCaller = true;
369
- if (permanent && this.onlineListener && typeof window !== "undefined") {
370
- window.removeEventListener("online", this.onlineListener);
371
- this.onlineListener = null;
372
- }
373
- this.isAuthenticated = false;
374
- this.authPromise = null;
375
- if (this.reconnectTimeout) {
376
- clearTimeout(this.reconnectTimeout);
377
- this.reconnectTimeout = null;
378
- }
379
- if (this.ws) {
380
- this.ws.onclose = null; // Prevent reconnect on explicit disconnect
381
- this.ws.onerror = null; // Prevent errors on explicit disconnect
382
- this.ws.onopen = null;
383
- this.ws.onmessage = null;
384
- this.ws.close();
385
- this.ws = null;
386
- }
387
- }
388
-
389
- // Initialize WebSocket connection
390
- private initWebSocket() {
391
- if (!this.WebSocketConstructor) return;
392
- if (this.ws?.readyState === this.WebSocketConstructor.OPEN) return;
393
-
394
- // Guard against race condition: if a previous socket is still connecting, tear it down
395
- if (this.ws) {
396
- this.ws.onclose = null;
397
- this.ws.close();
398
- this.ws = null;
399
- }
400
-
401
- try {
402
- // Captured so each handler can tell "my socket" from a later one:
403
- // a close arriving after a redial must not clear the new socket.
404
- const socket = new this.WebSocketConstructor(this.websocketUrl);
405
- this.ws = socket;
406
-
407
- this.ws!.onopen = async () => {
408
- console.debug("Connected to PostgreSQL backend");
409
- const wasReconnect = this.reconnectAttempts > 0;
410
- this.isConnected = true;
411
- this.reconnectAttempts = 0;
412
-
413
- // Auto-authenticate if token getter is available
414
- if (this.getAuthToken && !this.isAuthenticated) {
415
- try {
416
- const token = await this.getAuthToken();
417
- if (token) {
418
- await this.authenticate(token);
419
- console.debug("WebSocket auto-authenticated");
420
- }
421
- } catch (error) {
422
- // User not logged in or auth still loading — this is expected.
423
- // Authentication will happen on-demand when the user logs in.
424
- console.debug("WebSocket connected without auth:", (error as Error)?.message || error);
425
- }
426
- }
427
-
428
- this.emit(wasReconnect ? "reconnect" : "connect");
429
- this.processMessageQueue();
430
-
431
- // Re-subscribe all active subscriptions after reconnect.
432
- // The server-side subscription state was lost when the connection dropped,
433
- // so we need to re-register every active subscription.
434
- if (wasReconnect) {
435
- this.resubscribeAll();
436
- }
437
-
438
- // Subscribes requested while offline have just gone out; they
439
- // could not be watchdogged at request time.
440
- this.armPendingSubscribeWatchdogs();
441
- };
442
-
443
- this.ws!.onmessage = (event) => {
444
- try {
445
- const message = JSON.parse(event.data, rebaseReviver);
446
- this.handleWebSocketMessage(message);
447
- } catch (error) {
448
- console.error("Error parsing WebSocket message:", error);
449
- }
450
- };
451
-
452
- this.ws!.onclose = () => {
453
- console.debug("Disconnected from PostgreSQL backend");
454
- // Release the dead socket. `ensureConnected` returns early
455
- // while `this.ws` is set, so holding a closed one made the
456
- // "give up after N attempts" state permanent: nothing could
457
- // ever redial, not even a fresh `subscribe`.
458
- if (this.ws === socket) this.ws = null;
459
- this.isConnected = false;
460
- this.isAuthenticated = false;
461
- this.authPromise = null;
462
- // The reconnect path re-subscribes everything; a watchdog firing
463
- // in the meantime would tear down healthy subscriptions.
464
- this.suspendSubscribeWatchdogs();
465
- this.emit("disconnect");
466
-
467
- // Re-queue pending requests so the UI doesn't hang indefinitely or crash
468
- for (const [reqId, request] of this.pendingRequests.entries()) {
469
- if (reqId.startsWith("auth_")) {
470
- request.reject(new Error("Connection closed during authentication"));
471
- } else if (request.message) {
472
- request.message._queuedResolve = request.resolve;
473
- request.message._queuedReject = request.reject;
474
- this.messageQueue.push(request.message);
475
- } else {
476
- request.reject(new RebaseApiError("Connection closed"));
477
- }
478
- this.pendingRequests.delete(reqId);
479
- }
480
-
481
- this.attemptReconnect();
482
- };
483
-
484
- this.ws!.onerror = (error) => {
485
- console.error("WebSocket error:", error);
486
- this.isConnected = false;
487
- this.emit("error", error);
488
- };
489
- } catch (error) {
490
- console.error("Failed to initialize WebSocket:", error);
491
- this.attemptReconnect();
492
- }
493
- }
494
-
495
- private processMessageQueue() {
496
- while (this.messageQueue.length > 0 && this.isConnected) {
497
- const message = this.messageQueue.shift();
498
- if (message) this.sendMessage(message);
499
- }
500
- }
501
-
502
- private attemptReconnect() {
503
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
504
- console.error("Max reconnection attempts reached");
505
- // Nothing will re-subscribe now, so stop every subscription that
506
- // never loaded from spinning forever.
507
- this.gaveUp = true;
508
- this.failAllPendingSubscriptions(
509
- new RebaseApiError("Connection lost", { code: "CONNECTION_LOST" })
510
- );
511
- return;
512
- }
513
-
514
- this.reconnectAttempts++;
515
- const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
516
-
517
- console.debug(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
518
-
519
- if (this.reconnectTimeout) {
520
- clearTimeout(this.reconnectTimeout);
521
- }
522
-
523
- this.reconnectTimeout = setTimeout(() => {
524
- this.reconnectTimeout = null;
525
- this.initWebSocket();
526
- }, delay);
527
- }
528
-
529
- private isAuthError(message: WebSocketMessage): boolean {
530
- if (message.type === "AUTH_ERROR") return true;
531
- const { errorMessage, errorCode } = extractMessageError(message);
532
- if (errorCode === "UNAUTHORIZED" || errorCode === "JWT_EXPIRED" || errorCode === "AUTH_ERROR") return true;
533
- const lowerMessage = errorMessage.toLowerCase();
534
- return lowerMessage.includes("unauthorized") || lowerMessage.includes("token expired") || lowerMessage.includes("token is expired") || lowerMessage.includes("invalid token") || lowerMessage.includes("session expired") || lowerMessage.includes("auth error");
535
- }
536
-
537
- private async handleAuthFailure(): Promise<boolean> {
538
- if (this.refreshInProgress) {
539
- return this.refreshInProgress;
540
- }
541
- this.refreshInProgress = (async () => {
542
- this.isAuthenticated = false;
543
- this.authPromise = null;
544
- if (this.onUnauthorized) {
545
- try {
546
- const refreshed = await this.onUnauthorized();
547
- if (refreshed && this.getAuthToken) {
548
- const token = await this.getAuthToken();
549
- if (token) {
550
- await this.authenticate(token);
551
- return true;
552
- }
553
- }
554
- } catch (error) {
555
- console.error("WebSocket auth refresh failed:", error);
556
- }
557
- }
558
- return false;
559
- })();
560
- try {
561
- return await this.refreshInProgress;
562
- } finally {
563
- this.refreshInProgress = null;
564
- }
565
- }
566
-
567
- /**
568
- * Shared logic for re-subscribing a collection or row subscription
569
- * after an auth error is resolved by refreshing credentials.
570
- */
571
- private resubscribeAfterAuthRefresh(
572
- message: WebSocketMessage,
573
- subscription: {
574
- backendSubscriptionId: string;
575
- callbacks: Map<string, { onUpdate: (...args: never[]) => void; onError?: (error: Error) => void }>;
576
- props: FetchCollectionProps | FetchOneProps;
577
- },
578
- subscriptionKey: string,
579
- idPrefix: "collection" | "row",
580
- backendKeyMap: Map<string, string>,
581
- messageType: "subscribe_collection" | "subscribe_one"
582
- ): void {
583
- this.handleAuthFailure().then(refreshed => {
584
- if (refreshed) {
585
- const oldBackendId = subscription.backendSubscriptionId;
586
- const newBackendId = `${idPrefix}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
587
- subscription.backendSubscriptionId = newBackendId;
588
- backendKeyMap.delete(oldBackendId);
589
- backendKeyMap.set(newBackendId, subscriptionKey);
590
-
591
- // Route through the helpers so the retry is watchdogged too.
592
- if (messageType === "subscribe_collection") {
593
- this.sendCollectionSubscribe(subscriptionKey);
594
- } else {
595
- this.sendEntitySubscribe(subscriptionKey);
596
- }
597
- return;
598
- }
599
-
600
- // The refresh did not produce usable credentials. Report the original
601
- // error and drop the registration, so a later mount can try again
602
- // rather than attaching to a subscription that will never load.
603
- const { errorMessage, errorCode } = extractMessageError(message);
604
- const error = new RebaseApiError(errorMessage, { code: errorCode });
605
- if (messageType === "subscribe_collection") {
606
- this.failCollectionSubscription(subscriptionKey, error);
607
- } else {
608
- this.failEntitySubscription(subscriptionKey, error);
609
- }
610
- }).catch(err => {
611
- const error = err instanceof Error ? err : new Error(String(err));
612
- if (messageType === "subscribe_collection") {
613
- this.failCollectionSubscription(subscriptionKey, error);
614
- } else {
615
- this.failEntitySubscription(subscriptionKey, error);
616
- }
617
- });
618
- }
619
-
620
- private handleWebSocketMessage(message: WebSocketMessage) {
621
- const {
622
- type,
623
- requestId,
624
- subscriptionId
625
- } = message;
626
-
627
- // Handle responses to pending requests
628
- if (requestId && this.pendingRequests.has(requestId)) {
629
- const pendingReq = this.pendingRequests.get(requestId)!;
630
- if (type === "ERROR" || type === "AUTH_ERROR" || message.error) {
631
- if (this.isAuthError(message)) {
632
- this.pendingRequests.delete(requestId);
633
- this.handleAuthFailure().then(refreshed => {
634
- if (refreshed && pendingReq.message) {
635
- this.doSendMessage(pendingReq.message, pendingReq.resolve, pendingReq.reject).catch(pendingReq.reject);
636
- } else {
637
- const { errorMessage, errorCode } = extractMessageError(message);
638
- pendingReq.reject(new RebaseApiError(errorMessage, { code: errorCode }));
639
- }
640
- }).catch(err => {
641
- pendingReq.reject(err);
642
- });
643
- } else {
644
- this.pendingRequests.delete(requestId);
645
- const { errorMessage, errorCode } = extractMessageError(message);
646
- pendingReq.reject(new RebaseApiError(errorMessage, { code: errorCode }));
647
- }
648
- } else {
649
- this.pendingRequests.delete(requestId);
650
- pendingReq.resolve(message.payload || message);
651
- }
652
- return;
653
- }
654
-
655
- // Channel traffic (broadcast / presence) is addressed by channel name
656
- // rather than by requestId or subscriptionId, so it is dispatched
657
- // before the subscription paths — none of which would match it, and
658
- // the message would otherwise fall through and be dropped silently.
659
- if (typeof message.channel === "string" &&
660
- (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
661
- const handlers = this.channelHandlers.get(message.channel);
662
- if (handlers) {
663
- for (const handler of [...handlers]) {
664
- try {
665
- handler(message as unknown as Record<string, unknown>);
666
- } catch (error) {
667
- console.error("Error in channel handler:", error);
668
- }
669
- }
670
- }
671
- return;
672
- }
673
-
674
- // Handle subscription updates for collection subscriptions
675
- if (subscriptionId && type === "collection_update") {
676
- const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
677
- if (subscriptionKey) {
678
- const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
679
- if (collectionSub) {
680
- const wireEntities = (message.rows || []) as unknown as Record<string, unknown>[];
681
- const incomingRows = wireEntities;
682
-
683
- // The keys arrive with the rows, so they are known before the
684
- // first merge — a CDC-driven change never sends a patch, and
685
- // learning them from patches alone would leave every
686
- // externally-written collection unable to match a thing.
687
- const updatePks = (message as unknown as { pks?: PrimaryKeyInfo[] }).pks;
688
- if (updatePks) collectionSub.pks = updatePks;
689
-
690
- // Structural merge: preserve cached row references for rows
691
- // whose values haven't changed. This prevents downstream React components
692
- // from re-rendering (VirtualTableCell uses deepEqual on rowData —
693
- // same reference = instant true, avoiding expensive deep comparison).
694
- const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
695
-
696
- // Cache the latest data with optimizations
697
- collectionSub.latestData = rows;
698
- collectionSub.lastUpdated = Date.now();
699
- collectionSub.isInitialDataReceived = true;
700
- // The subscribe landed — stand the watchdog down.
701
- if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
702
- collectionSub.subscribeTimeout = undefined;
703
- collectionSub.subscribeInFlight = false;
704
-
705
- // Notify all callbacks for this subscription
706
- collectionSub.callbacks.forEach(callback => {
707
- try {
708
- callback.onUpdate(rows);
709
- } catch (error) {
710
- console.error("Error in collection subscription callback:", error);
711
- if (callback.onError) {
712
- callback.onError(error instanceof Error ? error : new Error(String(error)));
713
- }
714
- }
715
- });
716
- return;
717
- }
718
- }
719
- }
720
-
721
- // Handle instant row-level patches for collection subscriptions.
722
- // These arrive before the full refetch and give immediate cross-tab feedback.
723
- if (subscriptionId && type === "collection_patch") {
724
- const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
725
- if (subscriptionKey) {
726
- const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
727
- if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
728
- const patchWireEntity = message.row ?? null;
729
- const patchMessage = message as unknown as { id: string; pks?: PrimaryKeyInfo[] };
730
- const patchEntityId = patchMessage.id;
731
- // The server knows the key columns; remember them, because the
732
- // refetch reconciliation needs them too and carries no id.
733
- if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
734
- const patchRow = patchWireEntity ? (patchWireEntity as unknown as Record<string, unknown>) : null;
735
- let updated: Record<string, unknown>[];
736
-
737
- if (patchRow === null) {
738
- // Row was deleted — remove it from the cached list
739
- updated = collectionSub.latestData.filter(
740
- e => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId)
741
- );
742
- } else {
743
- // Row was created or updated — merge into the cached list.
744
- // Matched against the patch's own address rather than
745
- // anything read off the row: `patchRow.id` is undefined
746
- // for a table not keyed on `id`, so every update looked
747
- // like a new row and was prepended as a duplicate.
748
- const idx = collectionSub.latestData.findIndex(
749
- e => this.rowAddress(e, collectionSub.pks) === String(patchEntityId)
750
- );
751
- if (idx >= 0) {
752
- // Update in place (preserve array position)
753
- updated = [...collectionSub.latestData];
754
- updated[idx] = patchRow;
755
- } else {
756
- // New row — prepend (most recently created first)
757
- updated = [patchRow, ...collectionSub.latestData];
758
- }
759
- }
760
-
761
- collectionSub.latestData = updated;
762
- collectionSub.lastUpdated = Date.now();
763
-
764
- // Fire all callbacks with the patched data
765
- collectionSub.callbacks.forEach(callback => {
766
- try {
767
- callback.onUpdate(updated);
768
- } catch (error) {
769
- console.error("Error in collection patch callback:", error);
770
- if (callback.onError) {
771
- callback.onError(error instanceof Error ? error : new Error(String(error)));
772
- }
773
- }
774
- });
775
- return;
776
- }
777
- }
778
- }
779
-
780
- // Handle subscription updates for row subscriptions
781
- if (subscriptionId && type === "single_update") {
782
- const subscriptionKey = this.backendToEntityKey.get(subscriptionId);
783
- if (subscriptionKey) {
784
- const entitySub = this.singleSubscriptions.get(subscriptionKey);
785
- if (entitySub) {
786
- const wireEntity = message.row ?? null;
787
- const row = wireEntity ? (wireEntity as unknown as Record<string, unknown>) : null;
788
- // Cache the latest data with optimizations
789
- entitySub.latestData = row;
790
- entitySub.lastUpdated = Date.now();
791
- entitySub.isInitialDataReceived = true;
792
- if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
793
- entitySub.subscribeTimeout = undefined;
794
- entitySub.subscribeInFlight = false;
795
-
796
- // Notify all callbacks for this subscription
797
- entitySub.callbacks.forEach(callback => {
798
- try {
799
- callback.onUpdate(row);
800
- } catch (error) {
801
- console.error("Error in row subscription callback:", error);
802
- if (callback.onError) {
803
- callback.onError(error instanceof Error ? error : new Error(String(error)));
804
- }
805
- }
806
- });
807
- return;
808
- }
809
- }
810
- }
811
-
812
- // Handle subscription errors
813
- if (subscriptionId && (type === "ERROR" || message.error)) {
814
- const collectionKey = this.backendToCollectionKey.get(subscriptionId);
815
- if (collectionKey) {
816
- const collectionSub = this.collectionSubscriptions.get(collectionKey);
817
- if (collectionSub) {
818
- if (this.isAuthError(message)) {
819
- this.resubscribeAfterAuthRefresh(
820
- message,
821
- collectionSub,
822
- collectionKey,
823
- "collection",
824
- this.backendToCollectionKey,
825
- "subscribe_collection"
826
- );
827
- return;
828
- }
829
-
830
- // The server answered, so nothing is in flight any more. Leave
831
- // the registration in place (its listeners are still mounted
832
- // and have been told), but marked idle so the next listener
833
- // re-subscribes instead of attaching to a dead entry.
834
- if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
835
- collectionSub.subscribeTimeout = undefined;
836
- collectionSub.subscribeInFlight = false;
837
-
838
- const { errorMessage, errorCode } = extractMessageError(message);
839
- const error = new RebaseApiError(errorMessage, { code: errorCode });
840
- collectionSub.callbacks.forEach(callback => {
841
- if (callback.onError) {
842
- callback.onError(error);
843
- }
844
- });
845
- return;
846
- }
847
- }
848
-
849
- const entityKey = this.backendToEntityKey.get(subscriptionId);
850
- if (entityKey) {
851
- const entitySub = this.singleSubscriptions.get(entityKey);
852
- if (entitySub) {
853
- if (this.isAuthError(message)) {
854
- this.resubscribeAfterAuthRefresh(
855
- message,
856
- entitySub,
857
- entityKey,
858
- "row",
859
- this.backendToEntityKey,
860
- "subscribe_one"
861
- );
862
- return;
863
- }
864
-
865
- if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
866
- entitySub.subscribeTimeout = undefined;
867
- entitySub.subscribeInFlight = false;
868
-
869
- const { errorMessage, errorCode } = extractMessageError(message);
870
- const error = new RebaseApiError(errorMessage, { code: errorCode });
871
- entitySub.callbacks.forEach(callback => {
872
- if (callback.onError) {
873
- callback.onError(error);
874
- }
875
- });
876
- return;
877
- }
878
- }
879
- }
880
-
881
- // An error that matched no waiter used to fall off the end of this
882
- // method and disappear. Channel frames are the ones that always do:
883
- // they are fire-and-forget by design, so no `pendingRequests` entry
884
- // exists to reject and the server's errors about them — RATE_LIMITED,
885
- // CHANNEL_FORBIDDEN, CHANNEL_HISTORY_WRITE_FAILED — were dropped while
886
- // `await channel.broadcast(...)` resolved as if it had been sent. A
887
- // console warning is the floor, not the answer: an `onError` on
888
- // `RebaseRealtimeChannel` is the shape this should eventually take.
889
- if (type === "ERROR" || type === "error" || message.error) {
890
- const { errorMessage, errorCode } = extractMessageError(message);
891
- console.warn(
892
- `[Rebase] Realtime error from the server${errorCode ? ` (${errorCode})` : ""}: ${errorMessage}`
893
- );
894
- }
895
- }
896
-
897
- private async ensureAuthenticated(retryCount = 3): Promise<void> {
898
- // If already authenticated or no token getter, skip
899
- if (this.isAuthenticated || !this.getAuthToken) return;
900
-
901
- // If auth is in progress, wait for it.
902
- //
903
- // The share has to be published *before* the first await, which is why
904
- // the work lives in its own method. The guard used to be read here and
905
- // the promise only assigned after `await this.getAuthToken()` — so
906
- // every caller that arrived during that gap saw `null` and started an
907
- // attempt of its own. A queue flushing six subscriptions on connect
908
- // does exactly that, and each extra attempt raced the others through a
909
- // single `pendingRequests` slot: one settled, the rest hung until their
910
- // own 30s timeout, and the frames waiting behind them were never sent.
911
- // Their subscriptions then reported "Subscription timed out" — the
912
- // board's columns loading one at a time, or not at all.
913
- if (!this.authPromise) {
914
- this.authPromise = this.runAuthentication(retryCount);
915
- this.authPromise.finally(() => {
916
- this.authPromise = null;
917
- }).catch(() => undefined);
918
- }
919
- await this.authPromise;
920
- }
921
-
922
- private async runAuthentication(retryCount: number): Promise<void> {
923
- // Try to authenticate with retries
924
- let lastError: unknown = null;
925
-
926
- for (let attempt = 0; attempt < retryCount; attempt++) {
927
- try {
928
- const token = await this.getAuthToken!();
929
- if (!token) throw new Error("user not logged in");
930
- await this.authenticate(token);
931
- console.debug("WebSocket authenticated on demand");
932
- return; // Success
933
- } catch (error: unknown) {
934
- lastError = error;
935
-
936
- const errMsg = error instanceof Error ? error.message : String(error);
937
- // "not logged in" / "Session expired" are definitive - don't retry
938
- if (errMsg.includes("not logged in") || errMsg.includes("Session expired")) {
939
- console.warn("WebSocket auth failed: user not logged in");
940
- throw error;
941
- }
942
-
943
- // "still loading" is transient - retry with backoff (auth controller
944
- // is restoring tokens from localStorage; it will resolve shortly)
945
- if (errMsg.includes("still loading")) {
946
- if (attempt < retryCount - 1) {
947
- const delay = Math.min(500 * (attempt + 1), 2000);
948
- await new Promise(resolve => setTimeout(resolve, delay));
949
- continue;
950
- }
951
- }
952
-
953
- // For other errors, retry with backoff
954
- if (attempt < retryCount - 1) {
955
- const delay = Math.min(1000 * (attempt + 1), 3000);
956
- console.debug(`WebSocket auth attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
957
- await new Promise(resolve => setTimeout(resolve, delay));
958
- }
959
- }
960
- }
961
-
962
- console.warn("WebSocket on-demand auth failed after retries:", lastError);
963
- throw lastError;
964
- }
965
-
966
- async reauthenticate(): Promise<void> {
967
- if (!this.getAuthToken) return;
968
-
969
- this.isAuthenticated = false;
970
- try {
971
- const token = await this.getAuthToken();
972
- if (!token) throw new Error("user not logged in");
973
- await this.authenticate(token);
974
- console.debug("WebSocket reauthenticated successfully");
975
- } catch (error) {
976
- console.error("WebSocket reauthentication failed:", error);
977
- throw error;
978
- }
979
- }
980
-
981
- /**
982
- * Public because `RebaseRealtimeChannel` sends channel frames through it.
983
- * Not part of the stable surface — prefer `client.realtime.channel(name)`.
984
- */
985
- public sendMessage(message: Record<string, unknown>): Promise<unknown> {
986
- // If already has a requestId (re-sending from queue), use the stored promise handlers
987
- const queuedMsg = message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void };
988
- if (queuedMsg._queuedResolve && queuedMsg._queuedReject) {
989
- return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
990
- }
991
-
992
- if (!this.isConnected || !this.ws) {
993
- // The queue is only ever drained by a socket opening, so something
994
- // has to open one. Before lazy connect this was guaranteed by the
995
- // constructor; now the first frame is what asks for it.
996
- this.ensureConnected();
997
- // Queue the message and return a promise that will be resolved when actually sent
998
- return new Promise<unknown>((resolve, reject) => {
999
- const queueable = message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void };
1000
- queueable._queuedResolve = resolve;
1001
- queueable._queuedReject = reject;
1002
- this.messageQueue.push(message);
1003
- });
1004
- }
1005
-
1006
- return new Promise<unknown>((resolve, reject) => {
1007
- this.doSendMessage(message, resolve, reject);
1008
- });
1009
- }
1010
-
1011
- private async doSendMessage(message: Record<string, unknown>, resolve: (value: unknown) => void, reject: (error: Error) => void): Promise<void> {
1012
- // Ensure authenticated before sending non-auth messages.
1013
- //
1014
- // Channel traffic is exempt. `ensureAuthenticated` throws "user not
1015
- // logged in" when there is no token, which rejects the frame before it
1016
- // is ever sent — so on an anonymous-first app (the kind this API was
1017
- // added for) *every* channel operation failed client-side, and the
1018
- // server never got to decide. Presence in a public room does not
1019
- // require an account. A signed-in caller still authenticates: the
1020
- // socket does it from `getAuthToken` on open, and the server authorizes
1021
- // these frames either way.
1022
- if (message.type !== "AUTHENTICATE"
1023
- && !CHANNEL_MESSAGE_TYPES.has(message.type as string)
1024
- && this.getAuthToken && !this.isAuthenticated) {
1025
- try {
1026
- await this.ensureAuthenticated();
1027
- } catch (error: unknown) {
1028
- const errorMessage = error instanceof Error ? error.message : "Authentication required";
1029
- reject(new RebaseApiError(errorMessage));
1030
- return;
1031
- }
1032
- }
1033
-
1034
- const requestId = (message.requestId as string) || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1035
- message.requestId = requestId;
1036
-
1037
- const expectsResponse = !(
1038
- message.type === "subscribe_collection"
1039
- || message.type === "subscribe_one"
1040
- || message.type === "unsubscribe"
1041
- || CHANNEL_MESSAGE_TYPES.has(message.type as string)
1042
- );
1043
-
1044
- if (expectsResponse && !this.pendingRequests.has(requestId)) {
1045
- const timeoutHandle = setTimeout(() => {
1046
- if (this.pendingRequests.has(requestId)) {
1047
- this.pendingRequests.delete(requestId);
1048
- reject(new RebaseApiError("Request timed out"));
1049
- }
1050
- }, this.requestTimeoutMs);
1051
-
1052
- this.pendingRequests.set(requestId, {
1053
- resolve: (value: unknown) => {
1054
- clearTimeout(timeoutHandle);
1055
- resolve(value);
1056
- },
1057
- reject: (error: Error) => {
1058
- clearTimeout(timeoutHandle);
1059
- reject(error);
1060
- },
1061
- message: message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void }
1062
- });
1063
- }
1064
-
1065
- try {
1066
- this.ws!.send(JSON.stringify(message));
1067
- if (!expectsResponse) {
1068
- resolve(undefined);
1069
- }
1070
- } catch (error) {
1071
- if (expectsResponse) {
1072
- this.pendingRequests.delete(requestId);
1073
- }
1074
- reject(new RebaseApiError("Failed to send message", { cause: error }));
1075
- }
1076
- }
1077
-
1078
- // Data source methods
1079
- async fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Record<string, unknown>[]> {
1080
- const response = await this.sendMessage({
1081
- type: "FETCH_COLLECTION",
1082
- payload: props
1083
- }) as { rows?: Record<string, unknown>[] };
1084
- return (response.rows || []);
1085
- }
1086
-
1087
- async fetchOne<M extends Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined> {
1088
- const response = await this.sendMessage({
1089
- type: "FETCH_ONE",
1090
- payload: props
1091
- }) as { row?: Record<string, unknown> };
1092
- const wireEntity = response.row;
1093
- return wireEntity ?? undefined;
1094
- }
1095
-
1096
- async save<M extends Record<string, unknown>>(props: SaveProps<M>): Promise<Record<string, unknown>> {
1097
- const response = await this.sendMessage({
1098
- type: "SAVE",
1099
- payload: props
1100
- }) as { row: Record<string, unknown> };
1101
- return response.row;
1102
- }
1103
-
1104
- async delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void> {
1105
- await this.sendMessage({
1106
- type: "DELETE",
1107
- payload: props
1108
- });
1109
- }
1110
-
1111
- async executeSql(sql: string, options?: { database?: string, role?: string }): Promise<Record<string, unknown>[]> {
1112
- const response = await this.sendMessage({
1113
- type: "EXECUTE_SQL",
1114
- payload: { sql,
1115
- options }
1116
- }) as { result?: Record<string, unknown>[] };
1117
- return response.result || [];
1118
- }
1119
-
1120
- async fetchAvailableDatabases(): Promise<string[]> {
1121
- const response = await this.sendMessage({
1122
- type: "FETCH_DATABASES",
1123
- payload: {}
1124
- }) as { databases?: string[] };
1125
- return response.databases || [];
1126
- }
1127
-
1128
- async fetchAvailableRoles(): Promise<string[]> {
1129
- const response = await this.sendMessage({
1130
- type: "FETCH_ROLES"
1131
- }) as { roles?: string[] };
1132
- return response.roles || [];
1133
- }
1134
-
1135
- async fetchApplicationRoles(): Promise<string[]> {
1136
- const response = await this.sendMessage({
1137
- type: "FETCH_APPLICATION_ROLES"
1138
- }) as { roles?: string[] };
1139
- return response.roles || [];
1140
- }
1141
-
1142
- async fetchCurrentDatabase(): Promise<string | undefined> {
1143
- const response = await this.sendMessage({
1144
- type: "FETCH_CURRENT_DATABASE"
1145
- }) as { database?: string };
1146
- return response.database;
1147
- }
1148
-
1149
- async checkUniqueField(path: string, name: string, value: unknown, id?: string, collection?: CollectionConfig): Promise<boolean> {
1150
- const response = await this.sendMessage({
1151
- type: "CHECK_UNIQUE_FIELD",
1152
- payload: {
1153
- path,
1154
- name,
1155
- value,
1156
- id,
1157
- collection
1158
- }
1159
- }) as { isUnique: boolean };
1160
- return response.isUnique;
1161
- }
1162
-
1163
- async count<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number> {
1164
- const response = await this.sendMessage({
1165
- type: "COUNT",
1166
- payload: props
1167
- }) as { count: number };
1168
- return response.count;
1169
- }
1170
-
1171
- async fetchUnmappedTables(mappedPaths?: string[]): Promise<string[]> {
1172
- const response = await this.sendMessage({
1173
- type: "FETCH_UNMAPPED_TABLES",
1174
- payload: { mappedPaths }
1175
- }) as { tables?: string[] };
1176
- return response.tables || [];
1177
- }
1178
-
1179
- async fetchTableMetadata(tableName: string): Promise<TableMetadata> {
1180
- const response = await this.sendMessage({
1181
- type: "FETCH_TABLE_METADATA",
1182
- payload: { tableName }
1183
- }) as { metadata?: TableMetadata };
1184
-
1185
- return response.metadata || ({ columns: [],
1186
- foreignKeys: [],
1187
- junctions: [],
1188
- policies: [] } as TableMetadata);
1189
- }
1190
-
1191
- async createBranch(name: string, options?: { source?: string }): Promise<BranchInfo> {
1192
- const response = await this.sendMessage({
1193
- type: "CREATE_BRANCH",
1194
- payload: { name,
1195
- options }
1196
- }) as { branch: BranchInfo };
1197
- return response.branch;
1198
- }
1199
-
1200
- async deleteBranch(name: string): Promise<void> {
1201
- await this.sendMessage({
1202
- type: "DELETE_BRANCH",
1203
- payload: { name }
1204
- });
1205
- }
1206
-
1207
- async listBranches(): Promise<BranchInfo[]> {
1208
- const response = await this.sendMessage({
1209
- type: "LIST_BRANCHES",
1210
- payload: {}
1211
- }) as { branches?: BranchInfo[] };
1212
- return response.branches || [];
1213
- }
1214
-
1215
- /**
1216
- * Recursively compare two values for structural equality.
1217
- * Handles primitives, null, undefined, Date, RegExp, arrays, and plain objects.
1218
- */
1219
- private deepEqual(a: unknown, b: unknown): boolean {
1220
- // Same reference or same primitive
1221
- if (a === b) return true;
1222
-
1223
- // Handle null/undefined
1224
- if (a === null || b === null || a === undefined || b === undefined) return false;
1225
-
1226
- // Different types
1227
- if (typeof a !== typeof b) return false;
1228
-
1229
- // Non-object primitives (number, string, boolean, bigint, symbol)
1230
- // that weren't caught by === above (e.g. NaN !== NaN)
1231
- if (typeof a !== "object") return false;
1232
-
1233
- // Date comparison
1234
- if (a instanceof Date && b instanceof Date) {
1235
- return a.getTime() === b.getTime();
1236
- }
1237
- if (a instanceof Date || b instanceof Date) return false;
1238
-
1239
- // RegExp comparison
1240
- if (a instanceof RegExp && b instanceof RegExp) {
1241
- return a.source === b.source && a.flags === b.flags;
1242
- }
1243
- if (a instanceof RegExp || b instanceof RegExp) return false;
1244
-
1245
- // Array comparison
1246
- const aIsArray = Array.isArray(a);
1247
- const bIsArray = Array.isArray(b);
1248
- if (aIsArray !== bIsArray) return false;
1249
-
1250
- if (aIsArray && bIsArray) {
1251
- if (a.length !== b.length) return false;
1252
- for (let i = 0; i < a.length; i++) {
1253
- if (!this.deepEqual(a[i], b[i])) return false;
1254
- }
1255
- return true;
1256
- }
1257
-
1258
- // Plain object comparison
1259
- const aObj = a as Record<string, unknown>;
1260
- const bObj = b as Record<string, unknown>;
1261
- const aKeys = Object.keys(aObj);
1262
- const bKeys = Object.keys(bObj);
1263
-
1264
- if (aKeys.length !== bKeys.length) return false;
1265
-
1266
- for (const key of aKeys) {
1267
- if (!Object.prototype.hasOwnProperty.call(bObj, key)) return false;
1268
- if (!this.deepEqual(aObj[key], bObj[key])) return false;
1269
- }
1270
-
1271
- return true;
1272
- }
1273
-
1274
- private normalizeForComparison(val: unknown): unknown {
1275
- if (!val) return val;
1276
-
1277
- if (Array.isArray(val)) {
1278
- return val.map(item => this.normalizeForComparison(item));
1279
- }
1280
-
1281
- if (typeof val === "object") {
1282
- if (val instanceof Date) return val;
1283
- if (val instanceof RegExp) return val;
1284
-
1285
- const obj = val as Record<string, unknown>;
1286
- if (obj.__type === "relation") {
1287
- // `data` is dropped on purpose: a relation compares by the
1288
- // reference it holds, not by the row it happens to have loaded.
1289
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
1290
- const { data, ...rest } = obj;
1291
- return rest;
1292
- }
1293
-
1294
- const result: Record<string, unknown> = {};
1295
- for (const [k, v] of Object.entries(obj)) {
1296
- result[k] = this.normalizeForComparison(v);
1297
- }
1298
- return result;
1299
- }
1300
-
1301
- return val;
1302
- }
1303
-
1304
- /**
1305
- * The address of a row, for matching it against another copy of itself.
1306
- *
1307
- * A row is exactly its columns and carries no address, so it is derived
1308
- * from the key columns the server named — including the ordinary case where
1309
- * that key is `id`, which the server reports like any other.
1310
- *
1311
- * Undefined when there are no keys, which means the server could not
1312
- * resolve any: such rows genuinely cannot be recognised, and guessing at a
1313
- * column called `id` would be inventing an identity for a table that has
1314
- * none.
1315
- */
1316
- private rowAddress(row: Record<string, unknown>, pks: PrimaryKeyInfo[] | undefined): string | undefined {
1317
- if (!pks || pks.length === 0) return undefined;
1318
- const address = buildCompositeId(row, pks);
1319
- if (!address || address.split(COMPOSITE_ID_SEPARATOR).every(part => part === "")) return undefined;
1320
- return address;
1321
- }
1322
-
1323
- /**
1324
- * Merge incoming rows with cached data, preserving cached references
1325
- * for rows whose values haven't changed. This avoids unnecessary
1326
- * React re-renders when the server refetches all rows but most
1327
- * haven't actually changed.
1328
- */
1329
- private mergeRows(
1330
- cached: Record<string, unknown>[] | undefined,
1331
- incoming: Record<string, unknown>[],
1332
- pks?: PrimaryKeyInfo[]
1333
- ): Record<string, unknown>[] {
1334
- if (!cached || cached.length === 0) return incoming;
1335
-
1336
- // Build a lookup from cached rows by address for O(1) access
1337
- const cachedById = new Map<string, Record<string, unknown>>();
1338
- for (const row of cached) {
1339
- const address = this.rowAddress(row, pks);
1340
- if (address !== undefined) cachedById.set(address, row);
1341
- }
1342
-
1343
- return incoming.map(incomingRow => {
1344
- const address = this.rowAddress(incomingRow, pks);
1345
- const cachedRow = address === undefined ? undefined : cachedById.get(address);
1346
- if (!cachedRow) return incomingRow;
1347
-
1348
- // Compare flat rows directly (no more path/values nesting)
1349
- const normCached = this.normalizeForComparison(cachedRow) as Record<string, unknown>;
1350
- const normIncoming = this.normalizeForComparison(incomingRow) as Record<string, unknown>;
1351
-
1352
- if (this.deepEqual(normCached, normIncoming)) {
1353
- return cachedRow;
1354
- } else {
1355
- // Deep debug: Why did it fail?
1356
- const mismatches: Record<string, { cached: unknown, incoming: unknown }> = {};
1357
- const allKeys = new Set([...Object.keys(normCached), ...Object.keys(normIncoming)]);
1358
- for (const key of allKeys) {
1359
- if (!this.deepEqual(normCached[key], normIncoming[key])) {
1360
- mismatches[key] = { cached: normCached[key],
1361
- incoming: normIncoming[key] };
1362
- }
1363
- }
1364
- console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
1365
- }
1366
- return incomingRow;
1367
- });
1368
- }
1369
-
1370
- // Subscription methods
1371
- listenCollection<M extends Record<string, unknown>>(
1372
- props: FetchCollectionProps<M>,
1373
- onUpdate: (rows: Record<string, unknown>[]) => void,
1374
- onError?: (error: Error) => void
1375
- ): () => void {
1376
- // A subscription is the app asking for live data, so this is where the
1377
- // socket is wanted. Called before the dedup check below: joining an
1378
- // existing subscription must still work if the socket has since gone.
1379
- this.ensureConnected();
1380
-
1381
- const subscriptionKey = this.createCollectionSubscriptionKey(props);
1382
- const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1383
-
1384
- // Check if we already have a subscription for these exact parameters
1385
- const existingSubscription = this.collectionSubscriptions.get(subscriptionKey);
1386
-
1387
- if (existingSubscription) {
1388
- // Reuse existing subscription - just add the new callback
1389
- const callbackMap = existingSubscription.callbacks as Map<string, {
1390
- onUpdate: (rows: Record<string, unknown>[]) => void;
1391
- onError?: (error: Error) => void;
1392
- }>;
1393
- callbackMap.set(callbackId, { onUpdate,
1394
- onError });
1395
-
1396
- // Immediately fire the callback with cached data if available
1397
- if (existingSubscription.latestData !== undefined && existingSubscription.isInitialDataReceived) {
1398
- try {
1399
- onUpdate(existingSubscription.latestData);
1400
- } catch (error) {
1401
- console.error("Error in collection subscription callback:", error);
1402
- if (onError) {
1403
- onError(error instanceof Error ? error : new Error(String(error)));
1404
- }
1405
- }
1406
- } else if (!existingSubscription.subscribeInFlight) {
1407
- // Registered but idle: its subscribe never landed (the send failed,
1408
- // or the server answered with an error). Nothing is coming, so
1409
- // re-issue it — otherwise this listener waits forever.
1410
- this.sendCollectionSubscribe(subscriptionKey);
1411
- }
1412
-
1413
- // Return unsubscribe function
1414
- return () => {
1415
- callbackMap.delete(callbackId);
1416
- if (callbackMap.size === 0) {
1417
- // Only tear down if this is still the same registration — a
1418
- // failed subscribe may have replaced it in the meantime.
1419
- if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
1420
- if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
1421
- this.collectionSubscriptions.delete(subscriptionKey);
1422
- this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
1423
- if (this.isConnected && this.ws) {
1424
- this.sendMessage({
1425
- type: "unsubscribe",
1426
- payload: { subscriptionId: existingSubscription.backendSubscriptionId }
1427
- }).catch(console.error);
1428
- }
1429
- }
1430
- };
1431
- }
1432
-
1433
- // Create new subscription
1434
- const backendSubscriptionId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1435
- const callbackMap = new Map<string, {
1436
- onUpdate: (rows: Record<string, unknown>[]) => void;
1437
- onError?: (error: Error) => void;
1438
- }>();
1439
- callbackMap.set(callbackId, { onUpdate,
1440
- onError });
1441
-
1442
- this.collectionSubscriptions.set(subscriptionKey, {
1443
- backendSubscriptionId,
1444
- callbacks: callbackMap,
1445
- props
1446
- });
1447
-
1448
- // Add reverse lookup
1449
- this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
1450
-
1451
- // Send subscription request to backend. A failure here drops the
1452
- // registration and notifies every listener, so the next mount retries.
1453
- this.sendCollectionSubscribe(subscriptionKey);
1454
-
1455
- // Return unsubscribe function
1456
- return () => {
1457
- const subscription = this.collectionSubscriptions.get(subscriptionKey);
1458
- if (subscription) {
1459
- const callbacks = subscription.callbacks;
1460
- callbacks.delete(callbackId);
1461
- if (callbacks.size === 0) {
1462
- if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
1463
- this.collectionSubscriptions.delete(subscriptionKey);
1464
- this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
1465
- if (this.isConnected && this.ws) {
1466
- this.sendMessage({
1467
- type: "unsubscribe",
1468
- payload: { subscriptionId: subscription.backendSubscriptionId }
1469
- }).catch(console.error);
1470
- }
1471
- }
1472
- }
1473
- };
1474
- }
1475
-
1476
- listenOne<M extends Record<string, unknown>>(
1477
- props: FetchOneProps<M>,
1478
- onUpdate: (row: Record<string, unknown> | null) => void,
1479
- onError?: (error: Error) => void
1480
- ): () => void {
1481
- this.ensureConnected();
1482
-
1483
- const subscriptionKey = this.createSingleSubscriptionKey(props);
1484
- const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1485
-
1486
- // Check if we already have a subscription for these exact parameters
1487
- const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
1488
-
1489
- if (existingSubscription) {
1490
- // Reuse existing subscription - just add the new callback
1491
- const callbackMap = existingSubscription.callbacks as Map<string, {
1492
- onUpdate: (row: Record<string, unknown> | null) => void;
1493
- onError?: (error: Error) => void;
1494
- }>;
1495
- callbackMap.set(callbackId, { onUpdate,
1496
- onError });
1497
-
1498
- // Immediately fire the callback with cached data if available
1499
- if (existingSubscription.latestData !== undefined && existingSubscription.isInitialDataReceived) {
1500
- try {
1501
- onUpdate(existingSubscription.latestData);
1502
- } catch (error) {
1503
- console.error("Error in row subscription callback:", error);
1504
- if (onError) {
1505
- onError(error instanceof Error ? error : new Error(String(error)));
1506
- }
1507
- }
1508
- } else if (!existingSubscription.subscribeInFlight) {
1509
- // See listenCollection: a registration with nothing in flight is
1510
- // dead, and attaching to it silently would hang this listener.
1511
- this.sendEntitySubscribe(subscriptionKey);
1512
- }
1513
-
1514
- // Return unsubscribe function
1515
- return () => {
1516
- callbackMap.delete(callbackId);
1517
- if (callbackMap.size === 0) {
1518
- if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
1519
- if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
1520
- // No more callbacks, unsubscribe from backend
1521
- this.singleSubscriptions.delete(subscriptionKey);
1522
- this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
1523
- if (this.isConnected && this.ws) {
1524
- this.sendMessage({
1525
- type: "unsubscribe",
1526
- payload: { subscriptionId: existingSubscription.backendSubscriptionId }
1527
- }).catch(console.error);
1528
- }
1529
- }
1530
- };
1531
- }
1532
-
1533
- // Create new subscription
1534
- const backendSubscriptionId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1535
- const callbackMap = new Map<string, {
1536
- onUpdate: (row: Record<string, unknown> | null) => void;
1537
- onError?: (error: Error) => void;
1538
- }>();
1539
- callbackMap.set(callbackId, { onUpdate,
1540
- onError });
1541
-
1542
- this.singleSubscriptions.set(subscriptionKey, {
1543
- backendSubscriptionId,
1544
- callbacks: callbackMap,
1545
- props
1546
- });
1547
-
1548
- // Add reverse lookup
1549
- this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
1550
-
1551
- // Send subscription request to backend
1552
- this.sendEntitySubscribe(subscriptionKey);
1553
-
1554
- // Return unsubscribe function
1555
- return () => {
1556
- const subscription = this.singleSubscriptions.get(subscriptionKey);
1557
- if (subscription) {
1558
- const callbacks = subscription.callbacks;
1559
- callbacks.delete(callbackId);
1560
- if (callbacks.size === 0) {
1561
- this.singleSubscriptions.delete(subscriptionKey);
1562
- this.backendToEntityKey.delete(subscription.backendSubscriptionId);
1563
- if (this.isConnected && this.ws) {
1564
- this.sendMessage({
1565
- type: "unsubscribe",
1566
- payload: { subscriptionId: subscription.backendSubscriptionId }
1567
- }).catch(console.error);
1568
- }
1569
- }
1570
- }
1571
- };
1572
- }
1573
-
1574
- /**
1575
- * Send a `subscribe_collection` for an already-registered subscription and
1576
- * arm its watchdog.
1577
- *
1578
- * Every path that registers a collection subscription goes through here, so
1579
- * that a subscribe which never lands — a rejected send, or a server that
1580
- * never answers — always ends up in `failCollectionSubscription` rather than
1581
- * leaving the entry parked with `isInitialDataReceived === false` forever.
1582
- */
1583
- private sendCollectionSubscribe(subscriptionKey: string): void {
1584
- const subscription = this.collectionSubscriptions.get(subscriptionKey);
1585
- if (!subscription) return;
1586
-
1587
- const backendSubscriptionId = subscription.backendSubscriptionId;
1588
- subscription.subscribeInFlight = true;
1589
-
1590
- if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
1591
- subscription.subscribeTimeout = undefined;
1592
- // Only time out a frame that is actually on the wire. While offline the
1593
- // message just sits in the queue, and reconnect backoff can exceed the
1594
- // timeout — `armPendingSubscribeWatchdogs` picks these up on connect.
1595
- if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
1596
-
1597
- this.sendMessage({
1598
- type: "subscribe_collection",
1599
- payload: {
1600
- ...subscription.props,
1601
- subscriptionId: backendSubscriptionId
1602
- }
1603
- }).catch(error => {
1604
- const current = this.collectionSubscriptions.get(subscriptionKey);
1605
- if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
1606
- this.failCollectionSubscription(
1607
- subscriptionKey,
1608
- error instanceof Error ? error : new Error(String(error))
1609
- );
1610
- });
1611
- }
1612
-
1613
- /** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
1614
- private sendEntitySubscribe(subscriptionKey: string): void {
1615
- const subscription = this.singleSubscriptions.get(subscriptionKey);
1616
- if (!subscription) return;
1617
-
1618
- const backendSubscriptionId = subscription.backendSubscriptionId;
1619
- subscription.subscribeInFlight = true;
1620
-
1621
- if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
1622
- subscription.subscribeTimeout = undefined;
1623
- if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
1624
-
1625
- this.sendMessage({
1626
- type: "subscribe_one",
1627
- payload: {
1628
- ...subscription.props,
1629
- subscriptionId: backendSubscriptionId
1630
- }
1631
- }).catch(error => {
1632
- const current = this.singleSubscriptions.get(subscriptionKey);
1633
- if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
1634
- this.failEntitySubscription(
1635
- subscriptionKey,
1636
- error instanceof Error ? error : new Error(String(error))
1637
- );
1638
- });
1639
- }
1640
-
1641
- /**
1642
- * Report a subscribe failure to every listener and drop the registration.
1643
- *
1644
- * Dropping it is the point: the callbacks stay live (their components are
1645
- * still mounted and have been told), but the next `listenCollection` for
1646
- * these params finds no entry and issues a fresh subscribe instead of
1647
- * silently attaching to a dead one.
1648
- */
1649
- private failCollectionSubscription(subscriptionKey: string, error: Error): void {
1650
- const subscription = this.collectionSubscriptions.get(subscriptionKey);
1651
- if (!subscription) return;
1652
-
1653
- if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
1654
- subscription.subscribeInFlight = false;
1655
-
1656
- this.collectionSubscriptions.delete(subscriptionKey);
1657
- this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
1658
-
1659
- subscription.callbacks.forEach(callback => {
1660
- if (callback.onError) {
1661
- try {
1662
- callback.onError(error);
1663
- } catch (callbackError) {
1664
- console.error("Error in collection subscription error callback:", callbackError);
1665
- }
1666
- }
1667
- });
1668
- }
1669
-
1670
- /** The `listenOne` counterpart of {@link failCollectionSubscription}. */
1671
- private failEntitySubscription(subscriptionKey: string, error: Error): void {
1672
- const subscription = this.singleSubscriptions.get(subscriptionKey);
1673
- if (!subscription) return;
1674
-
1675
- if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
1676
- subscription.subscribeInFlight = false;
1677
-
1678
- this.singleSubscriptions.delete(subscriptionKey);
1679
- this.backendToEntityKey.delete(subscription.backendSubscriptionId);
1680
-
1681
- subscription.callbacks.forEach(callback => {
1682
- if (callback.onError) {
1683
- try {
1684
- callback.onError(error);
1685
- } catch (callbackError) {
1686
- console.error("Error in row subscription error callback:", callbackError);
1687
- }
1688
- }
1689
- });
1690
- }
1691
-
1692
- /**
1693
- * Stop the watchdogs without failing anything — used when the socket drops,
1694
- * since the reconnect path re-subscribes everything anyway and a watchdog
1695
- * firing mid-reconnect would tear down healthy subscriptions.
1696
- */
1697
- private suspendSubscribeWatchdogs(): void {
1698
- for (const sub of this.collectionSubscriptions.values()) {
1699
- if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
1700
- sub.subscribeTimeout = undefined;
1701
- sub.subscribeInFlight = false;
1702
- }
1703
- for (const sub of this.singleSubscriptions.values()) {
1704
- if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
1705
- sub.subscribeTimeout = undefined;
1706
- sub.subscribeInFlight = false;
1707
- }
1708
- }
1709
-
1710
- /**
1711
- * Arm watchdogs for subscribes that were requested while offline and have
1712
- * just been flushed to the socket. Their timers were deliberately not set at
1713
- * request time, so without this they would have no timeout at all.
1714
- */
1715
- private armPendingSubscribeWatchdogs(): void {
1716
- for (const [key, sub] of this.collectionSubscriptions.entries()) {
1717
- if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
1718
- }
1719
- for (const [key, sub] of this.singleSubscriptions.entries()) {
1720
- if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
1721
- }
1722
- }
1723
-
1724
- private sendCollectionSubscribeWatchdog(subscriptionKey: string): void {
1725
- const subscription = this.collectionSubscriptions.get(subscriptionKey);
1726
- if (!subscription) return;
1727
- const backendSubscriptionId = subscription.backendSubscriptionId;
1728
- subscription.subscribeTimeout = setTimeout(() => {
1729
- const current = this.collectionSubscriptions.get(subscriptionKey);
1730
- if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
1731
- if (!current.subscribeInFlight) return;
1732
- this.failCollectionSubscription(
1733
- subscriptionKey,
1734
- new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" })
1735
- );
1736
- }, this.subscriptionTimeoutMs);
1737
- }
1738
-
1739
- private sendEntitySubscribeWatchdog(subscriptionKey: string): void {
1740
- const subscription = this.singleSubscriptions.get(subscriptionKey);
1741
- if (!subscription) return;
1742
- const backendSubscriptionId = subscription.backendSubscriptionId;
1743
- subscription.subscribeTimeout = setTimeout(() => {
1744
- const current = this.singleSubscriptions.get(subscriptionKey);
1745
- if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
1746
- if (!current.subscribeInFlight) return;
1747
- this.failEntitySubscription(
1748
- subscriptionKey,
1749
- new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" })
1750
- );
1751
- }, this.subscriptionTimeoutMs);
1752
- }
1753
-
1754
- /**
1755
- * Fail every subscription that never received data. Called when reconnection
1756
- * is given up on, so views surface an error instead of spinning forever.
1757
- */
1758
- private failAllPendingSubscriptions(error: Error): void {
1759
- for (const key of [...this.collectionSubscriptions.keys()]) {
1760
- const sub = this.collectionSubscriptions.get(key);
1761
- if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
1762
- }
1763
- for (const key of [...this.singleSubscriptions.keys()]) {
1764
- const sub = this.singleSubscriptions.get(key);
1765
- if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
1766
- }
1767
- }
1768
-
1769
- /**
1770
- * Re-send all active subscriptions to the backend after a reconnect.
1771
- * The server wipes subscription state when a client disconnects, so
1772
- * we need to re-register everything to resume receiving updates.
1773
- */
1774
- private resubscribeAll(): void {
1775
- console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.singleSubscriptions.size} row(ies)`);
1776
-
1777
- // Re-subscribe collection subscriptions
1778
- for (const [key, sub] of this.collectionSubscriptions.entries()) {
1779
- // Generate a fresh backend ID since the old one is no longer valid on the server
1780
- const oldBackendId = sub.backendSubscriptionId;
1781
- const newBackendId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1782
- sub.backendSubscriptionId = newBackendId;
1783
-
1784
- // Update reverse lookup
1785
- this.backendToCollectionKey.delete(oldBackendId);
1786
- this.backendToCollectionKey.set(newBackendId, key);
1787
-
1788
- this.sendCollectionSubscribe(key);
1789
- }
1790
-
1791
- // Re-subscribe row subscriptions
1792
- for (const [key, sub] of this.singleSubscriptions.entries()) {
1793
- const oldBackendId = sub.backendSubscriptionId;
1794
- const newBackendId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1795
- sub.backendSubscriptionId = newBackendId;
1796
-
1797
- this.backendToEntityKey.delete(oldBackendId);
1798
- this.backendToEntityKey.set(newBackendId, key);
1799
-
1800
- this.sendEntitySubscribe(key);
1801
- }
1802
- }
1803
-
1804
- private createCollectionSubscriptionKey(props: FetchCollectionProps): string {
1805
- // Derived from the props, not a hand-listed subset of them.
1806
- //
1807
- // Two subscriptions share one server subscription when their keys
1808
- // match, so a field left off the key makes two different queries
1809
- // collide and hands the second listener the first one's rows. `offset`
1810
- // and `logical` were both missing: page two of a live list showed page
1811
- // one, and two views filtered by different `or(...)` groups saw the
1812
- // same rows. Listing fields by hand is what let that happen, so the key
1813
- // now covers whatever `FetchCollectionProps` carries.
1814
- //
1815
- // `collection` is the exception: it is the whole collection config,
1816
- // property thunks and all, so it contributes its name as before.
1817
- const { collection, ...query } = props as FetchCollectionProps & Record<string, unknown>;
1818
- const key = {
1819
- ...query,
1820
- collection: collection?.name
1821
- };
1822
- // Use replacer function (not array) to sort keys at all levels for deterministic output
1823
- return JSON.stringify(key, (_, value) => {
1824
- if (value && typeof value === "object" && !Array.isArray(value)) {
1825
- return Object.keys(value).sort().reduce((sorted: Record<string, unknown>, k) => {
1826
- sorted[k] = value[k];
1827
- return sorted;
1828
- }, {});
1829
- }
1830
- return value;
1831
- });
1832
- }
1833
-
1834
- private createSingleSubscriptionKey(props: FetchOneProps): string {
1835
- return `${props.path}|${props.id}`;
1836
- }
1837
- }