@rebasepro/client 0.9.1-canary.fd3754b → 0.10.0

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