@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/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 {
@@ -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
- 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
- });
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
- 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
- });
482
+ this.failEntitySubscription(subscriptionKey, error);
420
483
  }
421
484
  }).catch(err => {
422
- subscription.callbacks.forEach(callback => {
423
- if (callback.onError) callback.onError(err);
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 patchEntityId = (message as unknown as { id: string }).id;
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(e => String(e.id) !== String(patchEntityId));
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
- const idx = collectionSub.latestData.findIndex(e => String(e.id) === String(patchRow.id));
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
- private sendMessage(message: Record<string, unknown>): Promise<unknown> {
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(cached: Record<string, unknown>[] | undefined, incoming: Record<string, unknown>[]): Record<string, unknown>[] {
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 ID for O(1) access
1029
- const cachedById = new Map<string | number, Record<string, unknown>>();
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
- cachedById.set(row.id as string | number, row);
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 cachedRow = cachedById.get(incomingRow.id as string | number);
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 ${incomingRow.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
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
- // No more callbacks, unsubscribe from backend
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
- this.sendMessage({
1130
- type: "subscribe_collection",
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.sendMessage({
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.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
- });
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.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
- });
1635
+ this.sendEntitySubscribe(key);
1305
1636
  }
1306
1637
  }
1307
1638