@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/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
- return { errorMessage,
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-postgresql` driver
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
- this.sendMessage({
403
- type: messageType,
404
- payload: {
405
- ...subscription.props,
406
- subscriptionId: newBackendId
407
- }
408
- }).catch(error => {
409
- console.error(`[WS] Failed to re-subscribe ${idPrefix} after auth refresh:`, subscriptionKey, error);
410
- subscription.callbacks.forEach(callback => {
411
- if (callback.onError) callback.onError(error);
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
- const { errorMessage, errorCode } = extractMessageError(message);
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
- subscription.callbacks.forEach(callback => {
423
- if (callback.onError) callback.onError(err);
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 patchEntityId = (message as unknown as { id: string }).id;
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(e => String(e.id) !== String(patchEntityId));
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
- const idx = collectionSub.latestData.findIndex(e => String(e.id) === String(patchRow.id));
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(cached: Record<string, unknown>[] | undefined, incoming: Record<string, unknown>[]): Record<string, unknown>[] {
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 ID for O(1) access
1029
- const cachedById = new Map<string | number, Record<string, unknown>>();
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
- cachedById.set(row.id as string | number, row);
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 cachedRow = cachedById.get(incomingRow.id as string | number);
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 ${incomingRow.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
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
- // No more callbacks, unsubscribe from backend
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
- this.sendMessage({
1130
- type: "subscribe_collection",
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.sendMessage({
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.sendMessage({
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.sendMessage({
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 {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};