@rebasepro/client 0.8.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
@@ -1,17 +1,18 @@
1
1
  import {
2
- DeleteEntityProps,
3
- Entity,
4
- EntityCollection,
2
+ DeleteProps,
3
+ CollectionConfig,
5
4
  FetchCollectionProps,
6
- FetchEntityProps,
7
- SaveEntityProps,
5
+ FetchOneProps,
6
+ SaveProps,
8
7
  WebSocketMessage,
9
8
  WebSocketErrorPayload,
10
9
  CollectionUpdateMessage,
11
- EntityUpdateMessage,
10
+ SingleUpdateMessage,
12
11
  TableMetadata,
13
- BranchInfo
12
+ BranchInfo,
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,19 +52,15 @@ export interface RebaseWebSocketConfig {
44
52
  }
45
53
 
46
54
 
47
- export class ApiError extends Error {
48
- public code?: string;
49
- public error?: string;
50
-
51
- constructor(message: string, error?: string, code?: string) {
52
- super(message);
53
- this.name = "ApiError";
54
- this.code = code;
55
- this.error = error;
56
- }
57
- }
58
-
59
-
55
+ /**
56
+ * Low-level realtime WebSocket client.
57
+ *
58
+ * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
59
+ * manages this internally (exposed as `client.ws`, typed by the minimal
60
+ * `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
61
+ * package root only because the `@rebasepro/client-postgres` driver
62
+ * instantiates it directly; its surface may change without a major bump.
63
+ */
60
64
  export class RebaseWebSocketClient {
61
65
  private websocketUrl: string;
62
66
  private ws: WebSocket | null = null;
@@ -68,6 +72,26 @@ export class RebaseWebSocketClient {
68
72
 
69
73
  private listeners = new Map<string, Set<(...args: unknown[]) => void>>();
70
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
+
71
95
  public on(event: "connect" | "disconnect" | "reconnect" | "error", cb: (...args: unknown[]) => void) {
72
96
  if (!this.listeners.has(event)) {
73
97
  this.listeners.set(event, new Set());
@@ -86,25 +110,47 @@ export class RebaseWebSocketClient {
86
110
  private collectionSubscriptions = new Map<string, {
87
111
  backendSubscriptionId: string;
88
112
  callbacks: Map<string, {
89
- onUpdate: (entities: Entity[]) => void;
113
+ onUpdate: (rows: Record<string, unknown>[]) => void;
90
114
  onError?: (error: Error) => void;
91
115
  }>;
92
116
  props: FetchCollectionProps;
93
- latestData?: Entity[]; // Cache the latest data
117
+ latestData?: Record<string, unknown>[]; // Cache the latest flat rows
94
118
  lastUpdated?: number; // Timestamp for cache invalidation
95
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[];
96
139
  }>();
97
140
 
98
- private entitySubscriptions = new Map<string, {
141
+ private singleSubscriptions = new Map<string, {
99
142
  backendSubscriptionId: string;
100
143
  callbacks: Map<string, {
101
- onUpdate: (entity: Entity | null) => void;
144
+ onUpdate: (row: Record<string, unknown> | null) => void;
102
145
  onError?: (error: Error) => void;
103
146
  }>;
104
- props: FetchEntityProps;
105
- latestData?: Entity | null; // Cache the latest data
147
+ props: FetchOneProps;
148
+ latestData?: Record<string, unknown> | null; // Cache the latest flat row
106
149
  lastUpdated?: number; // Timestamp for cache invalidation
107
150
  isInitialDataReceived?: boolean; // Track if we got initial data
151
+ /** See the collection subscription counterparts. */
152
+ subscribeInFlight?: boolean;
153
+ subscribeTimeout?: ReturnType<typeof setTimeout>;
108
154
  }>();
109
155
 
110
156
  // Maps to quickly find subscription by backend subscription ID
@@ -122,6 +168,7 @@ export class RebaseWebSocketClient {
122
168
  private isConnected = false;
123
169
  private messageQueue: Record<string, unknown>[] = [];
124
170
  private requestTimeoutMs = 30000;
171
+ private subscriptionTimeoutMs = 30000;
125
172
  private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
126
173
 
127
174
  private isAuthenticated = false;
@@ -267,6 +314,10 @@ export class RebaseWebSocketClient {
267
314
  if (wasReconnect) {
268
315
  this.resubscribeAll();
269
316
  }
317
+
318
+ // Subscribes requested while offline have just gone out; they
319
+ // could not be watchdogged at request time.
320
+ this.armPendingSubscribeWatchdogs();
270
321
  };
271
322
 
272
323
  this.ws!.onmessage = (event) => {
@@ -283,6 +334,9 @@ export class RebaseWebSocketClient {
283
334
  this.isConnected = false;
284
335
  this.isAuthenticated = false;
285
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();
286
340
  this.emit("disconnect");
287
341
 
288
342
  // Re-queue pending requests so the UI doesn't hang indefinitely or crash
@@ -294,7 +348,7 @@ export class RebaseWebSocketClient {
294
348
  request.message._queuedReject = request.reject;
295
349
  this.messageQueue.push(request.message);
296
350
  } else {
297
- request.reject(new ApiError("Connection closed", "Connection closed"));
351
+ request.reject(new RebaseApiError("Connection closed"));
298
352
  }
299
353
  this.pendingRequests.delete(reqId);
300
354
  }
@@ -323,6 +377,11 @@ export class RebaseWebSocketClient {
323
377
  private attemptReconnect() {
324
378
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
325
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
+ );
326
385
  return;
327
386
  }
328
387
 
@@ -380,7 +439,7 @@ export class RebaseWebSocketClient {
380
439
  }
381
440
 
382
441
  /**
383
- * Shared logic for re-subscribing a collection or entity subscription
442
+ * Shared logic for re-subscribing a collection or row subscription
384
443
  * after an auth error is resolved by refreshing credentials.
385
444
  */
386
445
  private resubscribeAfterAuthRefresh(
@@ -388,12 +447,12 @@ export class RebaseWebSocketClient {
388
447
  subscription: {
389
448
  backendSubscriptionId: string;
390
449
  callbacks: Map<string, { onUpdate: (...args: never[]) => void; onError?: (error: Error) => void }>;
391
- props: FetchCollectionProps | FetchEntityProps;
450
+ props: FetchCollectionProps | FetchOneProps;
392
451
  },
393
452
  subscriptionKey: string,
394
- idPrefix: "collection" | "entity",
453
+ idPrefix: "collection" | "row",
395
454
  backendKeyMap: Map<string, string>,
396
- messageType: "subscribe_collection" | "subscribe_entity"
455
+ messageType: "subscribe_collection" | "subscribe_one"
397
456
  ): void {
398
457
  this.handleAuthFailure().then(refreshed => {
399
458
  if (refreshed) {
@@ -403,29 +462,32 @@ export class RebaseWebSocketClient {
403
462
  backendKeyMap.delete(oldBackendId);
404
463
  backendKeyMap.set(newBackendId, subscriptionKey);
405
464
 
406
- this.sendMessage({
407
- type: messageType,
408
- payload: {
409
- ...subscription.props,
410
- subscriptionId: newBackendId
411
- }
412
- }).catch(error => {
413
- console.error(`[WS] Failed to re-subscribe ${idPrefix} after auth refresh:`, subscriptionKey, error);
414
- subscription.callbacks.forEach(callback => {
415
- if (callback.onError) callback.onError(error);
416
- });
417
- });
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);
418
481
  } else {
419
- const { errorMessage, errorCode } = extractMessageError(message);
420
- const error = new ApiError(errorMessage, errorMessage, errorCode);
421
- subscription.callbacks.forEach(callback => {
422
- if (callback.onError) callback.onError(error);
423
- });
482
+ this.failEntitySubscription(subscriptionKey, error);
424
483
  }
425
484
  }).catch(err => {
426
- subscription.callbacks.forEach(callback => {
427
- if (callback.onError) callback.onError(err);
428
- });
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
+ }
429
491
  });
430
492
  }
431
493
 
@@ -447,7 +509,7 @@ export class RebaseWebSocketClient {
447
509
  this.doSendMessage(pendingReq.message, pendingReq.resolve, pendingReq.reject).catch(pendingReq.reject);
448
510
  } else {
449
511
  const { errorMessage, errorCode } = extractMessageError(message);
450
- pendingReq.reject(new ApiError(errorMessage, errorMessage, errorCode));
512
+ pendingReq.reject(new RebaseApiError(errorMessage, { code: errorCode }));
451
513
  }
452
514
  }).catch(err => {
453
515
  pendingReq.reject(err);
@@ -455,7 +517,7 @@ export class RebaseWebSocketClient {
455
517
  } else {
456
518
  this.pendingRequests.delete(requestId);
457
519
  const { errorMessage, errorCode } = extractMessageError(message);
458
- pendingReq.reject(new ApiError(errorMessage, errorMessage, errorCode));
520
+ pendingReq.reject(new RebaseApiError(errorMessage, { code: errorCode }));
459
521
  }
460
522
  } else {
461
523
  this.pendingRequests.delete(requestId);
@@ -464,29 +526,60 @@ export class RebaseWebSocketClient {
464
526
  return;
465
527
  }
466
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
+
467
548
  // Handle subscription updates for collection subscriptions
468
549
  if (subscriptionId && type === "collection_update") {
469
550
  const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
470
551
  if (subscriptionKey) {
471
552
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
472
553
  if (collectionSub) {
473
- const incomingEntities = (message.entities || []) as Entity[];
554
+ const wireEntities = (message.rows || []) as unknown as Record<string, unknown>[];
555
+ const incomingRows = wireEntities;
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;
474
563
 
475
- // Structural merge: preserve cached entity references for entities
564
+ // Structural merge: preserve cached row references for rows
476
565
  // whose values haven't changed. This prevents downstream React components
477
566
  // from re-rendering (VirtualTableCell uses deepEqual on rowData —
478
567
  // same reference = instant true, avoiding expensive deep comparison).
479
- const entities = this.mergeEntities(collectionSub.latestData, incomingEntities);
568
+ const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
480
569
 
481
570
  // Cache the latest data with optimizations
482
- collectionSub.latestData = entities;
571
+ collectionSub.latestData = rows;
483
572
  collectionSub.lastUpdated = Date.now();
484
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;
485
578
 
486
579
  // Notify all callbacks for this subscription
487
580
  collectionSub.callbacks.forEach(callback => {
488
581
  try {
489
- callback.onUpdate(entities);
582
+ callback.onUpdate(rows);
490
583
  } catch (error) {
491
584
  console.error("Error in collection subscription callback:", error);
492
585
  if (callback.onError) {
@@ -499,30 +592,43 @@ export class RebaseWebSocketClient {
499
592
  }
500
593
  }
501
594
 
502
- // Handle instant entity-level patches for collection subscriptions.
595
+ // Handle instant row-level patches for collection subscriptions.
503
596
  // These arrive before the full refetch and give immediate cross-tab feedback.
504
- if (subscriptionId && type === "collection_entity_patch") {
597
+ if (subscriptionId && type === "collection_patch") {
505
598
  const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
506
599
  if (subscriptionKey) {
507
600
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
508
601
  if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
509
- const patchEntity = message.entity ?? null;
510
- const patchEntityId = (message as unknown as { entityId: string }).entityId;
511
- let updated: Entity[];
512
-
513
- if (patchEntity === null || patchEntity === undefined) {
514
- // Entity was deleted — remove it from the cached list
515
- updated = collectionSub.latestData.filter(e => String(e.id) !== String(patchEntityId));
602
+ const patchWireEntity = message.row ?? null;
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;
608
+ const patchRow = patchWireEntity ? (patchWireEntity as unknown as Record<string, unknown>) : null;
609
+ let updated: Record<string, unknown>[];
610
+
611
+ if (patchRow === null) {
612
+ // Row was deleted — remove it from the cached list
613
+ updated = collectionSub.latestData.filter(
614
+ e => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId)
615
+ );
516
616
  } else {
517
- // Entity was created or updated — merge into the cached list
518
- const idx = collectionSub.latestData.findIndex(e => String(e.id) === String(patchEntity.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
+ );
519
625
  if (idx >= 0) {
520
626
  // Update in place (preserve array position)
521
627
  updated = [...collectionSub.latestData];
522
- updated[idx] = patchEntity;
628
+ updated[idx] = patchRow;
523
629
  } else {
524
- // New entity — prepend (most recently created entities first)
525
- updated = [patchEntity, ...collectionSub.latestData];
630
+ // New row — prepend (most recently created first)
631
+ updated = [patchRow, ...collectionSub.latestData];
526
632
  }
527
633
  }
528
634
 
@@ -545,24 +651,28 @@ export class RebaseWebSocketClient {
545
651
  }
546
652
  }
547
653
 
548
- // Handle subscription updates for entity subscriptions
549
- if (subscriptionId && type === "entity_update") {
654
+ // Handle subscription updates for row subscriptions
655
+ if (subscriptionId && type === "single_update") {
550
656
  const subscriptionKey = this.backendToEntityKey.get(subscriptionId);
551
657
  if (subscriptionKey) {
552
- const entitySub = this.entitySubscriptions.get(subscriptionKey);
658
+ const entitySub = this.singleSubscriptions.get(subscriptionKey);
553
659
  if (entitySub) {
554
- const entity = message.entity ?? null;
660
+ const wireEntity = message.row ?? null;
661
+ const row = wireEntity ? (wireEntity as unknown as Record<string, unknown>) : null;
555
662
  // Cache the latest data with optimizations
556
- entitySub.latestData = entity;
663
+ entitySub.latestData = row;
557
664
  entitySub.lastUpdated = Date.now();
558
665
  entitySub.isInitialDataReceived = true;
666
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
667
+ entitySub.subscribeTimeout = undefined;
668
+ entitySub.subscribeInFlight = false;
559
669
 
560
670
  // Notify all callbacks for this subscription
561
671
  entitySub.callbacks.forEach(callback => {
562
672
  try {
563
- callback.onUpdate(entity);
673
+ callback.onUpdate(row);
564
674
  } catch (error) {
565
- console.error("Error in entity subscription callback:", error);
675
+ console.error("Error in row subscription callback:", error);
566
676
  if (callback.onError) {
567
677
  callback.onError(error instanceof Error ? error : new Error(String(error)));
568
678
  }
@@ -591,8 +701,16 @@ export class RebaseWebSocketClient {
591
701
  return;
592
702
  }
593
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
+
594
712
  const { errorMessage, errorCode } = extractMessageError(message);
595
- const error = new ApiError(errorMessage, errorMessage, errorCode);
713
+ const error = new RebaseApiError(errorMessage, { code: errorCode });
596
714
  collectionSub.callbacks.forEach(callback => {
597
715
  if (callback.onError) {
598
716
  callback.onError(error);
@@ -604,22 +722,26 @@ export class RebaseWebSocketClient {
604
722
 
605
723
  const entityKey = this.backendToEntityKey.get(subscriptionId);
606
724
  if (entityKey) {
607
- const entitySub = this.entitySubscriptions.get(entityKey);
725
+ const entitySub = this.singleSubscriptions.get(entityKey);
608
726
  if (entitySub) {
609
727
  if (this.isAuthError(message)) {
610
728
  this.resubscribeAfterAuthRefresh(
611
729
  message,
612
730
  entitySub,
613
731
  entityKey,
614
- "entity",
732
+ "row",
615
733
  this.backendToEntityKey,
616
- "subscribe_entity"
734
+ "subscribe_one"
617
735
  );
618
736
  return;
619
737
  }
620
738
 
739
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
740
+ entitySub.subscribeTimeout = undefined;
741
+ entitySub.subscribeInFlight = false;
742
+
621
743
  const { errorMessage, errorCode } = extractMessageError(message);
622
- const error = new ApiError(errorMessage, errorMessage, errorCode);
744
+ const error = new RebaseApiError(errorMessage, { code: errorCode });
623
745
  entitySub.callbacks.forEach(callback => {
624
746
  if (callback.onError) {
625
747
  callback.onError(error);
@@ -639,7 +761,7 @@ export class RebaseWebSocketClient {
639
761
  if (message.type === "ERROR" || message.error) {
640
762
  if (callback.onError) {
641
763
  const { errorMessage, errorCode } = extractMessageError(message);
642
- callback.onError(new ApiError(errorMessage, errorMessage, errorCode));
764
+ callback.onError(new RebaseApiError(errorMessage, { code: errorCode }));
643
765
  }
644
766
  } else {
645
767
  callback.onUpdate(message);
@@ -718,7 +840,11 @@ export class RebaseWebSocketClient {
718
840
  }
719
841
  }
720
842
 
721
- 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> {
722
848
  // If already has a requestId (re-sending from queue), use the stored promise handlers
723
849
  const queuedMsg = message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void };
724
850
  if (queuedMsg._queuedResolve && queuedMsg._queuedReject) {
@@ -747,7 +873,7 @@ export class RebaseWebSocketClient {
747
873
  await this.ensureAuthenticated();
748
874
  } catch (error: unknown) {
749
875
  const errorMessage = error instanceof Error ? error.message : "Authentication required";
750
- reject(new ApiError(errorMessage, errorMessage));
876
+ reject(new RebaseApiError(errorMessage));
751
877
  return;
752
878
  }
753
879
  }
@@ -757,7 +883,7 @@ export class RebaseWebSocketClient {
757
883
 
758
884
  const expectsResponse = ![
759
885
  "subscribe_collection",
760
- "subscribe_entity",
886
+ "subscribe_one",
761
887
  "unsubscribe",
762
888
  "join_channel",
763
889
  "leave_channel",
@@ -771,7 +897,7 @@ export class RebaseWebSocketClient {
771
897
  const timeoutHandle = setTimeout(() => {
772
898
  if (this.pendingRequests.has(requestId)) {
773
899
  this.pendingRequests.delete(requestId);
774
- reject(new ApiError("Request timed out", "Request timed out"));
900
+ reject(new RebaseApiError("Request timed out"));
775
901
  }
776
902
  }, this.requestTimeoutMs);
777
903
 
@@ -797,38 +923,39 @@ export class RebaseWebSocketClient {
797
923
  if (expectsResponse) {
798
924
  this.pendingRequests.delete(requestId);
799
925
  }
800
- reject(new ApiError("Failed to send message", error instanceof Error ? error.message : "Unknown error"));
926
+ reject(new RebaseApiError("Failed to send message", { cause: error }));
801
927
  }
802
928
  }
803
929
 
804
930
  // Data source methods
805
- async fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Entity<M>[]> {
931
+ async fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Record<string, unknown>[]> {
806
932
  const response = await this.sendMessage({
807
933
  type: "FETCH_COLLECTION",
808
934
  payload: props
809
- }) as { entities?: Entity<M>[] };
810
- return response.entities || [];
935
+ }) as { rows?: Record<string, unknown>[] };
936
+ return (response.rows || []);
811
937
  }
812
938
 
813
- async fetchEntity<M extends Record<string, unknown>>(props: FetchEntityProps<M>): Promise<Entity<M> | undefined> {
939
+ async fetchOne<M extends Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined> {
814
940
  const response = await this.sendMessage({
815
- type: "FETCH_ENTITY",
941
+ type: "FETCH_ONE",
816
942
  payload: props
817
- }) as { entity?: Entity<M> };
818
- return response.entity ?? undefined;
943
+ }) as { row?: Record<string, unknown> };
944
+ const wireEntity = response.row;
945
+ return wireEntity ?? undefined;
819
946
  }
820
947
 
821
- async saveEntity<M extends Record<string, unknown>>(props: SaveEntityProps<M>): Promise<Entity<M>> {
948
+ async save<M extends Record<string, unknown>>(props: SaveProps<M>): Promise<Record<string, unknown>> {
822
949
  const response = await this.sendMessage({
823
- type: "SAVE_ENTITY",
950
+ type: "SAVE",
824
951
  payload: props
825
- }) as { entity: Entity<M> };
826
- return response.entity;
952
+ }) as { row: Record<string, unknown> };
953
+ return response.row;
827
954
  }
828
955
 
829
- async deleteEntity<M extends Record<string, unknown>>(props: DeleteEntityProps<M>): Promise<void> {
956
+ async delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void> {
830
957
  await this.sendMessage({
831
- type: "DELETE_ENTITY",
958
+ type: "DELETE",
832
959
  payload: props
833
960
  });
834
961
  }
@@ -864,23 +991,23 @@ options }
864
991
  return response.database;
865
992
  }
866
993
 
867
- async checkUniqueField(path: string, name: string, value: unknown, entityId?: string, collection?: EntityCollection): Promise<boolean> {
994
+ async checkUniqueField(path: string, name: string, value: unknown, id?: string, collection?: CollectionConfig): Promise<boolean> {
868
995
  const response = await this.sendMessage({
869
996
  type: "CHECK_UNIQUE_FIELD",
870
997
  payload: {
871
998
  path,
872
999
  name,
873
1000
  value,
874
- entityId,
1001
+ id,
875
1002
  collection
876
1003
  }
877
1004
  }) as { isUnique: boolean };
878
1005
  return response.isUnique;
879
1006
  }
880
1007
 
881
- async countEntities<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number> {
1008
+ async count<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number> {
882
1009
  const response = await this.sendMessage({
883
- type: "COUNT_ENTITIES",
1010
+ type: "COUNT",
884
1011
  payload: props
885
1012
  }) as { count: number };
886
1013
  return response.count;
@@ -1017,52 +1144,75 @@ options }
1017
1144
  }
1018
1145
 
1019
1146
  /**
1020
- * Merge incoming entities with cached data, preserving cached references
1021
- * for entities whose values haven't changed. This avoids unnecessary
1022
- * React re-renders when the server refetches all entities but most
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
+
1165
+ /**
1166
+ * Merge incoming rows with cached data, preserving cached references
1167
+ * for rows whose values haven't changed. This avoids unnecessary
1168
+ * React re-renders when the server refetches all rows but most
1023
1169
  * haven't actually changed.
1024
1170
  */
1025
- private mergeEntities(cached: Entity[] | undefined, incoming: Entity[]): Entity[] {
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 entities by ID for O(1) access
1029
- const cachedById = new Map<string | number, Entity>();
1030
- for (const entity of cached) {
1031
- cachedById.set(entity.id, entity);
1178
+ // Build a lookup from cached rows by address for O(1) access
1179
+ const cachedById = new Map<string, Record<string, unknown>>();
1180
+ for (const row of cached) {
1181
+ const address = this.rowAddress(row, pks);
1182
+ if (address !== undefined) cachedById.set(address, row);
1032
1183
  }
1033
1184
 
1034
- return incoming.map(incomingEntity => {
1035
- const cachedEntity = cachedById.get(incomingEntity.id);
1036
- if (!cachedEntity) return incomingEntity;
1185
+ return incoming.map(incomingRow => {
1186
+ const address = this.rowAddress(incomingRow, pks);
1187
+ const cachedRow = address === undefined ? undefined : cachedById.get(address);
1188
+ if (!cachedRow) return incomingRow;
1037
1189
 
1038
- if (cachedEntity.path === incomingEntity.path) {
1039
- const normCached = this.normalizeForComparison(cachedEntity.values) as Record<string, unknown>;
1040
- const normIncoming = this.normalizeForComparison(incomingEntity.values) as Record<string, unknown>;
1190
+ // Compare flat rows directly (no more path/values nesting)
1191
+ const normCached = this.normalizeForComparison(cachedRow) as Record<string, unknown>;
1192
+ const normIncoming = this.normalizeForComparison(incomingRow) as Record<string, unknown>;
1041
1193
 
1042
- if (this.deepEqual(normCached, normIncoming)) {
1043
- return cachedEntity;
1044
- } else {
1045
- // Deep debug: Why did it fail? Let's check which exact property differs
1046
- // so the user can see it in their browser console if flashing still occurs.
1047
- const mismatches: Record<string, { cached: unknown, incoming: unknown }> = {};
1048
- const allKeys = new Set([...Object.keys(normCached), ...Object.keys(normIncoming)]);
1049
- for (const key of allKeys) {
1050
- if (!this.deepEqual(normCached[key], normIncoming[key])) {
1051
- mismatches[key] = { cached: normCached[key],
1194
+ if (this.deepEqual(normCached, normIncoming)) {
1195
+ return cachedRow;
1196
+ } else {
1197
+ // Deep debug: Why did it fail?
1198
+ const mismatches: Record<string, { cached: unknown, incoming: unknown }> = {};
1199
+ const allKeys = new Set([...Object.keys(normCached), ...Object.keys(normIncoming)]);
1200
+ for (const key of allKeys) {
1201
+ if (!this.deepEqual(normCached[key], normIncoming[key])) {
1202
+ mismatches[key] = { cached: normCached[key],
1052
1203
  incoming: normIncoming[key] };
1053
- }
1054
1204
  }
1055
- console.debug(`[RebaseWS] Row ${incomingEntity.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
1056
1205
  }
1206
+ console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
1057
1207
  }
1058
- return incomingEntity;
1208
+ return incomingRow;
1059
1209
  });
1060
1210
  }
1061
1211
 
1062
1212
  // Subscription methods
1063
1213
  listenCollection<M extends Record<string, unknown>>(
1064
1214
  props: FetchCollectionProps<M>,
1065
- onUpdate: (entities: Entity[]) => void,
1215
+ onUpdate: (rows: Record<string, unknown>[]) => void,
1066
1216
  onError?: (error: Error) => void
1067
1217
  ): () => void {
1068
1218
  const subscriptionKey = this.createCollectionSubscriptionKey(props);
@@ -1074,7 +1224,7 @@ incoming: normIncoming[key] };
1074
1224
  if (existingSubscription) {
1075
1225
  // Reuse existing subscription - just add the new callback
1076
1226
  const callbackMap = existingSubscription.callbacks as Map<string, {
1077
- onUpdate: (entities: Entity[]) => void;
1227
+ onUpdate: (rows: Record<string, unknown>[]) => void;
1078
1228
  onError?: (error: Error) => void;
1079
1229
  }>;
1080
1230
  callbackMap.set(callbackId, { onUpdate,
@@ -1090,13 +1240,21 @@ onError });
1090
1240
  onError(error instanceof Error ? error : new Error(String(error)));
1091
1241
  }
1092
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);
1093
1248
  }
1094
1249
 
1095
1250
  // Return unsubscribe function
1096
1251
  return () => {
1097
1252
  callbackMap.delete(callbackId);
1098
1253
  if (callbackMap.size === 0) {
1099
- // 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);
1100
1258
  this.collectionSubscriptions.delete(subscriptionKey);
1101
1259
  this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
1102
1260
  if (this.isConnected && this.ws) {
@@ -1112,7 +1270,7 @@ onError });
1112
1270
  // Create new subscription
1113
1271
  const backendSubscriptionId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1114
1272
  const callbackMap = new Map<string, {
1115
- onUpdate: (entities: Entity[]) => void;
1273
+ onUpdate: (rows: Record<string, unknown>[]) => void;
1116
1274
  onError?: (error: Error) => void;
1117
1275
  }>();
1118
1276
  callbackMap.set(callbackId, { onUpdate,
@@ -1127,16 +1285,9 @@ onError });
1127
1285
  // Add reverse lookup
1128
1286
  this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
1129
1287
 
1130
- // Send subscription request to backend
1131
- this.sendMessage({
1132
- type: "subscribe_collection",
1133
- payload: {
1134
- ...props,
1135
- subscriptionId: backendSubscriptionId
1136
- }
1137
- }).catch(error => {
1138
- if (onError) onError(error);
1139
- });
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);
1140
1291
 
1141
1292
  // Return unsubscribe function
1142
1293
  return () => {
@@ -1145,6 +1296,7 @@ onError });
1145
1296
  const callbacks = subscription.callbacks;
1146
1297
  callbacks.delete(callbackId);
1147
1298
  if (callbacks.size === 0) {
1299
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
1148
1300
  this.collectionSubscriptions.delete(subscriptionKey);
1149
1301
  this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
1150
1302
  if (this.isConnected && this.ws) {
@@ -1158,21 +1310,21 @@ onError });
1158
1310
  };
1159
1311
  }
1160
1312
 
1161
- listenEntity<M extends Record<string, unknown>>(
1162
- props: FetchEntityProps<M>,
1163
- onUpdate: (entity: Entity | null) => void,
1313
+ listenOne<M extends Record<string, unknown>>(
1314
+ props: FetchOneProps<M>,
1315
+ onUpdate: (row: Record<string, unknown> | null) => void,
1164
1316
  onError?: (error: Error) => void
1165
1317
  ): () => void {
1166
- const subscriptionKey = this.createEntitySubscriptionKey(props);
1318
+ const subscriptionKey = this.createSingleSubscriptionKey(props);
1167
1319
  const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1168
1320
 
1169
1321
  // Check if we already have a subscription for these exact parameters
1170
- const existingSubscription = this.entitySubscriptions.get(subscriptionKey);
1322
+ const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
1171
1323
 
1172
1324
  if (existingSubscription) {
1173
1325
  // Reuse existing subscription - just add the new callback
1174
1326
  const callbackMap = existingSubscription.callbacks as Map<string, {
1175
- onUpdate: (entity: Entity | null) => void;
1327
+ onUpdate: (row: Record<string, unknown> | null) => void;
1176
1328
  onError?: (error: Error) => void;
1177
1329
  }>;
1178
1330
  callbackMap.set(callbackId, { onUpdate,
@@ -1183,19 +1335,25 @@ onError });
1183
1335
  try {
1184
1336
  onUpdate(existingSubscription.latestData);
1185
1337
  } catch (error) {
1186
- console.error("Error in entity subscription callback:", error);
1338
+ console.error("Error in row subscription callback:", error);
1187
1339
  if (onError) {
1188
1340
  onError(error instanceof Error ? error : new Error(String(error)));
1189
1341
  }
1190
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);
1191
1347
  }
1192
1348
 
1193
1349
  // Return unsubscribe function
1194
1350
  return () => {
1195
1351
  callbackMap.delete(callbackId);
1196
1352
  if (callbackMap.size === 0) {
1353
+ if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
1354
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
1197
1355
  // No more callbacks, unsubscribe from backend
1198
- this.entitySubscriptions.delete(subscriptionKey);
1356
+ this.singleSubscriptions.delete(subscriptionKey);
1199
1357
  this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
1200
1358
  if (this.isConnected && this.ws) {
1201
1359
  this.sendMessage({
@@ -1210,13 +1368,13 @@ onError });
1210
1368
  // Create new subscription
1211
1369
  const backendSubscriptionId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1212
1370
  const callbackMap = new Map<string, {
1213
- onUpdate: (entity: Entity | null) => void;
1371
+ onUpdate: (row: Record<string, unknown> | null) => void;
1214
1372
  onError?: (error: Error) => void;
1215
1373
  }>();
1216
1374
  callbackMap.set(callbackId, { onUpdate,
1217
1375
  onError });
1218
1376
 
1219
- this.entitySubscriptions.set(subscriptionKey, {
1377
+ this.singleSubscriptions.set(subscriptionKey, {
1220
1378
  backendSubscriptionId,
1221
1379
  callbacks: callbackMap,
1222
1380
  props
@@ -1226,24 +1384,16 @@ onError });
1226
1384
  this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
1227
1385
 
1228
1386
  // Send subscription request to backend
1229
- this.sendMessage({
1230
- type: "subscribe_entity",
1231
- payload: {
1232
- ...props,
1233
- subscriptionId: backendSubscriptionId
1234
- }
1235
- }).catch(error => {
1236
- if (onError) onError(error);
1237
- });
1387
+ this.sendEntitySubscribe(subscriptionKey);
1238
1388
 
1239
1389
  // Return unsubscribe function
1240
1390
  return () => {
1241
- const subscription = this.entitySubscriptions.get(subscriptionKey);
1391
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
1242
1392
  if (subscription) {
1243
1393
  const callbacks = subscription.callbacks;
1244
1394
  callbacks.delete(callbackId);
1245
1395
  if (callbacks.size === 0) {
1246
- this.entitySubscriptions.delete(subscriptionKey);
1396
+ this.singleSubscriptions.delete(subscriptionKey);
1247
1397
  this.backendToEntityKey.delete(subscription.backendSubscriptionId);
1248
1398
  if (this.isConnected && this.ws) {
1249
1399
  this.sendMessage({
@@ -1256,13 +1406,208 @@ onError });
1256
1406
  };
1257
1407
  }
1258
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
+
1259
1604
  /**
1260
1605
  * Re-send all active subscriptions to the backend after a reconnect.
1261
1606
  * The server wipes subscription state when a client disconnects, so
1262
1607
  * we need to re-register everything to resume receiving updates.
1263
1608
  */
1264
1609
  private resubscribeAll(): void {
1265
- console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.entitySubscriptions.size} entity(ies)`);
1610
+ console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.singleSubscriptions.size} row(ies)`);
1266
1611
 
1267
1612
  // Re-subscribe collection subscriptions
1268
1613
  for (const [key, sub] of this.collectionSubscriptions.entries()) {
@@ -1275,19 +1620,11 @@ onError });
1275
1620
  this.backendToCollectionKey.delete(oldBackendId);
1276
1621
  this.backendToCollectionKey.set(newBackendId, key);
1277
1622
 
1278
- this.sendMessage({
1279
- type: "subscribe_collection",
1280
- payload: {
1281
- ...sub.props,
1282
- subscriptionId: newBackendId
1283
- }
1284
- }).catch(error => {
1285
- console.error("[WS] Failed to re-subscribe collection:", key, error);
1286
- });
1623
+ this.sendCollectionSubscribe(key);
1287
1624
  }
1288
1625
 
1289
- // Re-subscribe entity subscriptions
1290
- for (const [key, sub] of this.entitySubscriptions.entries()) {
1626
+ // Re-subscribe row subscriptions
1627
+ for (const [key, sub] of this.singleSubscriptions.entries()) {
1291
1628
  const oldBackendId = sub.backendSubscriptionId;
1292
1629
  const newBackendId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1293
1630
  sub.backendSubscriptionId = newBackendId;
@@ -1295,15 +1632,7 @@ onError });
1295
1632
  this.backendToEntityKey.delete(oldBackendId);
1296
1633
  this.backendToEntityKey.set(newBackendId, key);
1297
1634
 
1298
- this.sendMessage({
1299
- type: "subscribe_entity",
1300
- payload: {
1301
- ...sub.props,
1302
- subscriptionId: newBackendId
1303
- }
1304
- }).catch(error => {
1305
- console.error("[WS] Failed to re-subscribe entity:", key, error);
1306
- });
1635
+ this.sendEntitySubscribe(key);
1307
1636
  }
1308
1637
  }
1309
1638
 
@@ -1331,7 +1660,7 @@ onError });
1331
1660
  });
1332
1661
  }
1333
1662
 
1334
- private createEntitySubscriptionKey(props: FetchEntityProps): string {
1335
- return `${props.path}|${props.entityId}`;
1663
+ private createSingleSubscriptionKey(props: FetchOneProps): string {
1664
+ return `${props.path}|${props.id}`;
1336
1665
  }
1337
1666
  }