@rebasepro/client 0.9.1-canary.fd3754b → 0.10.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 +5 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.es.js +799 -95
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +243 -0
- package/dist/transport.d.ts +34 -0
- package/dist/websocket.d.ts +96 -2
- package/package.json +11 -10
- package/src/auth.ts +32 -0
- package/src/collection.ts +16 -0
- package/src/index.ts +105 -2
- package/src/realtime-channel.test.ts +542 -0
- package/src/realtime-channel.ts +539 -0
- package/src/realtime-optout.test.ts +245 -0
- package/src/realtime-row-identity.test.ts +254 -0
- package/src/sdk_query_builder.ts +4 -1
- package/src/transport-baseurl.test.ts +53 -0
- package/src/transport.ts +59 -3
- package/src/websocket.ts +515 -88
- package/dist/collection.test.d.ts +0 -1
- package/dist/cron.test.d.ts +0 -1
- package/dist/data-proxy.test.d.ts +0 -1
- package/dist/index.umd.js +0 -2513
- package/dist/index.umd.js.map +0 -1
package/src/websocket.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
BranchInfo,
|
|
13
13
|
RebaseApiError
|
|
14
14
|
} from "@rebasepro/types";
|
|
15
|
+
import { buildCompositeId, COMPOSITE_ID_SEPARATOR, type PrimaryKeyInfo } from "@rebasepro/common";
|
|
15
16
|
import { rebaseReviver } from "./reviver";
|
|
16
17
|
|
|
17
18
|
|
|
@@ -29,7 +30,14 @@ function extractMessageError(message: WebSocketMessage): { errorMessage: string;
|
|
|
29
30
|
const errorCode = typeof errPayload === "object"
|
|
30
31
|
? errPayload.code
|
|
31
32
|
: payload?.code;
|
|
32
|
-
|
|
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,
|
|
33
41
|
errorCode };
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -44,6 +52,25 @@ export interface RebaseWebSocketConfig {
|
|
|
44
52
|
}
|
|
45
53
|
|
|
46
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
|
+
|
|
47
74
|
/**
|
|
48
75
|
* Low-level realtime WebSocket client.
|
|
49
76
|
*
|
|
@@ -64,6 +91,42 @@ export class RebaseWebSocketClient {
|
|
|
64
91
|
|
|
65
92
|
private listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
|
66
93
|
|
|
94
|
+
/** Channel-name → handlers, for broadcast and presence frames. */
|
|
95
|
+
private channelHandlers = new Map<string, Set<(message: Record<string, unknown>) => void>>();
|
|
96
|
+
|
|
97
|
+
/** Set by `close()`. Blocks any later operation from silently redialling. */
|
|
98
|
+
private closedByCaller = false;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Whether a socket exists at all (open or still opening).
|
|
102
|
+
*
|
|
103
|
+
* Lets callers distinguish "authenticate the live socket" from "there is
|
|
104
|
+
* nothing to authenticate yet", without that question forcing a dial.
|
|
105
|
+
*/
|
|
106
|
+
public get hasSocket(): boolean {
|
|
107
|
+
return this.ws !== null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** So the "no WebSocket in this environment" warning is said once, not per call. */
|
|
111
|
+
private warnedNoWebSocket = false;
|
|
112
|
+
|
|
113
|
+
/** Subscribe to broadcast/presence frames for one channel. */
|
|
114
|
+
public onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void {
|
|
115
|
+
if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, new Set());
|
|
116
|
+
this.channelHandlers.get(channel)!.add(handler);
|
|
117
|
+
return () => {
|
|
118
|
+
const handlers = this.channelHandlers.get(channel);
|
|
119
|
+
if (!handlers) return;
|
|
120
|
+
handlers.delete(handler);
|
|
121
|
+
if (handlers.size === 0) this.channelHandlers.delete(channel);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Notified after the socket comes back, so channels can re-join. */
|
|
126
|
+
public onReconnect(handler: () => void): () => void {
|
|
127
|
+
return this.on("reconnect", handler);
|
|
128
|
+
}
|
|
129
|
+
|
|
67
130
|
public on(event: "connect" | "disconnect" | "reconnect" | "error", cb: (...args: unknown[]) => void) {
|
|
68
131
|
if (!this.listeners.has(event)) {
|
|
69
132
|
this.listeners.set(event, new Set());
|
|
@@ -89,6 +152,25 @@ export class RebaseWebSocketClient {
|
|
|
89
152
|
latestData?: Record<string, unknown>[]; // Cache the latest flat rows
|
|
90
153
|
lastUpdated?: number; // Timestamp for cache invalidation
|
|
91
154
|
isInitialDataReceived?: boolean; // Track if we got initial data
|
|
155
|
+
/**
|
|
156
|
+
* A `subscribe_collection` frame is on the wire and its initial payload
|
|
157
|
+
* has not arrived yet. Without this, a subscription whose subscribe
|
|
158
|
+
* failed is indistinguishable from one still loading, and every later
|
|
159
|
+
* listener attaches to it and waits forever.
|
|
160
|
+
*/
|
|
161
|
+
subscribeInFlight?: boolean;
|
|
162
|
+
/**
|
|
163
|
+
* Watchdog for the above. `subscribe_collection` expects no response
|
|
164
|
+
* envelope, so it is not covered by `pendingRequests`' timeout — a lost
|
|
165
|
+
* initial payload would otherwise hang the subscription indefinitely.
|
|
166
|
+
*/
|
|
167
|
+
subscribeTimeout?: ReturnType<typeof setTimeout>;
|
|
168
|
+
/**
|
|
169
|
+
* The key columns of this collection, as told by the server on a patch.
|
|
170
|
+
* Rows are columns only, and the SDK holds no collection config, so
|
|
171
|
+
* without this there is nothing to derive an address from.
|
|
172
|
+
*/
|
|
173
|
+
pks?: PrimaryKeyInfo[];
|
|
92
174
|
}>();
|
|
93
175
|
|
|
94
176
|
private singleSubscriptions = new Map<string, {
|
|
@@ -101,6 +183,9 @@ export class RebaseWebSocketClient {
|
|
|
101
183
|
latestData?: Record<string, unknown> | null; // Cache the latest flat row
|
|
102
184
|
lastUpdated?: number; // Timestamp for cache invalidation
|
|
103
185
|
isInitialDataReceived?: boolean; // Track if we got initial data
|
|
186
|
+
/** See the collection subscription counterparts. */
|
|
187
|
+
subscribeInFlight?: boolean;
|
|
188
|
+
subscribeTimeout?: ReturnType<typeof setTimeout>;
|
|
104
189
|
}>();
|
|
105
190
|
|
|
106
191
|
// Maps to quickly find subscription by backend subscription ID
|
|
@@ -118,6 +203,7 @@ export class RebaseWebSocketClient {
|
|
|
118
203
|
private isConnected = false;
|
|
119
204
|
private messageQueue: Record<string, unknown>[] = [];
|
|
120
205
|
private requestTimeoutMs = 30000;
|
|
206
|
+
private subscriptionTimeoutMs = 30000;
|
|
121
207
|
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
122
208
|
|
|
123
209
|
private isAuthenticated = false;
|
|
@@ -132,11 +218,39 @@ export class RebaseWebSocketClient {
|
|
|
132
218
|
this.onUnauthorized = config.onUnauthorized;
|
|
133
219
|
this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : undefined);
|
|
134
220
|
|
|
221
|
+
// Deliberately does NOT dial here. Constructing the client is not a
|
|
222
|
+
// statement that the app wants a socket — `createRebaseClient` builds
|
|
223
|
+
// one whenever realtime is not explicitly disabled, so connecting here
|
|
224
|
+
// opened a socket on every page load of every app that merely *might*
|
|
225
|
+
// subscribe later. Anonymous-first apps paid that on every visit, to
|
|
226
|
+
// authenticate with nothing, which left them choosing between "socket
|
|
227
|
+
// on every page load" and "no channels at all".
|
|
228
|
+
//
|
|
229
|
+
// The environment warning is also deferred: an app that never
|
|
230
|
+
// subscribes should say nothing at all. See `ensureConnected`.
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Open the socket if it is not open (or opening) already.
|
|
235
|
+
*
|
|
236
|
+
* Idempotent, synchronous, and safe to call on every operation that needs a
|
|
237
|
+
* live socket — `initWebSocket` already no-ops on an open socket and is
|
|
238
|
+
* re-entrant, since the reconnect path has always called it.
|
|
239
|
+
*/
|
|
240
|
+
public ensureConnected(): void {
|
|
241
|
+
// An explicit `close()` is final. Without this, one queued frame could
|
|
242
|
+
// redial a socket the caller just released and keep a Node process
|
|
243
|
+
// alive forever.
|
|
244
|
+
if (this.closedByCaller) return;
|
|
135
245
|
if (!this.WebSocketConstructor) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
246
|
+
if (!this.warnedNoWebSocket) {
|
|
247
|
+
this.warnedNoWebSocket = true;
|
|
248
|
+
console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
139
251
|
}
|
|
252
|
+
if (this.ws || this.reconnectTimeout) return;
|
|
253
|
+
this.initWebSocket();
|
|
140
254
|
}
|
|
141
255
|
|
|
142
256
|
/**
|
|
@@ -201,7 +315,16 @@ export class RebaseWebSocketClient {
|
|
|
201
315
|
}
|
|
202
316
|
}
|
|
203
317
|
|
|
204
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Drop the socket.
|
|
320
|
+
*
|
|
321
|
+
* `permanent` distinguishes the two callers. Signing out drops the socket
|
|
322
|
+
* but the client stays usable — a later subscribe should reconnect
|
|
323
|
+
* anonymously. `client.close()` is the caller saying they are done, and
|
|
324
|
+
* must not be undone by a stray queued frame.
|
|
325
|
+
*/
|
|
326
|
+
public disconnect(permanent = false): void {
|
|
327
|
+
if (permanent) this.closedByCaller = true;
|
|
205
328
|
this.isAuthenticated = false;
|
|
206
329
|
this.authPromise = null;
|
|
207
330
|
if (this.reconnectTimeout) {
|
|
@@ -263,6 +386,10 @@ export class RebaseWebSocketClient {
|
|
|
263
386
|
if (wasReconnect) {
|
|
264
387
|
this.resubscribeAll();
|
|
265
388
|
}
|
|
389
|
+
|
|
390
|
+
// Subscribes requested while offline have just gone out; they
|
|
391
|
+
// could not be watchdogged at request time.
|
|
392
|
+
this.armPendingSubscribeWatchdogs();
|
|
266
393
|
};
|
|
267
394
|
|
|
268
395
|
this.ws!.onmessage = (event) => {
|
|
@@ -279,6 +406,9 @@ export class RebaseWebSocketClient {
|
|
|
279
406
|
this.isConnected = false;
|
|
280
407
|
this.isAuthenticated = false;
|
|
281
408
|
this.authPromise = null;
|
|
409
|
+
// The reconnect path re-subscribes everything; a watchdog firing
|
|
410
|
+
// in the meantime would tear down healthy subscriptions.
|
|
411
|
+
this.suspendSubscribeWatchdogs();
|
|
282
412
|
this.emit("disconnect");
|
|
283
413
|
|
|
284
414
|
// Re-queue pending requests so the UI doesn't hang indefinitely or crash
|
|
@@ -319,6 +449,11 @@ export class RebaseWebSocketClient {
|
|
|
319
449
|
private attemptReconnect() {
|
|
320
450
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
321
451
|
console.error("Max reconnection attempts reached");
|
|
452
|
+
// Nothing will re-subscribe now, so stop every subscription that
|
|
453
|
+
// never loaded from spinning forever.
|
|
454
|
+
this.failAllPendingSubscriptions(
|
|
455
|
+
new RebaseApiError("Connection lost", { code: "CONNECTION_LOST" })
|
|
456
|
+
);
|
|
322
457
|
return;
|
|
323
458
|
}
|
|
324
459
|
|
|
@@ -399,29 +534,32 @@ export class RebaseWebSocketClient {
|
|
|
399
534
|
backendKeyMap.delete(oldBackendId);
|
|
400
535
|
backendKeyMap.set(newBackendId, subscriptionKey);
|
|
401
536
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
537
|
+
// Route through the helpers so the retry is watchdogged too.
|
|
538
|
+
if (messageType === "subscribe_collection") {
|
|
539
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
540
|
+
} else {
|
|
541
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
542
|
+
}
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// The refresh did not produce usable credentials. Report the original
|
|
547
|
+
// error and drop the registration, so a later mount can try again
|
|
548
|
+
// rather than attaching to a subscription that will never load.
|
|
549
|
+
const { errorMessage, errorCode } = extractMessageError(message);
|
|
550
|
+
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
551
|
+
if (messageType === "subscribe_collection") {
|
|
552
|
+
this.failCollectionSubscription(subscriptionKey, error);
|
|
414
553
|
} else {
|
|
415
|
-
|
|
416
|
-
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
417
|
-
subscription.callbacks.forEach(callback => {
|
|
418
|
-
if (callback.onError) callback.onError(error);
|
|
419
|
-
});
|
|
554
|
+
this.failEntitySubscription(subscriptionKey, error);
|
|
420
555
|
}
|
|
421
556
|
}).catch(err => {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
557
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
558
|
+
if (messageType === "subscribe_collection") {
|
|
559
|
+
this.failCollectionSubscription(subscriptionKey, error);
|
|
560
|
+
} else {
|
|
561
|
+
this.failEntitySubscription(subscriptionKey, error);
|
|
562
|
+
}
|
|
425
563
|
});
|
|
426
564
|
}
|
|
427
565
|
|
|
@@ -460,6 +598,25 @@ export class RebaseWebSocketClient {
|
|
|
460
598
|
return;
|
|
461
599
|
}
|
|
462
600
|
|
|
601
|
+
// Channel traffic (broadcast / presence) is addressed by channel name
|
|
602
|
+
// rather than by requestId or subscriptionId, so it is dispatched
|
|
603
|
+
// before the subscription paths — none of which would match it, and
|
|
604
|
+
// the message would otherwise fall through and be dropped silently.
|
|
605
|
+
if (typeof message.channel === "string" &&
|
|
606
|
+
(type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
|
|
607
|
+
const handlers = this.channelHandlers.get(message.channel);
|
|
608
|
+
if (handlers) {
|
|
609
|
+
for (const handler of [...handlers]) {
|
|
610
|
+
try {
|
|
611
|
+
handler(message as unknown as Record<string, unknown>);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
console.error("Error in channel handler:", error);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
463
620
|
// Handle subscription updates for collection subscriptions
|
|
464
621
|
if (subscriptionId && type === "collection_update") {
|
|
465
622
|
const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
|
|
@@ -469,16 +626,27 @@ export class RebaseWebSocketClient {
|
|
|
469
626
|
const wireEntities = (message.rows || []) as unknown as Record<string, unknown>[];
|
|
470
627
|
const incomingRows = wireEntities;
|
|
471
628
|
|
|
629
|
+
// The keys arrive with the rows, so they are known before the
|
|
630
|
+
// first merge — a CDC-driven change never sends a patch, and
|
|
631
|
+
// learning them from patches alone would leave every
|
|
632
|
+
// externally-written collection unable to match a thing.
|
|
633
|
+
const updatePks = (message as unknown as { pks?: PrimaryKeyInfo[] }).pks;
|
|
634
|
+
if (updatePks) collectionSub.pks = updatePks;
|
|
635
|
+
|
|
472
636
|
// Structural merge: preserve cached row references for rows
|
|
473
637
|
// whose values haven't changed. This prevents downstream React components
|
|
474
638
|
// from re-rendering (VirtualTableCell uses deepEqual on rowData —
|
|
475
639
|
// same reference = instant true, avoiding expensive deep comparison).
|
|
476
|
-
const rows = this.mergeRows(collectionSub.latestData, incomingRows);
|
|
640
|
+
const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
|
|
477
641
|
|
|
478
642
|
// Cache the latest data with optimizations
|
|
479
643
|
collectionSub.latestData = rows;
|
|
480
644
|
collectionSub.lastUpdated = Date.now();
|
|
481
645
|
collectionSub.isInitialDataReceived = true;
|
|
646
|
+
// The subscribe landed — stand the watchdog down.
|
|
647
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
648
|
+
collectionSub.subscribeTimeout = undefined;
|
|
649
|
+
collectionSub.subscribeInFlight = false;
|
|
482
650
|
|
|
483
651
|
// Notify all callbacks for this subscription
|
|
484
652
|
collectionSub.callbacks.forEach(callback => {
|
|
@@ -504,16 +672,28 @@ export class RebaseWebSocketClient {
|
|
|
504
672
|
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
505
673
|
if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
|
|
506
674
|
const patchWireEntity = message.row ?? null;
|
|
507
|
-
const
|
|
675
|
+
const patchMessage = message as unknown as { id: string; pks?: PrimaryKeyInfo[] };
|
|
676
|
+
const patchEntityId = patchMessage.id;
|
|
677
|
+
// The server knows the key columns; remember them, because the
|
|
678
|
+
// refetch reconciliation needs them too and carries no id.
|
|
679
|
+
if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
|
|
508
680
|
const patchRow = patchWireEntity ? (patchWireEntity as unknown as Record<string, unknown>) : null;
|
|
509
681
|
let updated: Record<string, unknown>[];
|
|
510
682
|
|
|
511
683
|
if (patchRow === null) {
|
|
512
684
|
// Row was deleted — remove it from the cached list
|
|
513
|
-
updated = collectionSub.latestData.filter(
|
|
685
|
+
updated = collectionSub.latestData.filter(
|
|
686
|
+
e => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId)
|
|
687
|
+
);
|
|
514
688
|
} else {
|
|
515
|
-
// Row was created or updated — merge into the cached list
|
|
516
|
-
|
|
689
|
+
// Row was created or updated — merge into the cached list.
|
|
690
|
+
// Matched against the patch's own address rather than
|
|
691
|
+
// anything read off the row: `patchRow.id` is undefined
|
|
692
|
+
// for a table not keyed on `id`, so every update looked
|
|
693
|
+
// like a new row and was prepended as a duplicate.
|
|
694
|
+
const idx = collectionSub.latestData.findIndex(
|
|
695
|
+
e => this.rowAddress(e, collectionSub.pks) === String(patchEntityId)
|
|
696
|
+
);
|
|
517
697
|
if (idx >= 0) {
|
|
518
698
|
// Update in place (preserve array position)
|
|
519
699
|
updated = [...collectionSub.latestData];
|
|
@@ -555,6 +735,9 @@ export class RebaseWebSocketClient {
|
|
|
555
735
|
entitySub.latestData = row;
|
|
556
736
|
entitySub.lastUpdated = Date.now();
|
|
557
737
|
entitySub.isInitialDataReceived = true;
|
|
738
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
739
|
+
entitySub.subscribeTimeout = undefined;
|
|
740
|
+
entitySub.subscribeInFlight = false;
|
|
558
741
|
|
|
559
742
|
// Notify all callbacks for this subscription
|
|
560
743
|
entitySub.callbacks.forEach(callback => {
|
|
@@ -590,6 +773,14 @@ export class RebaseWebSocketClient {
|
|
|
590
773
|
return;
|
|
591
774
|
}
|
|
592
775
|
|
|
776
|
+
// The server answered, so nothing is in flight any more. Leave
|
|
777
|
+
// the registration in place (its listeners are still mounted
|
|
778
|
+
// and have been told), but marked idle so the next listener
|
|
779
|
+
// re-subscribes instead of attaching to a dead entry.
|
|
780
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
781
|
+
collectionSub.subscribeTimeout = undefined;
|
|
782
|
+
collectionSub.subscribeInFlight = false;
|
|
783
|
+
|
|
593
784
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
594
785
|
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
595
786
|
collectionSub.callbacks.forEach(callback => {
|
|
@@ -617,6 +808,10 @@ export class RebaseWebSocketClient {
|
|
|
617
808
|
return;
|
|
618
809
|
}
|
|
619
810
|
|
|
811
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
812
|
+
entitySub.subscribeTimeout = undefined;
|
|
813
|
+
entitySub.subscribeInFlight = false;
|
|
814
|
+
|
|
620
815
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
621
816
|
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
622
817
|
entitySub.callbacks.forEach(callback => {
|
|
@@ -717,7 +912,11 @@ export class RebaseWebSocketClient {
|
|
|
717
912
|
}
|
|
718
913
|
}
|
|
719
914
|
|
|
720
|
-
|
|
915
|
+
/**
|
|
916
|
+
* Public because `RebaseRealtimeChannel` sends channel frames through it.
|
|
917
|
+
* Not part of the stable surface — prefer `client.realtime.channel(name)`.
|
|
918
|
+
*/
|
|
919
|
+
public sendMessage(message: Record<string, unknown>): Promise<unknown> {
|
|
721
920
|
// If already has a requestId (re-sending from queue), use the stored promise handlers
|
|
722
921
|
const queuedMsg = message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void };
|
|
723
922
|
if (queuedMsg._queuedResolve && queuedMsg._queuedReject) {
|
|
@@ -725,6 +924,10 @@ export class RebaseWebSocketClient {
|
|
|
725
924
|
}
|
|
726
925
|
|
|
727
926
|
if (!this.isConnected || !this.ws) {
|
|
927
|
+
// The queue is only ever drained by a socket opening, so something
|
|
928
|
+
// has to open one. Before lazy connect this was guaranteed by the
|
|
929
|
+
// constructor; now the first frame is what asks for it.
|
|
930
|
+
this.ensureConnected();
|
|
728
931
|
// Queue the message and return a promise that will be resolved when actually sent
|
|
729
932
|
return new Promise<unknown>((resolve, reject) => {
|
|
730
933
|
const queueable = message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void };
|
|
@@ -740,8 +943,19 @@ export class RebaseWebSocketClient {
|
|
|
740
943
|
}
|
|
741
944
|
|
|
742
945
|
private async doSendMessage(message: Record<string, unknown>, resolve: (value: unknown) => void, reject: (error: Error) => void): Promise<void> {
|
|
743
|
-
// Ensure authenticated before sending non-auth messages
|
|
744
|
-
|
|
946
|
+
// Ensure authenticated before sending non-auth messages.
|
|
947
|
+
//
|
|
948
|
+
// Channel traffic is exempt. `ensureAuthenticated` throws "user not
|
|
949
|
+
// logged in" when there is no token, which rejects the frame before it
|
|
950
|
+
// is ever sent — so on an anonymous-first app (the kind this API was
|
|
951
|
+
// added for) *every* channel operation failed client-side, and the
|
|
952
|
+
// server never got to decide. Presence in a public room does not
|
|
953
|
+
// require an account. A signed-in caller still authenticates: the
|
|
954
|
+
// socket does it from `getAuthToken` on open, and the server authorizes
|
|
955
|
+
// these frames either way.
|
|
956
|
+
if (message.type !== "AUTHENTICATE"
|
|
957
|
+
&& !CHANNEL_MESSAGE_TYPES.has(message.type as string)
|
|
958
|
+
&& this.getAuthToken && !this.isAuthenticated) {
|
|
745
959
|
try {
|
|
746
960
|
await this.ensureAuthenticated();
|
|
747
961
|
} catch (error: unknown) {
|
|
@@ -754,17 +968,12 @@ export class RebaseWebSocketClient {
|
|
|
754
968
|
const requestId = (message.requestId as string) || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
755
969
|
message.requestId = requestId;
|
|
756
970
|
|
|
757
|
-
const expectsResponse = !
|
|
758
|
-
"subscribe_collection"
|
|
759
|
-
"subscribe_one"
|
|
760
|
-
"unsubscribe"
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
"broadcast",
|
|
764
|
-
"presence_track",
|
|
765
|
-
"presence_untrack",
|
|
766
|
-
"presence_state"
|
|
767
|
-
].includes(message.type as string);
|
|
971
|
+
const expectsResponse = !(
|
|
972
|
+
message.type === "subscribe_collection"
|
|
973
|
+
|| message.type === "subscribe_one"
|
|
974
|
+
|| message.type === "unsubscribe"
|
|
975
|
+
|| CHANNEL_MESSAGE_TYPES.has(message.type as string)
|
|
976
|
+
);
|
|
768
977
|
|
|
769
978
|
if (expectsResponse && !this.pendingRequests.has(requestId)) {
|
|
770
979
|
const timeoutHandle = setTimeout(() => {
|
|
@@ -857,6 +1066,13 @@ options }
|
|
|
857
1066
|
return response.roles || [];
|
|
858
1067
|
}
|
|
859
1068
|
|
|
1069
|
+
async fetchApplicationRoles(): Promise<string[]> {
|
|
1070
|
+
const response = await this.sendMessage({
|
|
1071
|
+
type: "FETCH_APPLICATION_ROLES"
|
|
1072
|
+
}) as { roles?: string[] };
|
|
1073
|
+
return response.roles || [];
|
|
1074
|
+
}
|
|
1075
|
+
|
|
860
1076
|
async fetchCurrentDatabase(): Promise<string | undefined> {
|
|
861
1077
|
const response = await this.sendMessage({
|
|
862
1078
|
type: "FETCH_CURRENT_DATABASE"
|
|
@@ -1016,23 +1232,48 @@ options }
|
|
|
1016
1232
|
return val;
|
|
1017
1233
|
}
|
|
1018
1234
|
|
|
1235
|
+
/**
|
|
1236
|
+
* The address of a row, for matching it against another copy of itself.
|
|
1237
|
+
*
|
|
1238
|
+
* A row is exactly its columns and carries no address, so it is derived
|
|
1239
|
+
* from the key columns the server named — including the ordinary case where
|
|
1240
|
+
* that key is `id`, which the server reports like any other.
|
|
1241
|
+
*
|
|
1242
|
+
* Undefined when there are no keys, which means the server could not
|
|
1243
|
+
* resolve any: such rows genuinely cannot be recognised, and guessing at a
|
|
1244
|
+
* column called `id` would be inventing an identity for a table that has
|
|
1245
|
+
* none.
|
|
1246
|
+
*/
|
|
1247
|
+
private rowAddress(row: Record<string, unknown>, pks: PrimaryKeyInfo[] | undefined): string | undefined {
|
|
1248
|
+
if (!pks || pks.length === 0) return undefined;
|
|
1249
|
+
const address = buildCompositeId(row, pks);
|
|
1250
|
+
if (!address || address.split(COMPOSITE_ID_SEPARATOR).every(part => part === "")) return undefined;
|
|
1251
|
+
return address;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1019
1254
|
/**
|
|
1020
1255
|
* Merge incoming rows with cached data, preserving cached references
|
|
1021
1256
|
* for rows whose values haven't changed. This avoids unnecessary
|
|
1022
1257
|
* React re-renders when the server refetches all rows but most
|
|
1023
1258
|
* haven't actually changed.
|
|
1024
1259
|
*/
|
|
1025
|
-
private mergeRows(
|
|
1260
|
+
private mergeRows(
|
|
1261
|
+
cached: Record<string, unknown>[] | undefined,
|
|
1262
|
+
incoming: Record<string, unknown>[],
|
|
1263
|
+
pks?: PrimaryKeyInfo[]
|
|
1264
|
+
): Record<string, unknown>[] {
|
|
1026
1265
|
if (!cached || cached.length === 0) return incoming;
|
|
1027
1266
|
|
|
1028
|
-
// Build a lookup from cached rows by
|
|
1029
|
-
const cachedById = new Map<string
|
|
1267
|
+
// Build a lookup from cached rows by address for O(1) access
|
|
1268
|
+
const cachedById = new Map<string, Record<string, unknown>>();
|
|
1030
1269
|
for (const row of cached) {
|
|
1031
|
-
|
|
1270
|
+
const address = this.rowAddress(row, pks);
|
|
1271
|
+
if (address !== undefined) cachedById.set(address, row);
|
|
1032
1272
|
}
|
|
1033
1273
|
|
|
1034
1274
|
return incoming.map(incomingRow => {
|
|
1035
|
-
const
|
|
1275
|
+
const address = this.rowAddress(incomingRow, pks);
|
|
1276
|
+
const cachedRow = address === undefined ? undefined : cachedById.get(address);
|
|
1036
1277
|
if (!cachedRow) return incomingRow;
|
|
1037
1278
|
|
|
1038
1279
|
// Compare flat rows directly (no more path/values nesting)
|
|
@@ -1051,7 +1292,7 @@ options }
|
|
|
1051
1292
|
incoming: normIncoming[key] };
|
|
1052
1293
|
}
|
|
1053
1294
|
}
|
|
1054
|
-
console.debug(`[RebaseWS] Row ${
|
|
1295
|
+
console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
|
|
1055
1296
|
}
|
|
1056
1297
|
return incomingRow;
|
|
1057
1298
|
});
|
|
@@ -1063,6 +1304,11 @@ incoming: normIncoming[key] };
|
|
|
1063
1304
|
onUpdate: (rows: Record<string, unknown>[]) => void,
|
|
1064
1305
|
onError?: (error: Error) => void
|
|
1065
1306
|
): () => void {
|
|
1307
|
+
// A subscription is the app asking for live data, so this is where the
|
|
1308
|
+
// socket is wanted. Called before the dedup check below: joining an
|
|
1309
|
+
// existing subscription must still work if the socket has since gone.
|
|
1310
|
+
this.ensureConnected();
|
|
1311
|
+
|
|
1066
1312
|
const subscriptionKey = this.createCollectionSubscriptionKey(props);
|
|
1067
1313
|
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1068
1314
|
|
|
@@ -1088,13 +1334,21 @@ onError });
|
|
|
1088
1334
|
onError(error instanceof Error ? error : new Error(String(error)));
|
|
1089
1335
|
}
|
|
1090
1336
|
}
|
|
1337
|
+
} else if (!existingSubscription.subscribeInFlight) {
|
|
1338
|
+
// Registered but idle: its subscribe never landed (the send failed,
|
|
1339
|
+
// or the server answered with an error). Nothing is coming, so
|
|
1340
|
+
// re-issue it — otherwise this listener waits forever.
|
|
1341
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
1091
1342
|
}
|
|
1092
1343
|
|
|
1093
1344
|
// Return unsubscribe function
|
|
1094
1345
|
return () => {
|
|
1095
1346
|
callbackMap.delete(callbackId);
|
|
1096
1347
|
if (callbackMap.size === 0) {
|
|
1097
|
-
//
|
|
1348
|
+
// Only tear down if this is still the same registration — a
|
|
1349
|
+
// failed subscribe may have replaced it in the meantime.
|
|
1350
|
+
if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
1351
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
1098
1352
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
1099
1353
|
this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
|
|
1100
1354
|
if (this.isConnected && this.ws) {
|
|
@@ -1125,16 +1379,9 @@ onError });
|
|
|
1125
1379
|
// Add reverse lookup
|
|
1126
1380
|
this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
|
|
1127
1381
|
|
|
1128
|
-
// Send subscription request to backend
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
payload: {
|
|
1132
|
-
...props,
|
|
1133
|
-
subscriptionId: backendSubscriptionId
|
|
1134
|
-
}
|
|
1135
|
-
}).catch(error => {
|
|
1136
|
-
if (onError) onError(error);
|
|
1137
|
-
});
|
|
1382
|
+
// Send subscription request to backend. A failure here drops the
|
|
1383
|
+
// registration and notifies every listener, so the next mount retries.
|
|
1384
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
1138
1385
|
|
|
1139
1386
|
// Return unsubscribe function
|
|
1140
1387
|
return () => {
|
|
@@ -1143,6 +1390,7 @@ onError });
|
|
|
1143
1390
|
const callbacks = subscription.callbacks;
|
|
1144
1391
|
callbacks.delete(callbackId);
|
|
1145
1392
|
if (callbacks.size === 0) {
|
|
1393
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1146
1394
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
1147
1395
|
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
1148
1396
|
if (this.isConnected && this.ws) {
|
|
@@ -1161,6 +1409,8 @@ onError });
|
|
|
1161
1409
|
onUpdate: (row: Record<string, unknown> | null) => void,
|
|
1162
1410
|
onError?: (error: Error) => void
|
|
1163
1411
|
): () => void {
|
|
1412
|
+
this.ensureConnected();
|
|
1413
|
+
|
|
1164
1414
|
const subscriptionKey = this.createSingleSubscriptionKey(props);
|
|
1165
1415
|
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1166
1416
|
|
|
@@ -1186,12 +1436,18 @@ onError });
|
|
|
1186
1436
|
onError(error instanceof Error ? error : new Error(String(error)));
|
|
1187
1437
|
}
|
|
1188
1438
|
}
|
|
1439
|
+
} else if (!existingSubscription.subscribeInFlight) {
|
|
1440
|
+
// See listenCollection: a registration with nothing in flight is
|
|
1441
|
+
// dead, and attaching to it silently would hang this listener.
|
|
1442
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
1189
1443
|
}
|
|
1190
1444
|
|
|
1191
1445
|
// Return unsubscribe function
|
|
1192
1446
|
return () => {
|
|
1193
1447
|
callbackMap.delete(callbackId);
|
|
1194
1448
|
if (callbackMap.size === 0) {
|
|
1449
|
+
if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
1450
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
1195
1451
|
// No more callbacks, unsubscribe from backend
|
|
1196
1452
|
this.singleSubscriptions.delete(subscriptionKey);
|
|
1197
1453
|
this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
|
|
@@ -1224,15 +1480,7 @@ onError });
|
|
|
1224
1480
|
this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
|
|
1225
1481
|
|
|
1226
1482
|
// Send subscription request to backend
|
|
1227
|
-
this.
|
|
1228
|
-
type: "subscribe_one",
|
|
1229
|
-
payload: {
|
|
1230
|
-
...props,
|
|
1231
|
-
subscriptionId: backendSubscriptionId
|
|
1232
|
-
}
|
|
1233
|
-
}).catch(error => {
|
|
1234
|
-
if (onError) onError(error);
|
|
1235
|
-
});
|
|
1483
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
1236
1484
|
|
|
1237
1485
|
// Return unsubscribe function
|
|
1238
1486
|
return () => {
|
|
@@ -1254,6 +1502,201 @@ onError });
|
|
|
1254
1502
|
};
|
|
1255
1503
|
}
|
|
1256
1504
|
|
|
1505
|
+
/**
|
|
1506
|
+
* Send a `subscribe_collection` for an already-registered subscription and
|
|
1507
|
+
* arm its watchdog.
|
|
1508
|
+
*
|
|
1509
|
+
* Every path that registers a collection subscription goes through here, so
|
|
1510
|
+
* that a subscribe which never lands — a rejected send, or a server that
|
|
1511
|
+
* never answers — always ends up in `failCollectionSubscription` rather than
|
|
1512
|
+
* leaving the entry parked with `isInitialDataReceived === false` forever.
|
|
1513
|
+
*/
|
|
1514
|
+
private sendCollectionSubscribe(subscriptionKey: string): void {
|
|
1515
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1516
|
+
if (!subscription) return;
|
|
1517
|
+
|
|
1518
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1519
|
+
subscription.subscribeInFlight = true;
|
|
1520
|
+
|
|
1521
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1522
|
+
subscription.subscribeTimeout = undefined;
|
|
1523
|
+
// Only time out a frame that is actually on the wire. While offline the
|
|
1524
|
+
// message just sits in the queue, and reconnect backoff can exceed the
|
|
1525
|
+
// timeout — `armPendingSubscribeWatchdogs` picks these up on connect.
|
|
1526
|
+
if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
|
|
1527
|
+
|
|
1528
|
+
this.sendMessage({
|
|
1529
|
+
type: "subscribe_collection",
|
|
1530
|
+
payload: {
|
|
1531
|
+
...subscription.props,
|
|
1532
|
+
subscriptionId: backendSubscriptionId
|
|
1533
|
+
}
|
|
1534
|
+
}).catch(error => {
|
|
1535
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
1536
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1537
|
+
this.failCollectionSubscription(
|
|
1538
|
+
subscriptionKey,
|
|
1539
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1540
|
+
);
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
|
|
1545
|
+
private sendEntitySubscribe(subscriptionKey: string): void {
|
|
1546
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1547
|
+
if (!subscription) return;
|
|
1548
|
+
|
|
1549
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1550
|
+
subscription.subscribeInFlight = true;
|
|
1551
|
+
|
|
1552
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1553
|
+
subscription.subscribeTimeout = undefined;
|
|
1554
|
+
if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
|
|
1555
|
+
|
|
1556
|
+
this.sendMessage({
|
|
1557
|
+
type: "subscribe_one",
|
|
1558
|
+
payload: {
|
|
1559
|
+
...subscription.props,
|
|
1560
|
+
subscriptionId: backendSubscriptionId
|
|
1561
|
+
}
|
|
1562
|
+
}).catch(error => {
|
|
1563
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
1564
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1565
|
+
this.failEntitySubscription(
|
|
1566
|
+
subscriptionKey,
|
|
1567
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1568
|
+
);
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* Report a subscribe failure to every listener and drop the registration.
|
|
1574
|
+
*
|
|
1575
|
+
* Dropping it is the point: the callbacks stay live (their components are
|
|
1576
|
+
* still mounted and have been told), but the next `listenCollection` for
|
|
1577
|
+
* these params finds no entry and issues a fresh subscribe instead of
|
|
1578
|
+
* silently attaching to a dead one.
|
|
1579
|
+
*/
|
|
1580
|
+
private failCollectionSubscription(subscriptionKey: string, error: Error): void {
|
|
1581
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1582
|
+
if (!subscription) return;
|
|
1583
|
+
|
|
1584
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1585
|
+
subscription.subscribeInFlight = false;
|
|
1586
|
+
|
|
1587
|
+
this.collectionSubscriptions.delete(subscriptionKey);
|
|
1588
|
+
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
1589
|
+
|
|
1590
|
+
subscription.callbacks.forEach(callback => {
|
|
1591
|
+
if (callback.onError) {
|
|
1592
|
+
try {
|
|
1593
|
+
callback.onError(error);
|
|
1594
|
+
} catch (callbackError) {
|
|
1595
|
+
console.error("Error in collection subscription error callback:", callbackError);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/** The `listenOne` counterpart of {@link failCollectionSubscription}. */
|
|
1602
|
+
private failEntitySubscription(subscriptionKey: string, error: Error): void {
|
|
1603
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1604
|
+
if (!subscription) return;
|
|
1605
|
+
|
|
1606
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1607
|
+
subscription.subscribeInFlight = false;
|
|
1608
|
+
|
|
1609
|
+
this.singleSubscriptions.delete(subscriptionKey);
|
|
1610
|
+
this.backendToEntityKey.delete(subscription.backendSubscriptionId);
|
|
1611
|
+
|
|
1612
|
+
subscription.callbacks.forEach(callback => {
|
|
1613
|
+
if (callback.onError) {
|
|
1614
|
+
try {
|
|
1615
|
+
callback.onError(error);
|
|
1616
|
+
} catch (callbackError) {
|
|
1617
|
+
console.error("Error in row subscription error callback:", callbackError);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* Stop the watchdogs without failing anything — used when the socket drops,
|
|
1625
|
+
* since the reconnect path re-subscribes everything anyway and a watchdog
|
|
1626
|
+
* firing mid-reconnect would tear down healthy subscriptions.
|
|
1627
|
+
*/
|
|
1628
|
+
private suspendSubscribeWatchdogs(): void {
|
|
1629
|
+
for (const sub of this.collectionSubscriptions.values()) {
|
|
1630
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
1631
|
+
sub.subscribeTimeout = undefined;
|
|
1632
|
+
sub.subscribeInFlight = false;
|
|
1633
|
+
}
|
|
1634
|
+
for (const sub of this.singleSubscriptions.values()) {
|
|
1635
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
1636
|
+
sub.subscribeTimeout = undefined;
|
|
1637
|
+
sub.subscribeInFlight = false;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* Arm watchdogs for subscribes that were requested while offline and have
|
|
1643
|
+
* just been flushed to the socket. Their timers were deliberately not set at
|
|
1644
|
+
* request time, so without this they would have no timeout at all.
|
|
1645
|
+
*/
|
|
1646
|
+
private armPendingSubscribeWatchdogs(): void {
|
|
1647
|
+
for (const [key, sub] of this.collectionSubscriptions.entries()) {
|
|
1648
|
+
if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
|
|
1649
|
+
}
|
|
1650
|
+
for (const [key, sub] of this.singleSubscriptions.entries()) {
|
|
1651
|
+
if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
private sendCollectionSubscribeWatchdog(subscriptionKey: string): void {
|
|
1656
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1657
|
+
if (!subscription) return;
|
|
1658
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1659
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
1660
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
1661
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1662
|
+
if (!current.subscribeInFlight) return;
|
|
1663
|
+
this.failCollectionSubscription(
|
|
1664
|
+
subscriptionKey,
|
|
1665
|
+
new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" })
|
|
1666
|
+
);
|
|
1667
|
+
}, this.subscriptionTimeoutMs);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
private sendEntitySubscribeWatchdog(subscriptionKey: string): void {
|
|
1671
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1672
|
+
if (!subscription) return;
|
|
1673
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1674
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
1675
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
1676
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1677
|
+
if (!current.subscribeInFlight) return;
|
|
1678
|
+
this.failEntitySubscription(
|
|
1679
|
+
subscriptionKey,
|
|
1680
|
+
new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" })
|
|
1681
|
+
);
|
|
1682
|
+
}, this.subscriptionTimeoutMs);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* Fail every subscription that never received data. Called when reconnection
|
|
1687
|
+
* is given up on, so views surface an error instead of spinning forever.
|
|
1688
|
+
*/
|
|
1689
|
+
private failAllPendingSubscriptions(error: Error): void {
|
|
1690
|
+
for (const key of [...this.collectionSubscriptions.keys()]) {
|
|
1691
|
+
const sub = this.collectionSubscriptions.get(key);
|
|
1692
|
+
if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
|
|
1693
|
+
}
|
|
1694
|
+
for (const key of [...this.singleSubscriptions.keys()]) {
|
|
1695
|
+
const sub = this.singleSubscriptions.get(key);
|
|
1696
|
+
if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1257
1700
|
/**
|
|
1258
1701
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
1259
1702
|
* The server wipes subscription state when a client disconnects, so
|
|
@@ -1273,15 +1716,7 @@ onError });
|
|
|
1273
1716
|
this.backendToCollectionKey.delete(oldBackendId);
|
|
1274
1717
|
this.backendToCollectionKey.set(newBackendId, key);
|
|
1275
1718
|
|
|
1276
|
-
this.
|
|
1277
|
-
type: "subscribe_collection",
|
|
1278
|
-
payload: {
|
|
1279
|
-
...sub.props,
|
|
1280
|
-
subscriptionId: newBackendId
|
|
1281
|
-
}
|
|
1282
|
-
}).catch(error => {
|
|
1283
|
-
console.error("[WS] Failed to re-subscribe collection:", key, error);
|
|
1284
|
-
});
|
|
1719
|
+
this.sendCollectionSubscribe(key);
|
|
1285
1720
|
}
|
|
1286
1721
|
|
|
1287
1722
|
// Re-subscribe row subscriptions
|
|
@@ -1293,15 +1728,7 @@ onError });
|
|
|
1293
1728
|
this.backendToEntityKey.delete(oldBackendId);
|
|
1294
1729
|
this.backendToEntityKey.set(newBackendId, key);
|
|
1295
1730
|
|
|
1296
|
-
this.
|
|
1297
|
-
type: "subscribe_one",
|
|
1298
|
-
payload: {
|
|
1299
|
-
...sub.props,
|
|
1300
|
-
subscriptionId: newBackendId
|
|
1301
|
-
}
|
|
1302
|
-
}).catch(error => {
|
|
1303
|
-
console.error("[WS] Failed to re-subscribe row:", key, error);
|
|
1304
|
-
});
|
|
1731
|
+
this.sendEntitySubscribe(key);
|
|
1305
1732
|
}
|
|
1306
1733
|
}
|
|
1307
1734
|
|