@rebasepro/client 0.9.0 → 0.9.1-canary.09aaf62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/admin.d.ts +1 -0
- package/dist/backups.d.ts +13 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +516 -72
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +89 -0
- package/dist/transport.d.ts +34 -0
- package/dist/websocket.d.ts +68 -2
- package/package.json +8 -9
- package/src/admin.ts +1 -1
- package/src/api-keys.ts +1 -1
- package/src/backups.ts +40 -0
- package/src/collection.ts +16 -0
- package/src/index.ts +66 -2
- package/src/realtime-channel.test.ts +241 -0
- package/src/realtime-channel.ts +238 -0
- package/src/realtime-optout.test.ts +119 -0
- package/src/realtime-row-identity.test.ts +254 -0
- package/src/sdk_query_builder.ts +4 -1
- package/src/transport.ts +34 -0
- package/src/websocket.ts +403 -72
- 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 -2484
- 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
|
|
|
@@ -50,7 +58,7 @@ export interface RebaseWebSocketConfig {
|
|
|
50
58
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
51
59
|
* manages this internally (exposed as `client.ws`, typed by the minimal
|
|
52
60
|
* `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
|
|
53
|
-
* package root only because the `@rebasepro/client-
|
|
61
|
+
* package root only because the `@rebasepro/client-postgres` driver
|
|
54
62
|
* instantiates it directly; its surface may change without a major bump.
|
|
55
63
|
*/
|
|
56
64
|
export class RebaseWebSocketClient {
|
|
@@ -64,6 +72,26 @@ export class RebaseWebSocketClient {
|
|
|
64
72
|
|
|
65
73
|
private listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
|
66
74
|
|
|
75
|
+
/** Channel-name → handlers, for broadcast and presence frames. */
|
|
76
|
+
private channelHandlers = new Map<string, Set<(message: Record<string, unknown>) => void>>();
|
|
77
|
+
|
|
78
|
+
/** Subscribe to broadcast/presence frames for one channel. */
|
|
79
|
+
public onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void {
|
|
80
|
+
if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, new Set());
|
|
81
|
+
this.channelHandlers.get(channel)!.add(handler);
|
|
82
|
+
return () => {
|
|
83
|
+
const handlers = this.channelHandlers.get(channel);
|
|
84
|
+
if (!handlers) return;
|
|
85
|
+
handlers.delete(handler);
|
|
86
|
+
if (handlers.size === 0) this.channelHandlers.delete(channel);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Notified after the socket comes back, so channels can re-join. */
|
|
91
|
+
public onReconnect(handler: () => void): () => void {
|
|
92
|
+
return this.on("reconnect", handler);
|
|
93
|
+
}
|
|
94
|
+
|
|
67
95
|
public on(event: "connect" | "disconnect" | "reconnect" | "error", cb: (...args: unknown[]) => void) {
|
|
68
96
|
if (!this.listeners.has(event)) {
|
|
69
97
|
this.listeners.set(event, new Set());
|
|
@@ -89,6 +117,25 @@ export class RebaseWebSocketClient {
|
|
|
89
117
|
latestData?: Record<string, unknown>[]; // Cache the latest flat rows
|
|
90
118
|
lastUpdated?: number; // Timestamp for cache invalidation
|
|
91
119
|
isInitialDataReceived?: boolean; // Track if we got initial data
|
|
120
|
+
/**
|
|
121
|
+
* A `subscribe_collection` frame is on the wire and its initial payload
|
|
122
|
+
* has not arrived yet. Without this, a subscription whose subscribe
|
|
123
|
+
* failed is indistinguishable from one still loading, and every later
|
|
124
|
+
* listener attaches to it and waits forever.
|
|
125
|
+
*/
|
|
126
|
+
subscribeInFlight?: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Watchdog for the above. `subscribe_collection` expects no response
|
|
129
|
+
* envelope, so it is not covered by `pendingRequests`' timeout — a lost
|
|
130
|
+
* initial payload would otherwise hang the subscription indefinitely.
|
|
131
|
+
*/
|
|
132
|
+
subscribeTimeout?: ReturnType<typeof setTimeout>;
|
|
133
|
+
/**
|
|
134
|
+
* The key columns of this collection, as told by the server on a patch.
|
|
135
|
+
* Rows are columns only, and the SDK holds no collection config, so
|
|
136
|
+
* without this there is nothing to derive an address from.
|
|
137
|
+
*/
|
|
138
|
+
pks?: PrimaryKeyInfo[];
|
|
92
139
|
}>();
|
|
93
140
|
|
|
94
141
|
private singleSubscriptions = new Map<string, {
|
|
@@ -101,6 +148,9 @@ export class RebaseWebSocketClient {
|
|
|
101
148
|
latestData?: Record<string, unknown> | null; // Cache the latest flat row
|
|
102
149
|
lastUpdated?: number; // Timestamp for cache invalidation
|
|
103
150
|
isInitialDataReceived?: boolean; // Track if we got initial data
|
|
151
|
+
/** See the collection subscription counterparts. */
|
|
152
|
+
subscribeInFlight?: boolean;
|
|
153
|
+
subscribeTimeout?: ReturnType<typeof setTimeout>;
|
|
104
154
|
}>();
|
|
105
155
|
|
|
106
156
|
// Maps to quickly find subscription by backend subscription ID
|
|
@@ -118,6 +168,7 @@ export class RebaseWebSocketClient {
|
|
|
118
168
|
private isConnected = false;
|
|
119
169
|
private messageQueue: Record<string, unknown>[] = [];
|
|
120
170
|
private requestTimeoutMs = 30000;
|
|
171
|
+
private subscriptionTimeoutMs = 30000;
|
|
121
172
|
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
122
173
|
|
|
123
174
|
private isAuthenticated = false;
|
|
@@ -263,6 +314,10 @@ export class RebaseWebSocketClient {
|
|
|
263
314
|
if (wasReconnect) {
|
|
264
315
|
this.resubscribeAll();
|
|
265
316
|
}
|
|
317
|
+
|
|
318
|
+
// Subscribes requested while offline have just gone out; they
|
|
319
|
+
// could not be watchdogged at request time.
|
|
320
|
+
this.armPendingSubscribeWatchdogs();
|
|
266
321
|
};
|
|
267
322
|
|
|
268
323
|
this.ws!.onmessage = (event) => {
|
|
@@ -279,6 +334,9 @@ export class RebaseWebSocketClient {
|
|
|
279
334
|
this.isConnected = false;
|
|
280
335
|
this.isAuthenticated = false;
|
|
281
336
|
this.authPromise = null;
|
|
337
|
+
// The reconnect path re-subscribes everything; a watchdog firing
|
|
338
|
+
// in the meantime would tear down healthy subscriptions.
|
|
339
|
+
this.suspendSubscribeWatchdogs();
|
|
282
340
|
this.emit("disconnect");
|
|
283
341
|
|
|
284
342
|
// Re-queue pending requests so the UI doesn't hang indefinitely or crash
|
|
@@ -319,6 +377,11 @@ export class RebaseWebSocketClient {
|
|
|
319
377
|
private attemptReconnect() {
|
|
320
378
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
321
379
|
console.error("Max reconnection attempts reached");
|
|
380
|
+
// Nothing will re-subscribe now, so stop every subscription that
|
|
381
|
+
// never loaded from spinning forever.
|
|
382
|
+
this.failAllPendingSubscriptions(
|
|
383
|
+
new RebaseApiError("Connection lost", { code: "CONNECTION_LOST" })
|
|
384
|
+
);
|
|
322
385
|
return;
|
|
323
386
|
}
|
|
324
387
|
|
|
@@ -399,29 +462,32 @@ export class RebaseWebSocketClient {
|
|
|
399
462
|
backendKeyMap.delete(oldBackendId);
|
|
400
463
|
backendKeyMap.set(newBackendId, subscriptionKey);
|
|
401
464
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
465
|
+
// Route through the helpers so the retry is watchdogged too.
|
|
466
|
+
if (messageType === "subscribe_collection") {
|
|
467
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
468
|
+
} else {
|
|
469
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
470
|
+
}
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// The refresh did not produce usable credentials. Report the original
|
|
475
|
+
// error and drop the registration, so a later mount can try again
|
|
476
|
+
// rather than attaching to a subscription that will never load.
|
|
477
|
+
const { errorMessage, errorCode } = extractMessageError(message);
|
|
478
|
+
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
479
|
+
if (messageType === "subscribe_collection") {
|
|
480
|
+
this.failCollectionSubscription(subscriptionKey, error);
|
|
414
481
|
} else {
|
|
415
|
-
|
|
416
|
-
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
417
|
-
subscription.callbacks.forEach(callback => {
|
|
418
|
-
if (callback.onError) callback.onError(error);
|
|
419
|
-
});
|
|
482
|
+
this.failEntitySubscription(subscriptionKey, error);
|
|
420
483
|
}
|
|
421
484
|
}).catch(err => {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
485
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
486
|
+
if (messageType === "subscribe_collection") {
|
|
487
|
+
this.failCollectionSubscription(subscriptionKey, error);
|
|
488
|
+
} else {
|
|
489
|
+
this.failEntitySubscription(subscriptionKey, error);
|
|
490
|
+
}
|
|
425
491
|
});
|
|
426
492
|
}
|
|
427
493
|
|
|
@@ -460,6 +526,25 @@ export class RebaseWebSocketClient {
|
|
|
460
526
|
return;
|
|
461
527
|
}
|
|
462
528
|
|
|
529
|
+
// Channel traffic (broadcast / presence) is addressed by channel name
|
|
530
|
+
// rather than by requestId or subscriptionId, so it is dispatched
|
|
531
|
+
// before the subscription paths — none of which would match it, and
|
|
532
|
+
// the message would otherwise fall through and be dropped silently.
|
|
533
|
+
if (typeof message.channel === "string" &&
|
|
534
|
+
(type === "broadcast" || type === "presence_state" || type === "presence_diff")) {
|
|
535
|
+
const handlers = this.channelHandlers.get(message.channel);
|
|
536
|
+
if (handlers) {
|
|
537
|
+
for (const handler of [...handlers]) {
|
|
538
|
+
try {
|
|
539
|
+
handler(message as unknown as Record<string, unknown>);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
console.error("Error in channel handler:", error);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
463
548
|
// Handle subscription updates for collection subscriptions
|
|
464
549
|
if (subscriptionId && type === "collection_update") {
|
|
465
550
|
const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
|
|
@@ -469,16 +554,27 @@ export class RebaseWebSocketClient {
|
|
|
469
554
|
const wireEntities = (message.rows || []) as unknown as Record<string, unknown>[];
|
|
470
555
|
const incomingRows = wireEntities;
|
|
471
556
|
|
|
557
|
+
// The keys arrive with the rows, so they are known before the
|
|
558
|
+
// first merge — a CDC-driven change never sends a patch, and
|
|
559
|
+
// learning them from patches alone would leave every
|
|
560
|
+
// externally-written collection unable to match a thing.
|
|
561
|
+
const updatePks = (message as unknown as { pks?: PrimaryKeyInfo[] }).pks;
|
|
562
|
+
if (updatePks) collectionSub.pks = updatePks;
|
|
563
|
+
|
|
472
564
|
// Structural merge: preserve cached row references for rows
|
|
473
565
|
// whose values haven't changed. This prevents downstream React components
|
|
474
566
|
// from re-rendering (VirtualTableCell uses deepEqual on rowData —
|
|
475
567
|
// same reference = instant true, avoiding expensive deep comparison).
|
|
476
|
-
const rows = this.mergeRows(collectionSub.latestData, incomingRows);
|
|
568
|
+
const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
|
|
477
569
|
|
|
478
570
|
// Cache the latest data with optimizations
|
|
479
571
|
collectionSub.latestData = rows;
|
|
480
572
|
collectionSub.lastUpdated = Date.now();
|
|
481
573
|
collectionSub.isInitialDataReceived = true;
|
|
574
|
+
// The subscribe landed — stand the watchdog down.
|
|
575
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
576
|
+
collectionSub.subscribeTimeout = undefined;
|
|
577
|
+
collectionSub.subscribeInFlight = false;
|
|
482
578
|
|
|
483
579
|
// Notify all callbacks for this subscription
|
|
484
580
|
collectionSub.callbacks.forEach(callback => {
|
|
@@ -504,16 +600,28 @@ export class RebaseWebSocketClient {
|
|
|
504
600
|
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
505
601
|
if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
|
|
506
602
|
const patchWireEntity = message.row ?? null;
|
|
507
|
-
const
|
|
603
|
+
const patchMessage = message as unknown as { id: string; pks?: PrimaryKeyInfo[] };
|
|
604
|
+
const patchEntityId = patchMessage.id;
|
|
605
|
+
// The server knows the key columns; remember them, because the
|
|
606
|
+
// refetch reconciliation needs them too and carries no id.
|
|
607
|
+
if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
|
|
508
608
|
const patchRow = patchWireEntity ? (patchWireEntity as unknown as Record<string, unknown>) : null;
|
|
509
609
|
let updated: Record<string, unknown>[];
|
|
510
610
|
|
|
511
611
|
if (patchRow === null) {
|
|
512
612
|
// Row was deleted — remove it from the cached list
|
|
513
|
-
updated = collectionSub.latestData.filter(
|
|
613
|
+
updated = collectionSub.latestData.filter(
|
|
614
|
+
e => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId)
|
|
615
|
+
);
|
|
514
616
|
} else {
|
|
515
|
-
// Row was created or updated — merge into the cached list
|
|
516
|
-
|
|
617
|
+
// Row was created or updated — merge into the cached list.
|
|
618
|
+
// Matched against the patch's own address rather than
|
|
619
|
+
// anything read off the row: `patchRow.id` is undefined
|
|
620
|
+
// for a table not keyed on `id`, so every update looked
|
|
621
|
+
// like a new row and was prepended as a duplicate.
|
|
622
|
+
const idx = collectionSub.latestData.findIndex(
|
|
623
|
+
e => this.rowAddress(e, collectionSub.pks) === String(patchEntityId)
|
|
624
|
+
);
|
|
517
625
|
if (idx >= 0) {
|
|
518
626
|
// Update in place (preserve array position)
|
|
519
627
|
updated = [...collectionSub.latestData];
|
|
@@ -555,6 +663,9 @@ export class RebaseWebSocketClient {
|
|
|
555
663
|
entitySub.latestData = row;
|
|
556
664
|
entitySub.lastUpdated = Date.now();
|
|
557
665
|
entitySub.isInitialDataReceived = true;
|
|
666
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
667
|
+
entitySub.subscribeTimeout = undefined;
|
|
668
|
+
entitySub.subscribeInFlight = false;
|
|
558
669
|
|
|
559
670
|
// Notify all callbacks for this subscription
|
|
560
671
|
entitySub.callbacks.forEach(callback => {
|
|
@@ -590,6 +701,14 @@ export class RebaseWebSocketClient {
|
|
|
590
701
|
return;
|
|
591
702
|
}
|
|
592
703
|
|
|
704
|
+
// The server answered, so nothing is in flight any more. Leave
|
|
705
|
+
// the registration in place (its listeners are still mounted
|
|
706
|
+
// and have been told), but marked idle so the next listener
|
|
707
|
+
// re-subscribes instead of attaching to a dead entry.
|
|
708
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
709
|
+
collectionSub.subscribeTimeout = undefined;
|
|
710
|
+
collectionSub.subscribeInFlight = false;
|
|
711
|
+
|
|
593
712
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
594
713
|
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
595
714
|
collectionSub.callbacks.forEach(callback => {
|
|
@@ -617,6 +736,10 @@ export class RebaseWebSocketClient {
|
|
|
617
736
|
return;
|
|
618
737
|
}
|
|
619
738
|
|
|
739
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
740
|
+
entitySub.subscribeTimeout = undefined;
|
|
741
|
+
entitySub.subscribeInFlight = false;
|
|
742
|
+
|
|
620
743
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
621
744
|
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
622
745
|
entitySub.callbacks.forEach(callback => {
|
|
@@ -717,7 +840,11 @@ export class RebaseWebSocketClient {
|
|
|
717
840
|
}
|
|
718
841
|
}
|
|
719
842
|
|
|
720
|
-
|
|
843
|
+
/**
|
|
844
|
+
* Public because `RebaseRealtimeChannel` sends channel frames through it.
|
|
845
|
+
* Not part of the stable surface — prefer `client.realtime.channel(name)`.
|
|
846
|
+
*/
|
|
847
|
+
public sendMessage(message: Record<string, unknown>): Promise<unknown> {
|
|
721
848
|
// If already has a requestId (re-sending from queue), use the stored promise handlers
|
|
722
849
|
const queuedMsg = message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void };
|
|
723
850
|
if (queuedMsg._queuedResolve && queuedMsg._queuedReject) {
|
|
@@ -1016,23 +1143,48 @@ options }
|
|
|
1016
1143
|
return val;
|
|
1017
1144
|
}
|
|
1018
1145
|
|
|
1146
|
+
/**
|
|
1147
|
+
* The address of a row, for matching it against another copy of itself.
|
|
1148
|
+
*
|
|
1149
|
+
* A row is exactly its columns and carries no address, so it is derived
|
|
1150
|
+
* from the key columns the server named — including the ordinary case where
|
|
1151
|
+
* that key is `id`, which the server reports like any other.
|
|
1152
|
+
*
|
|
1153
|
+
* Undefined when there are no keys, which means the server could not
|
|
1154
|
+
* resolve any: such rows genuinely cannot be recognised, and guessing at a
|
|
1155
|
+
* column called `id` would be inventing an identity for a table that has
|
|
1156
|
+
* none.
|
|
1157
|
+
*/
|
|
1158
|
+
private rowAddress(row: Record<string, unknown>, pks: PrimaryKeyInfo[] | undefined): string | undefined {
|
|
1159
|
+
if (!pks || pks.length === 0) return undefined;
|
|
1160
|
+
const address = buildCompositeId(row, pks);
|
|
1161
|
+
if (!address || address.split(COMPOSITE_ID_SEPARATOR).every(part => part === "")) return undefined;
|
|
1162
|
+
return address;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1019
1165
|
/**
|
|
1020
1166
|
* Merge incoming rows with cached data, preserving cached references
|
|
1021
1167
|
* for rows whose values haven't changed. This avoids unnecessary
|
|
1022
1168
|
* React re-renders when the server refetches all rows but most
|
|
1023
1169
|
* haven't actually changed.
|
|
1024
1170
|
*/
|
|
1025
|
-
private mergeRows(
|
|
1171
|
+
private mergeRows(
|
|
1172
|
+
cached: Record<string, unknown>[] | undefined,
|
|
1173
|
+
incoming: Record<string, unknown>[],
|
|
1174
|
+
pks?: PrimaryKeyInfo[]
|
|
1175
|
+
): Record<string, unknown>[] {
|
|
1026
1176
|
if (!cached || cached.length === 0) return incoming;
|
|
1027
1177
|
|
|
1028
|
-
// Build a lookup from cached rows by
|
|
1029
|
-
const cachedById = new Map<string
|
|
1178
|
+
// Build a lookup from cached rows by address for O(1) access
|
|
1179
|
+
const cachedById = new Map<string, Record<string, unknown>>();
|
|
1030
1180
|
for (const row of cached) {
|
|
1031
|
-
|
|
1181
|
+
const address = this.rowAddress(row, pks);
|
|
1182
|
+
if (address !== undefined) cachedById.set(address, row);
|
|
1032
1183
|
}
|
|
1033
1184
|
|
|
1034
1185
|
return incoming.map(incomingRow => {
|
|
1035
|
-
const
|
|
1186
|
+
const address = this.rowAddress(incomingRow, pks);
|
|
1187
|
+
const cachedRow = address === undefined ? undefined : cachedById.get(address);
|
|
1036
1188
|
if (!cachedRow) return incomingRow;
|
|
1037
1189
|
|
|
1038
1190
|
// Compare flat rows directly (no more path/values nesting)
|
|
@@ -1051,7 +1203,7 @@ options }
|
|
|
1051
1203
|
incoming: normIncoming[key] };
|
|
1052
1204
|
}
|
|
1053
1205
|
}
|
|
1054
|
-
console.debug(`[RebaseWS] Row ${
|
|
1206
|
+
console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
|
|
1055
1207
|
}
|
|
1056
1208
|
return incomingRow;
|
|
1057
1209
|
});
|
|
@@ -1088,13 +1240,21 @@ onError });
|
|
|
1088
1240
|
onError(error instanceof Error ? error : new Error(String(error)));
|
|
1089
1241
|
}
|
|
1090
1242
|
}
|
|
1243
|
+
} else if (!existingSubscription.subscribeInFlight) {
|
|
1244
|
+
// Registered but idle: its subscribe never landed (the send failed,
|
|
1245
|
+
// or the server answered with an error). Nothing is coming, so
|
|
1246
|
+
// re-issue it — otherwise this listener waits forever.
|
|
1247
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
1091
1248
|
}
|
|
1092
1249
|
|
|
1093
1250
|
// Return unsubscribe function
|
|
1094
1251
|
return () => {
|
|
1095
1252
|
callbackMap.delete(callbackId);
|
|
1096
1253
|
if (callbackMap.size === 0) {
|
|
1097
|
-
//
|
|
1254
|
+
// Only tear down if this is still the same registration — a
|
|
1255
|
+
// failed subscribe may have replaced it in the meantime.
|
|
1256
|
+
if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
1257
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
1098
1258
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
1099
1259
|
this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
|
|
1100
1260
|
if (this.isConnected && this.ws) {
|
|
@@ -1125,16 +1285,9 @@ onError });
|
|
|
1125
1285
|
// Add reverse lookup
|
|
1126
1286
|
this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
|
|
1127
1287
|
|
|
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
|
-
});
|
|
1288
|
+
// Send subscription request to backend. A failure here drops the
|
|
1289
|
+
// registration and notifies every listener, so the next mount retries.
|
|
1290
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
1138
1291
|
|
|
1139
1292
|
// Return unsubscribe function
|
|
1140
1293
|
return () => {
|
|
@@ -1143,6 +1296,7 @@ onError });
|
|
|
1143
1296
|
const callbacks = subscription.callbacks;
|
|
1144
1297
|
callbacks.delete(callbackId);
|
|
1145
1298
|
if (callbacks.size === 0) {
|
|
1299
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1146
1300
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
1147
1301
|
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
1148
1302
|
if (this.isConnected && this.ws) {
|
|
@@ -1186,12 +1340,18 @@ onError });
|
|
|
1186
1340
|
onError(error instanceof Error ? error : new Error(String(error)));
|
|
1187
1341
|
}
|
|
1188
1342
|
}
|
|
1343
|
+
} else if (!existingSubscription.subscribeInFlight) {
|
|
1344
|
+
// See listenCollection: a registration with nothing in flight is
|
|
1345
|
+
// dead, and attaching to it silently would hang this listener.
|
|
1346
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
1189
1347
|
}
|
|
1190
1348
|
|
|
1191
1349
|
// Return unsubscribe function
|
|
1192
1350
|
return () => {
|
|
1193
1351
|
callbackMap.delete(callbackId);
|
|
1194
1352
|
if (callbackMap.size === 0) {
|
|
1353
|
+
if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
1354
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
1195
1355
|
// No more callbacks, unsubscribe from backend
|
|
1196
1356
|
this.singleSubscriptions.delete(subscriptionKey);
|
|
1197
1357
|
this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
|
|
@@ -1224,15 +1384,7 @@ onError });
|
|
|
1224
1384
|
this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
|
|
1225
1385
|
|
|
1226
1386
|
// 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
|
-
});
|
|
1387
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
1236
1388
|
|
|
1237
1389
|
// Return unsubscribe function
|
|
1238
1390
|
return () => {
|
|
@@ -1254,6 +1406,201 @@ onError });
|
|
|
1254
1406
|
};
|
|
1255
1407
|
}
|
|
1256
1408
|
|
|
1409
|
+
/**
|
|
1410
|
+
* Send a `subscribe_collection` for an already-registered subscription and
|
|
1411
|
+
* arm its watchdog.
|
|
1412
|
+
*
|
|
1413
|
+
* Every path that registers a collection subscription goes through here, so
|
|
1414
|
+
* that a subscribe which never lands — a rejected send, or a server that
|
|
1415
|
+
* never answers — always ends up in `failCollectionSubscription` rather than
|
|
1416
|
+
* leaving the entry parked with `isInitialDataReceived === false` forever.
|
|
1417
|
+
*/
|
|
1418
|
+
private sendCollectionSubscribe(subscriptionKey: string): void {
|
|
1419
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1420
|
+
if (!subscription) return;
|
|
1421
|
+
|
|
1422
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1423
|
+
subscription.subscribeInFlight = true;
|
|
1424
|
+
|
|
1425
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1426
|
+
subscription.subscribeTimeout = undefined;
|
|
1427
|
+
// Only time out a frame that is actually on the wire. While offline the
|
|
1428
|
+
// message just sits in the queue, and reconnect backoff can exceed the
|
|
1429
|
+
// timeout — `armPendingSubscribeWatchdogs` picks these up on connect.
|
|
1430
|
+
if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
|
|
1431
|
+
|
|
1432
|
+
this.sendMessage({
|
|
1433
|
+
type: "subscribe_collection",
|
|
1434
|
+
payload: {
|
|
1435
|
+
...subscription.props,
|
|
1436
|
+
subscriptionId: backendSubscriptionId
|
|
1437
|
+
}
|
|
1438
|
+
}).catch(error => {
|
|
1439
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
1440
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1441
|
+
this.failCollectionSubscription(
|
|
1442
|
+
subscriptionKey,
|
|
1443
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1444
|
+
);
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
|
|
1449
|
+
private sendEntitySubscribe(subscriptionKey: string): void {
|
|
1450
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1451
|
+
if (!subscription) return;
|
|
1452
|
+
|
|
1453
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1454
|
+
subscription.subscribeInFlight = true;
|
|
1455
|
+
|
|
1456
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1457
|
+
subscription.subscribeTimeout = undefined;
|
|
1458
|
+
if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
|
|
1459
|
+
|
|
1460
|
+
this.sendMessage({
|
|
1461
|
+
type: "subscribe_one",
|
|
1462
|
+
payload: {
|
|
1463
|
+
...subscription.props,
|
|
1464
|
+
subscriptionId: backendSubscriptionId
|
|
1465
|
+
}
|
|
1466
|
+
}).catch(error => {
|
|
1467
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
1468
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1469
|
+
this.failEntitySubscription(
|
|
1470
|
+
subscriptionKey,
|
|
1471
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1472
|
+
);
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Report a subscribe failure to every listener and drop the registration.
|
|
1478
|
+
*
|
|
1479
|
+
* Dropping it is the point: the callbacks stay live (their components are
|
|
1480
|
+
* still mounted and have been told), but the next `listenCollection` for
|
|
1481
|
+
* these params finds no entry and issues a fresh subscribe instead of
|
|
1482
|
+
* silently attaching to a dead one.
|
|
1483
|
+
*/
|
|
1484
|
+
private failCollectionSubscription(subscriptionKey: string, error: Error): void {
|
|
1485
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1486
|
+
if (!subscription) return;
|
|
1487
|
+
|
|
1488
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1489
|
+
subscription.subscribeInFlight = false;
|
|
1490
|
+
|
|
1491
|
+
this.collectionSubscriptions.delete(subscriptionKey);
|
|
1492
|
+
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
1493
|
+
|
|
1494
|
+
subscription.callbacks.forEach(callback => {
|
|
1495
|
+
if (callback.onError) {
|
|
1496
|
+
try {
|
|
1497
|
+
callback.onError(error);
|
|
1498
|
+
} catch (callbackError) {
|
|
1499
|
+
console.error("Error in collection subscription error callback:", callbackError);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/** The `listenOne` counterpart of {@link failCollectionSubscription}. */
|
|
1506
|
+
private failEntitySubscription(subscriptionKey: string, error: Error): void {
|
|
1507
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1508
|
+
if (!subscription) return;
|
|
1509
|
+
|
|
1510
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1511
|
+
subscription.subscribeInFlight = false;
|
|
1512
|
+
|
|
1513
|
+
this.singleSubscriptions.delete(subscriptionKey);
|
|
1514
|
+
this.backendToEntityKey.delete(subscription.backendSubscriptionId);
|
|
1515
|
+
|
|
1516
|
+
subscription.callbacks.forEach(callback => {
|
|
1517
|
+
if (callback.onError) {
|
|
1518
|
+
try {
|
|
1519
|
+
callback.onError(error);
|
|
1520
|
+
} catch (callbackError) {
|
|
1521
|
+
console.error("Error in row subscription error callback:", callbackError);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* Stop the watchdogs without failing anything — used when the socket drops,
|
|
1529
|
+
* since the reconnect path re-subscribes everything anyway and a watchdog
|
|
1530
|
+
* firing mid-reconnect would tear down healthy subscriptions.
|
|
1531
|
+
*/
|
|
1532
|
+
private suspendSubscribeWatchdogs(): void {
|
|
1533
|
+
for (const sub of this.collectionSubscriptions.values()) {
|
|
1534
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
1535
|
+
sub.subscribeTimeout = undefined;
|
|
1536
|
+
sub.subscribeInFlight = false;
|
|
1537
|
+
}
|
|
1538
|
+
for (const sub of this.singleSubscriptions.values()) {
|
|
1539
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
1540
|
+
sub.subscribeTimeout = undefined;
|
|
1541
|
+
sub.subscribeInFlight = false;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Arm watchdogs for subscribes that were requested while offline and have
|
|
1547
|
+
* just been flushed to the socket. Their timers were deliberately not set at
|
|
1548
|
+
* request time, so without this they would have no timeout at all.
|
|
1549
|
+
*/
|
|
1550
|
+
private armPendingSubscribeWatchdogs(): void {
|
|
1551
|
+
for (const [key, sub] of this.collectionSubscriptions.entries()) {
|
|
1552
|
+
if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
|
|
1553
|
+
}
|
|
1554
|
+
for (const [key, sub] of this.singleSubscriptions.entries()) {
|
|
1555
|
+
if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
private sendCollectionSubscribeWatchdog(subscriptionKey: string): void {
|
|
1560
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1561
|
+
if (!subscription) return;
|
|
1562
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1563
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
1564
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
1565
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1566
|
+
if (!current.subscribeInFlight) return;
|
|
1567
|
+
this.failCollectionSubscription(
|
|
1568
|
+
subscriptionKey,
|
|
1569
|
+
new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" })
|
|
1570
|
+
);
|
|
1571
|
+
}, this.subscriptionTimeoutMs);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
private sendEntitySubscribeWatchdog(subscriptionKey: string): void {
|
|
1575
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1576
|
+
if (!subscription) return;
|
|
1577
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1578
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
1579
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
1580
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1581
|
+
if (!current.subscribeInFlight) return;
|
|
1582
|
+
this.failEntitySubscription(
|
|
1583
|
+
subscriptionKey,
|
|
1584
|
+
new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" })
|
|
1585
|
+
);
|
|
1586
|
+
}, this.subscriptionTimeoutMs);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Fail every subscription that never received data. Called when reconnection
|
|
1591
|
+
* is given up on, so views surface an error instead of spinning forever.
|
|
1592
|
+
*/
|
|
1593
|
+
private failAllPendingSubscriptions(error: Error): void {
|
|
1594
|
+
for (const key of [...this.collectionSubscriptions.keys()]) {
|
|
1595
|
+
const sub = this.collectionSubscriptions.get(key);
|
|
1596
|
+
if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
|
|
1597
|
+
}
|
|
1598
|
+
for (const key of [...this.singleSubscriptions.keys()]) {
|
|
1599
|
+
const sub = this.singleSubscriptions.get(key);
|
|
1600
|
+
if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1257
1604
|
/**
|
|
1258
1605
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
1259
1606
|
* The server wipes subscription state when a client disconnects, so
|
|
@@ -1273,15 +1620,7 @@ onError });
|
|
|
1273
1620
|
this.backendToCollectionKey.delete(oldBackendId);
|
|
1274
1621
|
this.backendToCollectionKey.set(newBackendId, key);
|
|
1275
1622
|
|
|
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
|
-
});
|
|
1623
|
+
this.sendCollectionSubscribe(key);
|
|
1285
1624
|
}
|
|
1286
1625
|
|
|
1287
1626
|
// Re-subscribe row subscriptions
|
|
@@ -1293,15 +1632,7 @@ onError });
|
|
|
1293
1632
|
this.backendToEntityKey.delete(oldBackendId);
|
|
1294
1633
|
this.backendToEntityKey.set(newBackendId, key);
|
|
1295
1634
|
|
|
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
|
-
});
|
|
1635
|
+
this.sendEntitySubscribe(key);
|
|
1305
1636
|
}
|
|
1306
1637
|
}
|
|
1307
1638
|
|