@rebasepro/client 0.9.0 → 0.9.1-canary.0fce67c
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 +13 -0
- package/dist/index.es.js +280 -72
- package/dist/index.es.js.map +1 -1
- package/dist/transport.d.ts +34 -0
- package/dist/websocket.d.ts +57 -1
- 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 +33 -2
- 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 +359 -71
- 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 {
|
|
@@ -89,6 +97,25 @@ export class RebaseWebSocketClient {
|
|
|
89
97
|
latestData?: Record<string, unknown>[]; // Cache the latest flat rows
|
|
90
98
|
lastUpdated?: number; // Timestamp for cache invalidation
|
|
91
99
|
isInitialDataReceived?: boolean; // Track if we got initial data
|
|
100
|
+
/**
|
|
101
|
+
* A `subscribe_collection` frame is on the wire and its initial payload
|
|
102
|
+
* has not arrived yet. Without this, a subscription whose subscribe
|
|
103
|
+
* failed is indistinguishable from one still loading, and every later
|
|
104
|
+
* listener attaches to it and waits forever.
|
|
105
|
+
*/
|
|
106
|
+
subscribeInFlight?: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Watchdog for the above. `subscribe_collection` expects no response
|
|
109
|
+
* envelope, so it is not covered by `pendingRequests`' timeout — a lost
|
|
110
|
+
* initial payload would otherwise hang the subscription indefinitely.
|
|
111
|
+
*/
|
|
112
|
+
subscribeTimeout?: ReturnType<typeof setTimeout>;
|
|
113
|
+
/**
|
|
114
|
+
* The key columns of this collection, as told by the server on a patch.
|
|
115
|
+
* Rows are columns only, and the SDK holds no collection config, so
|
|
116
|
+
* without this there is nothing to derive an address from.
|
|
117
|
+
*/
|
|
118
|
+
pks?: PrimaryKeyInfo[];
|
|
92
119
|
}>();
|
|
93
120
|
|
|
94
121
|
private singleSubscriptions = new Map<string, {
|
|
@@ -101,6 +128,9 @@ export class RebaseWebSocketClient {
|
|
|
101
128
|
latestData?: Record<string, unknown> | null; // Cache the latest flat row
|
|
102
129
|
lastUpdated?: number; // Timestamp for cache invalidation
|
|
103
130
|
isInitialDataReceived?: boolean; // Track if we got initial data
|
|
131
|
+
/** See the collection subscription counterparts. */
|
|
132
|
+
subscribeInFlight?: boolean;
|
|
133
|
+
subscribeTimeout?: ReturnType<typeof setTimeout>;
|
|
104
134
|
}>();
|
|
105
135
|
|
|
106
136
|
// Maps to quickly find subscription by backend subscription ID
|
|
@@ -118,6 +148,7 @@ export class RebaseWebSocketClient {
|
|
|
118
148
|
private isConnected = false;
|
|
119
149
|
private messageQueue: Record<string, unknown>[] = [];
|
|
120
150
|
private requestTimeoutMs = 30000;
|
|
151
|
+
private subscriptionTimeoutMs = 30000;
|
|
121
152
|
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
122
153
|
|
|
123
154
|
private isAuthenticated = false;
|
|
@@ -263,6 +294,10 @@ export class RebaseWebSocketClient {
|
|
|
263
294
|
if (wasReconnect) {
|
|
264
295
|
this.resubscribeAll();
|
|
265
296
|
}
|
|
297
|
+
|
|
298
|
+
// Subscribes requested while offline have just gone out; they
|
|
299
|
+
// could not be watchdogged at request time.
|
|
300
|
+
this.armPendingSubscribeWatchdogs();
|
|
266
301
|
};
|
|
267
302
|
|
|
268
303
|
this.ws!.onmessage = (event) => {
|
|
@@ -279,6 +314,9 @@ export class RebaseWebSocketClient {
|
|
|
279
314
|
this.isConnected = false;
|
|
280
315
|
this.isAuthenticated = false;
|
|
281
316
|
this.authPromise = null;
|
|
317
|
+
// The reconnect path re-subscribes everything; a watchdog firing
|
|
318
|
+
// in the meantime would tear down healthy subscriptions.
|
|
319
|
+
this.suspendSubscribeWatchdogs();
|
|
282
320
|
this.emit("disconnect");
|
|
283
321
|
|
|
284
322
|
// Re-queue pending requests so the UI doesn't hang indefinitely or crash
|
|
@@ -319,6 +357,11 @@ export class RebaseWebSocketClient {
|
|
|
319
357
|
private attemptReconnect() {
|
|
320
358
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
321
359
|
console.error("Max reconnection attempts reached");
|
|
360
|
+
// Nothing will re-subscribe now, so stop every subscription that
|
|
361
|
+
// never loaded from spinning forever.
|
|
362
|
+
this.failAllPendingSubscriptions(
|
|
363
|
+
new RebaseApiError("Connection lost", { code: "CONNECTION_LOST" })
|
|
364
|
+
);
|
|
322
365
|
return;
|
|
323
366
|
}
|
|
324
367
|
|
|
@@ -399,29 +442,32 @@ export class RebaseWebSocketClient {
|
|
|
399
442
|
backendKeyMap.delete(oldBackendId);
|
|
400
443
|
backendKeyMap.set(newBackendId, subscriptionKey);
|
|
401
444
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
445
|
+
// Route through the helpers so the retry is watchdogged too.
|
|
446
|
+
if (messageType === "subscribe_collection") {
|
|
447
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
448
|
+
} else {
|
|
449
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
450
|
+
}
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// The refresh did not produce usable credentials. Report the original
|
|
455
|
+
// error and drop the registration, so a later mount can try again
|
|
456
|
+
// rather than attaching to a subscription that will never load.
|
|
457
|
+
const { errorMessage, errorCode } = extractMessageError(message);
|
|
458
|
+
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
459
|
+
if (messageType === "subscribe_collection") {
|
|
460
|
+
this.failCollectionSubscription(subscriptionKey, error);
|
|
414
461
|
} else {
|
|
415
|
-
|
|
416
|
-
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
417
|
-
subscription.callbacks.forEach(callback => {
|
|
418
|
-
if (callback.onError) callback.onError(error);
|
|
419
|
-
});
|
|
462
|
+
this.failEntitySubscription(subscriptionKey, error);
|
|
420
463
|
}
|
|
421
464
|
}).catch(err => {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
465
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
466
|
+
if (messageType === "subscribe_collection") {
|
|
467
|
+
this.failCollectionSubscription(subscriptionKey, error);
|
|
468
|
+
} else {
|
|
469
|
+
this.failEntitySubscription(subscriptionKey, error);
|
|
470
|
+
}
|
|
425
471
|
});
|
|
426
472
|
}
|
|
427
473
|
|
|
@@ -469,16 +515,27 @@ export class RebaseWebSocketClient {
|
|
|
469
515
|
const wireEntities = (message.rows || []) as unknown as Record<string, unknown>[];
|
|
470
516
|
const incomingRows = wireEntities;
|
|
471
517
|
|
|
518
|
+
// The keys arrive with the rows, so they are known before the
|
|
519
|
+
// first merge — a CDC-driven change never sends a patch, and
|
|
520
|
+
// learning them from patches alone would leave every
|
|
521
|
+
// externally-written collection unable to match a thing.
|
|
522
|
+
const updatePks = (message as unknown as { pks?: PrimaryKeyInfo[] }).pks;
|
|
523
|
+
if (updatePks) collectionSub.pks = updatePks;
|
|
524
|
+
|
|
472
525
|
// Structural merge: preserve cached row references for rows
|
|
473
526
|
// whose values haven't changed. This prevents downstream React components
|
|
474
527
|
// from re-rendering (VirtualTableCell uses deepEqual on rowData —
|
|
475
528
|
// same reference = instant true, avoiding expensive deep comparison).
|
|
476
|
-
const rows = this.mergeRows(collectionSub.latestData, incomingRows);
|
|
529
|
+
const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
|
|
477
530
|
|
|
478
531
|
// Cache the latest data with optimizations
|
|
479
532
|
collectionSub.latestData = rows;
|
|
480
533
|
collectionSub.lastUpdated = Date.now();
|
|
481
534
|
collectionSub.isInitialDataReceived = true;
|
|
535
|
+
// The subscribe landed — stand the watchdog down.
|
|
536
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
537
|
+
collectionSub.subscribeTimeout = undefined;
|
|
538
|
+
collectionSub.subscribeInFlight = false;
|
|
482
539
|
|
|
483
540
|
// Notify all callbacks for this subscription
|
|
484
541
|
collectionSub.callbacks.forEach(callback => {
|
|
@@ -504,16 +561,28 @@ export class RebaseWebSocketClient {
|
|
|
504
561
|
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
505
562
|
if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
|
|
506
563
|
const patchWireEntity = message.row ?? null;
|
|
507
|
-
const
|
|
564
|
+
const patchMessage = message as unknown as { id: string; pks?: PrimaryKeyInfo[] };
|
|
565
|
+
const patchEntityId = patchMessage.id;
|
|
566
|
+
// The server knows the key columns; remember them, because the
|
|
567
|
+
// refetch reconciliation needs them too and carries no id.
|
|
568
|
+
if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
|
|
508
569
|
const patchRow = patchWireEntity ? (patchWireEntity as unknown as Record<string, unknown>) : null;
|
|
509
570
|
let updated: Record<string, unknown>[];
|
|
510
571
|
|
|
511
572
|
if (patchRow === null) {
|
|
512
573
|
// Row was deleted — remove it from the cached list
|
|
513
|
-
updated = collectionSub.latestData.filter(
|
|
574
|
+
updated = collectionSub.latestData.filter(
|
|
575
|
+
e => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId)
|
|
576
|
+
);
|
|
514
577
|
} else {
|
|
515
|
-
// Row was created or updated — merge into the cached list
|
|
516
|
-
|
|
578
|
+
// Row was created or updated — merge into the cached list.
|
|
579
|
+
// Matched against the patch's own address rather than
|
|
580
|
+
// anything read off the row: `patchRow.id` is undefined
|
|
581
|
+
// for a table not keyed on `id`, so every update looked
|
|
582
|
+
// like a new row and was prepended as a duplicate.
|
|
583
|
+
const idx = collectionSub.latestData.findIndex(
|
|
584
|
+
e => this.rowAddress(e, collectionSub.pks) === String(patchEntityId)
|
|
585
|
+
);
|
|
517
586
|
if (idx >= 0) {
|
|
518
587
|
// Update in place (preserve array position)
|
|
519
588
|
updated = [...collectionSub.latestData];
|
|
@@ -555,6 +624,9 @@ export class RebaseWebSocketClient {
|
|
|
555
624
|
entitySub.latestData = row;
|
|
556
625
|
entitySub.lastUpdated = Date.now();
|
|
557
626
|
entitySub.isInitialDataReceived = true;
|
|
627
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
628
|
+
entitySub.subscribeTimeout = undefined;
|
|
629
|
+
entitySub.subscribeInFlight = false;
|
|
558
630
|
|
|
559
631
|
// Notify all callbacks for this subscription
|
|
560
632
|
entitySub.callbacks.forEach(callback => {
|
|
@@ -590,6 +662,14 @@ export class RebaseWebSocketClient {
|
|
|
590
662
|
return;
|
|
591
663
|
}
|
|
592
664
|
|
|
665
|
+
// The server answered, so nothing is in flight any more. Leave
|
|
666
|
+
// the registration in place (its listeners are still mounted
|
|
667
|
+
// and have been told), but marked idle so the next listener
|
|
668
|
+
// re-subscribes instead of attaching to a dead entry.
|
|
669
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
670
|
+
collectionSub.subscribeTimeout = undefined;
|
|
671
|
+
collectionSub.subscribeInFlight = false;
|
|
672
|
+
|
|
593
673
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
594
674
|
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
595
675
|
collectionSub.callbacks.forEach(callback => {
|
|
@@ -617,6 +697,10 @@ export class RebaseWebSocketClient {
|
|
|
617
697
|
return;
|
|
618
698
|
}
|
|
619
699
|
|
|
700
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
701
|
+
entitySub.subscribeTimeout = undefined;
|
|
702
|
+
entitySub.subscribeInFlight = false;
|
|
703
|
+
|
|
620
704
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
621
705
|
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
622
706
|
entitySub.callbacks.forEach(callback => {
|
|
@@ -1016,23 +1100,48 @@ options }
|
|
|
1016
1100
|
return val;
|
|
1017
1101
|
}
|
|
1018
1102
|
|
|
1103
|
+
/**
|
|
1104
|
+
* The address of a row, for matching it against another copy of itself.
|
|
1105
|
+
*
|
|
1106
|
+
* A row is exactly its columns and carries no address, so it is derived
|
|
1107
|
+
* from the key columns the server named — including the ordinary case where
|
|
1108
|
+
* that key is `id`, which the server reports like any other.
|
|
1109
|
+
*
|
|
1110
|
+
* Undefined when there are no keys, which means the server could not
|
|
1111
|
+
* resolve any: such rows genuinely cannot be recognised, and guessing at a
|
|
1112
|
+
* column called `id` would be inventing an identity for a table that has
|
|
1113
|
+
* none.
|
|
1114
|
+
*/
|
|
1115
|
+
private rowAddress(row: Record<string, unknown>, pks: PrimaryKeyInfo[] | undefined): string | undefined {
|
|
1116
|
+
if (!pks || pks.length === 0) return undefined;
|
|
1117
|
+
const address = buildCompositeId(row, pks);
|
|
1118
|
+
if (!address || address.split(COMPOSITE_ID_SEPARATOR).every(part => part === "")) return undefined;
|
|
1119
|
+
return address;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1019
1122
|
/**
|
|
1020
1123
|
* Merge incoming rows with cached data, preserving cached references
|
|
1021
1124
|
* for rows whose values haven't changed. This avoids unnecessary
|
|
1022
1125
|
* React re-renders when the server refetches all rows but most
|
|
1023
1126
|
* haven't actually changed.
|
|
1024
1127
|
*/
|
|
1025
|
-
private mergeRows(
|
|
1128
|
+
private mergeRows(
|
|
1129
|
+
cached: Record<string, unknown>[] | undefined,
|
|
1130
|
+
incoming: Record<string, unknown>[],
|
|
1131
|
+
pks?: PrimaryKeyInfo[]
|
|
1132
|
+
): Record<string, unknown>[] {
|
|
1026
1133
|
if (!cached || cached.length === 0) return incoming;
|
|
1027
1134
|
|
|
1028
|
-
// Build a lookup from cached rows by
|
|
1029
|
-
const cachedById = new Map<string
|
|
1135
|
+
// Build a lookup from cached rows by address for O(1) access
|
|
1136
|
+
const cachedById = new Map<string, Record<string, unknown>>();
|
|
1030
1137
|
for (const row of cached) {
|
|
1031
|
-
|
|
1138
|
+
const address = this.rowAddress(row, pks);
|
|
1139
|
+
if (address !== undefined) cachedById.set(address, row);
|
|
1032
1140
|
}
|
|
1033
1141
|
|
|
1034
1142
|
return incoming.map(incomingRow => {
|
|
1035
|
-
const
|
|
1143
|
+
const address = this.rowAddress(incomingRow, pks);
|
|
1144
|
+
const cachedRow = address === undefined ? undefined : cachedById.get(address);
|
|
1036
1145
|
if (!cachedRow) return incomingRow;
|
|
1037
1146
|
|
|
1038
1147
|
// Compare flat rows directly (no more path/values nesting)
|
|
@@ -1051,7 +1160,7 @@ options }
|
|
|
1051
1160
|
incoming: normIncoming[key] };
|
|
1052
1161
|
}
|
|
1053
1162
|
}
|
|
1054
|
-
console.debug(`[RebaseWS] Row ${
|
|
1163
|
+
console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
|
|
1055
1164
|
}
|
|
1056
1165
|
return incomingRow;
|
|
1057
1166
|
});
|
|
@@ -1088,13 +1197,21 @@ onError });
|
|
|
1088
1197
|
onError(error instanceof Error ? error : new Error(String(error)));
|
|
1089
1198
|
}
|
|
1090
1199
|
}
|
|
1200
|
+
} else if (!existingSubscription.subscribeInFlight) {
|
|
1201
|
+
// Registered but idle: its subscribe never landed (the send failed,
|
|
1202
|
+
// or the server answered with an error). Nothing is coming, so
|
|
1203
|
+
// re-issue it — otherwise this listener waits forever.
|
|
1204
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
1091
1205
|
}
|
|
1092
1206
|
|
|
1093
1207
|
// Return unsubscribe function
|
|
1094
1208
|
return () => {
|
|
1095
1209
|
callbackMap.delete(callbackId);
|
|
1096
1210
|
if (callbackMap.size === 0) {
|
|
1097
|
-
//
|
|
1211
|
+
// Only tear down if this is still the same registration — a
|
|
1212
|
+
// failed subscribe may have replaced it in the meantime.
|
|
1213
|
+
if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
1214
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
1098
1215
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
1099
1216
|
this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
|
|
1100
1217
|
if (this.isConnected && this.ws) {
|
|
@@ -1125,16 +1242,9 @@ onError });
|
|
|
1125
1242
|
// Add reverse lookup
|
|
1126
1243
|
this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
|
|
1127
1244
|
|
|
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
|
-
});
|
|
1245
|
+
// Send subscription request to backend. A failure here drops the
|
|
1246
|
+
// registration and notifies every listener, so the next mount retries.
|
|
1247
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
1138
1248
|
|
|
1139
1249
|
// Return unsubscribe function
|
|
1140
1250
|
return () => {
|
|
@@ -1143,6 +1253,7 @@ onError });
|
|
|
1143
1253
|
const callbacks = subscription.callbacks;
|
|
1144
1254
|
callbacks.delete(callbackId);
|
|
1145
1255
|
if (callbacks.size === 0) {
|
|
1256
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1146
1257
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
1147
1258
|
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
1148
1259
|
if (this.isConnected && this.ws) {
|
|
@@ -1186,12 +1297,18 @@ onError });
|
|
|
1186
1297
|
onError(error instanceof Error ? error : new Error(String(error)));
|
|
1187
1298
|
}
|
|
1188
1299
|
}
|
|
1300
|
+
} else if (!existingSubscription.subscribeInFlight) {
|
|
1301
|
+
// See listenCollection: a registration with nothing in flight is
|
|
1302
|
+
// dead, and attaching to it silently would hang this listener.
|
|
1303
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
1189
1304
|
}
|
|
1190
1305
|
|
|
1191
1306
|
// Return unsubscribe function
|
|
1192
1307
|
return () => {
|
|
1193
1308
|
callbackMap.delete(callbackId);
|
|
1194
1309
|
if (callbackMap.size === 0) {
|
|
1310
|
+
if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
1311
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
1195
1312
|
// No more callbacks, unsubscribe from backend
|
|
1196
1313
|
this.singleSubscriptions.delete(subscriptionKey);
|
|
1197
1314
|
this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
|
|
@@ -1224,15 +1341,7 @@ onError });
|
|
|
1224
1341
|
this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
|
|
1225
1342
|
|
|
1226
1343
|
// 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
|
-
});
|
|
1344
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
1236
1345
|
|
|
1237
1346
|
// Return unsubscribe function
|
|
1238
1347
|
return () => {
|
|
@@ -1254,6 +1363,201 @@ onError });
|
|
|
1254
1363
|
};
|
|
1255
1364
|
}
|
|
1256
1365
|
|
|
1366
|
+
/**
|
|
1367
|
+
* Send a `subscribe_collection` for an already-registered subscription and
|
|
1368
|
+
* arm its watchdog.
|
|
1369
|
+
*
|
|
1370
|
+
* Every path that registers a collection subscription goes through here, so
|
|
1371
|
+
* that a subscribe which never lands — a rejected send, or a server that
|
|
1372
|
+
* never answers — always ends up in `failCollectionSubscription` rather than
|
|
1373
|
+
* leaving the entry parked with `isInitialDataReceived === false` forever.
|
|
1374
|
+
*/
|
|
1375
|
+
private sendCollectionSubscribe(subscriptionKey: string): void {
|
|
1376
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1377
|
+
if (!subscription) return;
|
|
1378
|
+
|
|
1379
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1380
|
+
subscription.subscribeInFlight = true;
|
|
1381
|
+
|
|
1382
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1383
|
+
subscription.subscribeTimeout = undefined;
|
|
1384
|
+
// Only time out a frame that is actually on the wire. While offline the
|
|
1385
|
+
// message just sits in the queue, and reconnect backoff can exceed the
|
|
1386
|
+
// timeout — `armPendingSubscribeWatchdogs` picks these up on connect.
|
|
1387
|
+
if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
|
|
1388
|
+
|
|
1389
|
+
this.sendMessage({
|
|
1390
|
+
type: "subscribe_collection",
|
|
1391
|
+
payload: {
|
|
1392
|
+
...subscription.props,
|
|
1393
|
+
subscriptionId: backendSubscriptionId
|
|
1394
|
+
}
|
|
1395
|
+
}).catch(error => {
|
|
1396
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
1397
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1398
|
+
this.failCollectionSubscription(
|
|
1399
|
+
subscriptionKey,
|
|
1400
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1401
|
+
);
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
|
|
1406
|
+
private sendEntitySubscribe(subscriptionKey: string): void {
|
|
1407
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1408
|
+
if (!subscription) return;
|
|
1409
|
+
|
|
1410
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1411
|
+
subscription.subscribeInFlight = true;
|
|
1412
|
+
|
|
1413
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1414
|
+
subscription.subscribeTimeout = undefined;
|
|
1415
|
+
if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
|
|
1416
|
+
|
|
1417
|
+
this.sendMessage({
|
|
1418
|
+
type: "subscribe_one",
|
|
1419
|
+
payload: {
|
|
1420
|
+
...subscription.props,
|
|
1421
|
+
subscriptionId: backendSubscriptionId
|
|
1422
|
+
}
|
|
1423
|
+
}).catch(error => {
|
|
1424
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
1425
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1426
|
+
this.failEntitySubscription(
|
|
1427
|
+
subscriptionKey,
|
|
1428
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1429
|
+
);
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* Report a subscribe failure to every listener and drop the registration.
|
|
1435
|
+
*
|
|
1436
|
+
* Dropping it is the point: the callbacks stay live (their components are
|
|
1437
|
+
* still mounted and have been told), but the next `listenCollection` for
|
|
1438
|
+
* these params finds no entry and issues a fresh subscribe instead of
|
|
1439
|
+
* silently attaching to a dead one.
|
|
1440
|
+
*/
|
|
1441
|
+
private failCollectionSubscription(subscriptionKey: string, error: Error): void {
|
|
1442
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1443
|
+
if (!subscription) return;
|
|
1444
|
+
|
|
1445
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1446
|
+
subscription.subscribeInFlight = false;
|
|
1447
|
+
|
|
1448
|
+
this.collectionSubscriptions.delete(subscriptionKey);
|
|
1449
|
+
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
1450
|
+
|
|
1451
|
+
subscription.callbacks.forEach(callback => {
|
|
1452
|
+
if (callback.onError) {
|
|
1453
|
+
try {
|
|
1454
|
+
callback.onError(error);
|
|
1455
|
+
} catch (callbackError) {
|
|
1456
|
+
console.error("Error in collection subscription error callback:", callbackError);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** The `listenOne` counterpart of {@link failCollectionSubscription}. */
|
|
1463
|
+
private failEntitySubscription(subscriptionKey: string, error: Error): void {
|
|
1464
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1465
|
+
if (!subscription) return;
|
|
1466
|
+
|
|
1467
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1468
|
+
subscription.subscribeInFlight = false;
|
|
1469
|
+
|
|
1470
|
+
this.singleSubscriptions.delete(subscriptionKey);
|
|
1471
|
+
this.backendToEntityKey.delete(subscription.backendSubscriptionId);
|
|
1472
|
+
|
|
1473
|
+
subscription.callbacks.forEach(callback => {
|
|
1474
|
+
if (callback.onError) {
|
|
1475
|
+
try {
|
|
1476
|
+
callback.onError(error);
|
|
1477
|
+
} catch (callbackError) {
|
|
1478
|
+
console.error("Error in row subscription error callback:", callbackError);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Stop the watchdogs without failing anything — used when the socket drops,
|
|
1486
|
+
* since the reconnect path re-subscribes everything anyway and a watchdog
|
|
1487
|
+
* firing mid-reconnect would tear down healthy subscriptions.
|
|
1488
|
+
*/
|
|
1489
|
+
private suspendSubscribeWatchdogs(): void {
|
|
1490
|
+
for (const sub of this.collectionSubscriptions.values()) {
|
|
1491
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
1492
|
+
sub.subscribeTimeout = undefined;
|
|
1493
|
+
sub.subscribeInFlight = false;
|
|
1494
|
+
}
|
|
1495
|
+
for (const sub of this.singleSubscriptions.values()) {
|
|
1496
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
1497
|
+
sub.subscribeTimeout = undefined;
|
|
1498
|
+
sub.subscribeInFlight = false;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* Arm watchdogs for subscribes that were requested while offline and have
|
|
1504
|
+
* just been flushed to the socket. Their timers were deliberately not set at
|
|
1505
|
+
* request time, so without this they would have no timeout at all.
|
|
1506
|
+
*/
|
|
1507
|
+
private armPendingSubscribeWatchdogs(): void {
|
|
1508
|
+
for (const [key, sub] of this.collectionSubscriptions.entries()) {
|
|
1509
|
+
if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
|
|
1510
|
+
}
|
|
1511
|
+
for (const [key, sub] of this.singleSubscriptions.entries()) {
|
|
1512
|
+
if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
private sendCollectionSubscribeWatchdog(subscriptionKey: string): void {
|
|
1517
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1518
|
+
if (!subscription) return;
|
|
1519
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1520
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
1521
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
1522
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1523
|
+
if (!current.subscribeInFlight) return;
|
|
1524
|
+
this.failCollectionSubscription(
|
|
1525
|
+
subscriptionKey,
|
|
1526
|
+
new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" })
|
|
1527
|
+
);
|
|
1528
|
+
}, this.subscriptionTimeoutMs);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
private sendEntitySubscribeWatchdog(subscriptionKey: string): void {
|
|
1532
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1533
|
+
if (!subscription) return;
|
|
1534
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1535
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
1536
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
1537
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1538
|
+
if (!current.subscribeInFlight) return;
|
|
1539
|
+
this.failEntitySubscription(
|
|
1540
|
+
subscriptionKey,
|
|
1541
|
+
new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" })
|
|
1542
|
+
);
|
|
1543
|
+
}, this.subscriptionTimeoutMs);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* Fail every subscription that never received data. Called when reconnection
|
|
1548
|
+
* is given up on, so views surface an error instead of spinning forever.
|
|
1549
|
+
*/
|
|
1550
|
+
private failAllPendingSubscriptions(error: Error): void {
|
|
1551
|
+
for (const key of [...this.collectionSubscriptions.keys()]) {
|
|
1552
|
+
const sub = this.collectionSubscriptions.get(key);
|
|
1553
|
+
if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
|
|
1554
|
+
}
|
|
1555
|
+
for (const key of [...this.singleSubscriptions.keys()]) {
|
|
1556
|
+
const sub = this.singleSubscriptions.get(key);
|
|
1557
|
+
if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1257
1561
|
/**
|
|
1258
1562
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
1259
1563
|
* The server wipes subscription state when a client disconnects, so
|
|
@@ -1273,15 +1577,7 @@ onError });
|
|
|
1273
1577
|
this.backendToCollectionKey.delete(oldBackendId);
|
|
1274
1578
|
this.backendToCollectionKey.set(newBackendId, key);
|
|
1275
1579
|
|
|
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
|
-
});
|
|
1580
|
+
this.sendCollectionSubscribe(key);
|
|
1285
1581
|
}
|
|
1286
1582
|
|
|
1287
1583
|
// Re-subscribe row subscriptions
|
|
@@ -1293,15 +1589,7 @@ onError });
|
|
|
1293
1589
|
this.backendToEntityKey.delete(oldBackendId);
|
|
1294
1590
|
this.backendToEntityKey.set(newBackendId, key);
|
|
1295
1591
|
|
|
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
|
-
});
|
|
1592
|
+
this.sendEntitySubscribe(key);
|
|
1305
1593
|
}
|
|
1306
1594
|
}
|
|
1307
1595
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/cron.test.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|