@rebasepro/client 0.7.0 → 0.9.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
@@ -1,16 +1,16 @@
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
15
  import { rebaseReviver } from "./reviver";
16
16
 
@@ -44,19 +44,15 @@ export interface RebaseWebSocketConfig {
44
44
  }
45
45
 
46
46
 
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
-
47
+ /**
48
+ * Low-level realtime WebSocket client.
49
+ *
50
+ * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
51
+ * manages this internally (exposed as `client.ws`, typed by the minimal
52
+ * `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
53
+ * package root only because the `@rebasepro/client-postgresql` driver
54
+ * instantiates it directly; its surface may change without a major bump.
55
+ */
60
56
  export class RebaseWebSocketClient {
61
57
  private websocketUrl: string;
62
58
  private ws: WebSocket | null = null;
@@ -86,23 +82,23 @@ export class RebaseWebSocketClient {
86
82
  private collectionSubscriptions = new Map<string, {
87
83
  backendSubscriptionId: string;
88
84
  callbacks: Map<string, {
89
- onUpdate: (entities: Entity[]) => void;
85
+ onUpdate: (rows: Record<string, unknown>[]) => void;
90
86
  onError?: (error: Error) => void;
91
87
  }>;
92
88
  props: FetchCollectionProps;
93
- latestData?: Entity[]; // Cache the latest data
89
+ latestData?: Record<string, unknown>[]; // Cache the latest flat rows
94
90
  lastUpdated?: number; // Timestamp for cache invalidation
95
91
  isInitialDataReceived?: boolean; // Track if we got initial data
96
92
  }>();
97
93
 
98
- private entitySubscriptions = new Map<string, {
94
+ private singleSubscriptions = new Map<string, {
99
95
  backendSubscriptionId: string;
100
96
  callbacks: Map<string, {
101
- onUpdate: (entity: Entity | null) => void;
97
+ onUpdate: (row: Record<string, unknown> | null) => void;
102
98
  onError?: (error: Error) => void;
103
99
  }>;
104
- props: FetchEntityProps;
105
- latestData?: Entity | null; // Cache the latest data
100
+ props: FetchOneProps;
101
+ latestData?: Record<string, unknown> | null; // Cache the latest flat row
106
102
  lastUpdated?: number; // Timestamp for cache invalidation
107
103
  isInitialDataReceived?: boolean; // Track if we got initial data
108
104
  }>();
@@ -294,7 +290,7 @@ export class RebaseWebSocketClient {
294
290
  request.message._queuedReject = request.reject;
295
291
  this.messageQueue.push(request.message);
296
292
  } else {
297
- request.reject(new ApiError("Connection closed", "Connection closed"));
293
+ request.reject(new RebaseApiError("Connection closed"));
298
294
  }
299
295
  this.pendingRequests.delete(reqId);
300
296
  }
@@ -380,7 +376,7 @@ export class RebaseWebSocketClient {
380
376
  }
381
377
 
382
378
  /**
383
- * Shared logic for re-subscribing a collection or entity subscription
379
+ * Shared logic for re-subscribing a collection or row subscription
384
380
  * after an auth error is resolved by refreshing credentials.
385
381
  */
386
382
  private resubscribeAfterAuthRefresh(
@@ -388,12 +384,12 @@ export class RebaseWebSocketClient {
388
384
  subscription: {
389
385
  backendSubscriptionId: string;
390
386
  callbacks: Map<string, { onUpdate: (...args: never[]) => void; onError?: (error: Error) => void }>;
391
- props: FetchCollectionProps | FetchEntityProps;
387
+ props: FetchCollectionProps | FetchOneProps;
392
388
  },
393
389
  subscriptionKey: string,
394
- idPrefix: "collection" | "entity",
390
+ idPrefix: "collection" | "row",
395
391
  backendKeyMap: Map<string, string>,
396
- messageType: "subscribe_collection" | "subscribe_entity"
392
+ messageType: "subscribe_collection" | "subscribe_one"
397
393
  ): void {
398
394
  this.handleAuthFailure().then(refreshed => {
399
395
  if (refreshed) {
@@ -417,7 +413,7 @@ export class RebaseWebSocketClient {
417
413
  });
418
414
  } else {
419
415
  const { errorMessage, errorCode } = extractMessageError(message);
420
- const error = new ApiError(errorMessage, errorMessage, errorCode);
416
+ const error = new RebaseApiError(errorMessage, { code: errorCode });
421
417
  subscription.callbacks.forEach(callback => {
422
418
  if (callback.onError) callback.onError(error);
423
419
  });
@@ -447,7 +443,7 @@ export class RebaseWebSocketClient {
447
443
  this.doSendMessage(pendingReq.message, pendingReq.resolve, pendingReq.reject).catch(pendingReq.reject);
448
444
  } else {
449
445
  const { errorMessage, errorCode } = extractMessageError(message);
450
- pendingReq.reject(new ApiError(errorMessage, errorMessage, errorCode));
446
+ pendingReq.reject(new RebaseApiError(errorMessage, { code: errorCode }));
451
447
  }
452
448
  }).catch(err => {
453
449
  pendingReq.reject(err);
@@ -455,7 +451,7 @@ export class RebaseWebSocketClient {
455
451
  } else {
456
452
  this.pendingRequests.delete(requestId);
457
453
  const { errorMessage, errorCode } = extractMessageError(message);
458
- pendingReq.reject(new ApiError(errorMessage, errorMessage, errorCode));
454
+ pendingReq.reject(new RebaseApiError(errorMessage, { code: errorCode }));
459
455
  }
460
456
  } else {
461
457
  this.pendingRequests.delete(requestId);
@@ -470,23 +466,24 @@ export class RebaseWebSocketClient {
470
466
  if (subscriptionKey) {
471
467
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
472
468
  if (collectionSub) {
473
- const incomingEntities = (message.entities || []) as Entity[];
469
+ const wireEntities = (message.rows || []) as unknown as Record<string, unknown>[];
470
+ const incomingRows = wireEntities;
474
471
 
475
- // Structural merge: preserve cached entity references for entities
472
+ // Structural merge: preserve cached row references for rows
476
473
  // whose values haven't changed. This prevents downstream React components
477
474
  // from re-rendering (VirtualTableCell uses deepEqual on rowData —
478
475
  // same reference = instant true, avoiding expensive deep comparison).
479
- const entities = this.mergeEntities(collectionSub.latestData, incomingEntities);
476
+ const rows = this.mergeRows(collectionSub.latestData, incomingRows);
480
477
 
481
478
  // Cache the latest data with optimizations
482
- collectionSub.latestData = entities;
479
+ collectionSub.latestData = rows;
483
480
  collectionSub.lastUpdated = Date.now();
484
481
  collectionSub.isInitialDataReceived = true;
485
482
 
486
483
  // Notify all callbacks for this subscription
487
484
  collectionSub.callbacks.forEach(callback => {
488
485
  try {
489
- callback.onUpdate(entities);
486
+ callback.onUpdate(rows);
490
487
  } catch (error) {
491
488
  console.error("Error in collection subscription callback:", error);
492
489
  if (callback.onError) {
@@ -499,30 +496,31 @@ export class RebaseWebSocketClient {
499
496
  }
500
497
  }
501
498
 
502
- // Handle instant entity-level patches for collection subscriptions.
499
+ // Handle instant row-level patches for collection subscriptions.
503
500
  // These arrive before the full refetch and give immediate cross-tab feedback.
504
- if (subscriptionId && type === "collection_entity_patch") {
501
+ if (subscriptionId && type === "collection_patch") {
505
502
  const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
506
503
  if (subscriptionKey) {
507
504
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
508
505
  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[];
506
+ const patchWireEntity = message.row ?? null;
507
+ const patchEntityId = (message as unknown as { id: string }).id;
508
+ const patchRow = patchWireEntity ? (patchWireEntity as unknown as Record<string, unknown>) : null;
509
+ let updated: Record<string, unknown>[];
512
510
 
513
- if (patchEntity === null || patchEntity === undefined) {
514
- // Entity was deleted — remove it from the cached list
511
+ if (patchRow === null) {
512
+ // Row was deleted — remove it from the cached list
515
513
  updated = collectionSub.latestData.filter(e => String(e.id) !== String(patchEntityId));
516
514
  } 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));
515
+ // Row was created or updated — merge into the cached list
516
+ const idx = collectionSub.latestData.findIndex(e => String(e.id) === String(patchRow.id));
519
517
  if (idx >= 0) {
520
518
  // Update in place (preserve array position)
521
519
  updated = [...collectionSub.latestData];
522
- updated[idx] = patchEntity;
520
+ updated[idx] = patchRow;
523
521
  } else {
524
- // New entity — prepend (most recently created entities first)
525
- updated = [patchEntity, ...collectionSub.latestData];
522
+ // New row — prepend (most recently created first)
523
+ updated = [patchRow, ...collectionSub.latestData];
526
524
  }
527
525
  }
528
526
 
@@ -545,24 +543,25 @@ export class RebaseWebSocketClient {
545
543
  }
546
544
  }
547
545
 
548
- // Handle subscription updates for entity subscriptions
549
- if (subscriptionId && type === "entity_update") {
546
+ // Handle subscription updates for row subscriptions
547
+ if (subscriptionId && type === "single_update") {
550
548
  const subscriptionKey = this.backendToEntityKey.get(subscriptionId);
551
549
  if (subscriptionKey) {
552
- const entitySub = this.entitySubscriptions.get(subscriptionKey);
550
+ const entitySub = this.singleSubscriptions.get(subscriptionKey);
553
551
  if (entitySub) {
554
- const entity = message.entity ?? null;
552
+ const wireEntity = message.row ?? null;
553
+ const row = wireEntity ? (wireEntity as unknown as Record<string, unknown>) : null;
555
554
  // Cache the latest data with optimizations
556
- entitySub.latestData = entity;
555
+ entitySub.latestData = row;
557
556
  entitySub.lastUpdated = Date.now();
558
557
  entitySub.isInitialDataReceived = true;
559
558
 
560
559
  // Notify all callbacks for this subscription
561
560
  entitySub.callbacks.forEach(callback => {
562
561
  try {
563
- callback.onUpdate(entity);
562
+ callback.onUpdate(row);
564
563
  } catch (error) {
565
- console.error("Error in entity subscription callback:", error);
564
+ console.error("Error in row subscription callback:", error);
566
565
  if (callback.onError) {
567
566
  callback.onError(error instanceof Error ? error : new Error(String(error)));
568
567
  }
@@ -592,7 +591,7 @@ export class RebaseWebSocketClient {
592
591
  }
593
592
 
594
593
  const { errorMessage, errorCode } = extractMessageError(message);
595
- const error = new ApiError(errorMessage, errorMessage, errorCode);
594
+ const error = new RebaseApiError(errorMessage, { code: errorCode });
596
595
  collectionSub.callbacks.forEach(callback => {
597
596
  if (callback.onError) {
598
597
  callback.onError(error);
@@ -604,22 +603,22 @@ export class RebaseWebSocketClient {
604
603
 
605
604
  const entityKey = this.backendToEntityKey.get(subscriptionId);
606
605
  if (entityKey) {
607
- const entitySub = this.entitySubscriptions.get(entityKey);
606
+ const entitySub = this.singleSubscriptions.get(entityKey);
608
607
  if (entitySub) {
609
608
  if (this.isAuthError(message)) {
610
609
  this.resubscribeAfterAuthRefresh(
611
610
  message,
612
611
  entitySub,
613
612
  entityKey,
614
- "entity",
613
+ "row",
615
614
  this.backendToEntityKey,
616
- "subscribe_entity"
615
+ "subscribe_one"
617
616
  );
618
617
  return;
619
618
  }
620
619
 
621
620
  const { errorMessage, errorCode } = extractMessageError(message);
622
- const error = new ApiError(errorMessage, errorMessage, errorCode);
621
+ const error = new RebaseApiError(errorMessage, { code: errorCode });
623
622
  entitySub.callbacks.forEach(callback => {
624
623
  if (callback.onError) {
625
624
  callback.onError(error);
@@ -639,7 +638,7 @@ export class RebaseWebSocketClient {
639
638
  if (message.type === "ERROR" || message.error) {
640
639
  if (callback.onError) {
641
640
  const { errorMessage, errorCode } = extractMessageError(message);
642
- callback.onError(new ApiError(errorMessage, errorMessage, errorCode));
641
+ callback.onError(new RebaseApiError(errorMessage, { code: errorCode }));
643
642
  }
644
643
  } else {
645
644
  callback.onUpdate(message);
@@ -747,7 +746,7 @@ export class RebaseWebSocketClient {
747
746
  await this.ensureAuthenticated();
748
747
  } catch (error: unknown) {
749
748
  const errorMessage = error instanceof Error ? error.message : "Authentication required";
750
- reject(new ApiError(errorMessage, errorMessage));
749
+ reject(new RebaseApiError(errorMessage));
751
750
  return;
752
751
  }
753
752
  }
@@ -757,7 +756,7 @@ export class RebaseWebSocketClient {
757
756
 
758
757
  const expectsResponse = ![
759
758
  "subscribe_collection",
760
- "subscribe_entity",
759
+ "subscribe_one",
761
760
  "unsubscribe",
762
761
  "join_channel",
763
762
  "leave_channel",
@@ -771,7 +770,7 @@ export class RebaseWebSocketClient {
771
770
  const timeoutHandle = setTimeout(() => {
772
771
  if (this.pendingRequests.has(requestId)) {
773
772
  this.pendingRequests.delete(requestId);
774
- reject(new ApiError("Request timed out", "Request timed out"));
773
+ reject(new RebaseApiError("Request timed out"));
775
774
  }
776
775
  }, this.requestTimeoutMs);
777
776
 
@@ -797,38 +796,39 @@ export class RebaseWebSocketClient {
797
796
  if (expectsResponse) {
798
797
  this.pendingRequests.delete(requestId);
799
798
  }
800
- reject(new ApiError("Failed to send message", error instanceof Error ? error.message : "Unknown error"));
799
+ reject(new RebaseApiError("Failed to send message", { cause: error }));
801
800
  }
802
801
  }
803
802
 
804
803
  // Data source methods
805
- async fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Entity<M>[]> {
804
+ async fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Record<string, unknown>[]> {
806
805
  const response = await this.sendMessage({
807
806
  type: "FETCH_COLLECTION",
808
807
  payload: props
809
- }) as { entities?: Entity<M>[] };
810
- return response.entities || [];
808
+ }) as { rows?: Record<string, unknown>[] };
809
+ return (response.rows || []);
811
810
  }
812
811
 
813
- async fetchEntity<M extends Record<string, unknown>>(props: FetchEntityProps<M>): Promise<Entity<M> | undefined> {
812
+ async fetchOne<M extends Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined> {
814
813
  const response = await this.sendMessage({
815
- type: "FETCH_ENTITY",
814
+ type: "FETCH_ONE",
816
815
  payload: props
817
- }) as { entity?: Entity<M> };
818
- return response.entity ?? undefined;
816
+ }) as { row?: Record<string, unknown> };
817
+ const wireEntity = response.row;
818
+ return wireEntity ?? undefined;
819
819
  }
820
820
 
821
- async saveEntity<M extends Record<string, unknown>>(props: SaveEntityProps<M>): Promise<Entity<M>> {
821
+ async save<M extends Record<string, unknown>>(props: SaveProps<M>): Promise<Record<string, unknown>> {
822
822
  const response = await this.sendMessage({
823
- type: "SAVE_ENTITY",
823
+ type: "SAVE",
824
824
  payload: props
825
- }) as { entity: Entity<M> };
826
- return response.entity;
825
+ }) as { row: Record<string, unknown> };
826
+ return response.row;
827
827
  }
828
828
 
829
- async deleteEntity<M extends Record<string, unknown>>(props: DeleteEntityProps<M>): Promise<void> {
829
+ async delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void> {
830
830
  await this.sendMessage({
831
- type: "DELETE_ENTITY",
831
+ type: "DELETE",
832
832
  payload: props
833
833
  });
834
834
  }
@@ -864,23 +864,23 @@ options }
864
864
  return response.database;
865
865
  }
866
866
 
867
- async checkUniqueField(path: string, name: string, value: unknown, entityId?: string, collection?: EntityCollection): Promise<boolean> {
867
+ async checkUniqueField(path: string, name: string, value: unknown, id?: string, collection?: CollectionConfig): Promise<boolean> {
868
868
  const response = await this.sendMessage({
869
869
  type: "CHECK_UNIQUE_FIELD",
870
870
  payload: {
871
871
  path,
872
872
  name,
873
873
  value,
874
- entityId,
874
+ id,
875
875
  collection
876
876
  }
877
877
  }) as { isUnique: boolean };
878
878
  return response.isUnique;
879
879
  }
880
880
 
881
- async countEntities<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number> {
881
+ async count<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number> {
882
882
  const response = await this.sendMessage({
883
- type: "COUNT_ENTITIES",
883
+ type: "COUNT",
884
884
  payload: props
885
885
  }) as { count: number };
886
886
  return response.count;
@@ -1017,52 +1017,50 @@ options }
1017
1017
  }
1018
1018
 
1019
1019
  /**
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
1020
+ * Merge incoming rows with cached data, preserving cached references
1021
+ * for rows whose values haven't changed. This avoids unnecessary
1022
+ * React re-renders when the server refetches all rows but most
1023
1023
  * haven't actually changed.
1024
1024
  */
1025
- private mergeEntities(cached: Entity[] | undefined, incoming: Entity[]): Entity[] {
1025
+ private mergeRows(cached: Record<string, unknown>[] | undefined, incoming: Record<string, unknown>[]): Record<string, unknown>[] {
1026
1026
  if (!cached || cached.length === 0) return incoming;
1027
1027
 
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);
1028
+ // Build a lookup from cached rows by ID for O(1) access
1029
+ const cachedById = new Map<string | number, Record<string, unknown>>();
1030
+ for (const row of cached) {
1031
+ cachedById.set(row.id as string | number, row);
1032
1032
  }
1033
1033
 
1034
- return incoming.map(incomingEntity => {
1035
- const cachedEntity = cachedById.get(incomingEntity.id);
1036
- if (!cachedEntity) return incomingEntity;
1034
+ return incoming.map(incomingRow => {
1035
+ const cachedRow = cachedById.get(incomingRow.id as string | number);
1036
+ if (!cachedRow) return incomingRow;
1037
1037
 
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>;
1038
+ // Compare flat rows directly (no more path/values nesting)
1039
+ const normCached = this.normalizeForComparison(cachedRow) as Record<string, unknown>;
1040
+ const normIncoming = this.normalizeForComparison(incomingRow) as Record<string, unknown>;
1041
1041
 
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],
1042
+ if (this.deepEqual(normCached, normIncoming)) {
1043
+ return cachedRow;
1044
+ } else {
1045
+ // Deep debug: Why did it fail?
1046
+ const mismatches: Record<string, { cached: unknown, incoming: unknown }> = {};
1047
+ const allKeys = new Set([...Object.keys(normCached), ...Object.keys(normIncoming)]);
1048
+ for (const key of allKeys) {
1049
+ if (!this.deepEqual(normCached[key], normIncoming[key])) {
1050
+ mismatches[key] = { cached: normCached[key],
1052
1051
  incoming: normIncoming[key] };
1053
- }
1054
1052
  }
1055
- console.debug(`[RebaseWS] Row ${incomingEntity.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
1056
1053
  }
1054
+ console.debug(`[RebaseWS] Row ${incomingRow.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
1057
1055
  }
1058
- return incomingEntity;
1056
+ return incomingRow;
1059
1057
  });
1060
1058
  }
1061
1059
 
1062
1060
  // Subscription methods
1063
1061
  listenCollection<M extends Record<string, unknown>>(
1064
1062
  props: FetchCollectionProps<M>,
1065
- onUpdate: (entities: Entity[]) => void,
1063
+ onUpdate: (rows: Record<string, unknown>[]) => void,
1066
1064
  onError?: (error: Error) => void
1067
1065
  ): () => void {
1068
1066
  const subscriptionKey = this.createCollectionSubscriptionKey(props);
@@ -1074,7 +1072,7 @@ incoming: normIncoming[key] };
1074
1072
  if (existingSubscription) {
1075
1073
  // Reuse existing subscription - just add the new callback
1076
1074
  const callbackMap = existingSubscription.callbacks as Map<string, {
1077
- onUpdate: (entities: Entity[]) => void;
1075
+ onUpdate: (rows: Record<string, unknown>[]) => void;
1078
1076
  onError?: (error: Error) => void;
1079
1077
  }>;
1080
1078
  callbackMap.set(callbackId, { onUpdate,
@@ -1112,7 +1110,7 @@ onError });
1112
1110
  // Create new subscription
1113
1111
  const backendSubscriptionId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1114
1112
  const callbackMap = new Map<string, {
1115
- onUpdate: (entities: Entity[]) => void;
1113
+ onUpdate: (rows: Record<string, unknown>[]) => void;
1116
1114
  onError?: (error: Error) => void;
1117
1115
  }>();
1118
1116
  callbackMap.set(callbackId, { onUpdate,
@@ -1158,21 +1156,21 @@ onError });
1158
1156
  };
1159
1157
  }
1160
1158
 
1161
- listenEntity<M extends Record<string, unknown>>(
1162
- props: FetchEntityProps<M>,
1163
- onUpdate: (entity: Entity | null) => void,
1159
+ listenOne<M extends Record<string, unknown>>(
1160
+ props: FetchOneProps<M>,
1161
+ onUpdate: (row: Record<string, unknown> | null) => void,
1164
1162
  onError?: (error: Error) => void
1165
1163
  ): () => void {
1166
- const subscriptionKey = this.createEntitySubscriptionKey(props);
1164
+ const subscriptionKey = this.createSingleSubscriptionKey(props);
1167
1165
  const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1168
1166
 
1169
1167
  // Check if we already have a subscription for these exact parameters
1170
- const existingSubscription = this.entitySubscriptions.get(subscriptionKey);
1168
+ const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
1171
1169
 
1172
1170
  if (existingSubscription) {
1173
1171
  // Reuse existing subscription - just add the new callback
1174
1172
  const callbackMap = existingSubscription.callbacks as Map<string, {
1175
- onUpdate: (entity: Entity | null) => void;
1173
+ onUpdate: (row: Record<string, unknown> | null) => void;
1176
1174
  onError?: (error: Error) => void;
1177
1175
  }>;
1178
1176
  callbackMap.set(callbackId, { onUpdate,
@@ -1183,7 +1181,7 @@ onError });
1183
1181
  try {
1184
1182
  onUpdate(existingSubscription.latestData);
1185
1183
  } catch (error) {
1186
- console.error("Error in entity subscription callback:", error);
1184
+ console.error("Error in row subscription callback:", error);
1187
1185
  if (onError) {
1188
1186
  onError(error instanceof Error ? error : new Error(String(error)));
1189
1187
  }
@@ -1195,7 +1193,7 @@ onError });
1195
1193
  callbackMap.delete(callbackId);
1196
1194
  if (callbackMap.size === 0) {
1197
1195
  // No more callbacks, unsubscribe from backend
1198
- this.entitySubscriptions.delete(subscriptionKey);
1196
+ this.singleSubscriptions.delete(subscriptionKey);
1199
1197
  this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
1200
1198
  if (this.isConnected && this.ws) {
1201
1199
  this.sendMessage({
@@ -1210,13 +1208,13 @@ onError });
1210
1208
  // Create new subscription
1211
1209
  const backendSubscriptionId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1212
1210
  const callbackMap = new Map<string, {
1213
- onUpdate: (entity: Entity | null) => void;
1211
+ onUpdate: (row: Record<string, unknown> | null) => void;
1214
1212
  onError?: (error: Error) => void;
1215
1213
  }>();
1216
1214
  callbackMap.set(callbackId, { onUpdate,
1217
1215
  onError });
1218
1216
 
1219
- this.entitySubscriptions.set(subscriptionKey, {
1217
+ this.singleSubscriptions.set(subscriptionKey, {
1220
1218
  backendSubscriptionId,
1221
1219
  callbacks: callbackMap,
1222
1220
  props
@@ -1227,7 +1225,7 @@ onError });
1227
1225
 
1228
1226
  // Send subscription request to backend
1229
1227
  this.sendMessage({
1230
- type: "subscribe_entity",
1228
+ type: "subscribe_one",
1231
1229
  payload: {
1232
1230
  ...props,
1233
1231
  subscriptionId: backendSubscriptionId
@@ -1238,12 +1236,12 @@ onError });
1238
1236
 
1239
1237
  // Return unsubscribe function
1240
1238
  return () => {
1241
- const subscription = this.entitySubscriptions.get(subscriptionKey);
1239
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
1242
1240
  if (subscription) {
1243
1241
  const callbacks = subscription.callbacks;
1244
1242
  callbacks.delete(callbackId);
1245
1243
  if (callbacks.size === 0) {
1246
- this.entitySubscriptions.delete(subscriptionKey);
1244
+ this.singleSubscriptions.delete(subscriptionKey);
1247
1245
  this.backendToEntityKey.delete(subscription.backendSubscriptionId);
1248
1246
  if (this.isConnected && this.ws) {
1249
1247
  this.sendMessage({
@@ -1262,7 +1260,7 @@ onError });
1262
1260
  * we need to re-register everything to resume receiving updates.
1263
1261
  */
1264
1262
  private resubscribeAll(): void {
1265
- console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.entitySubscriptions.size} entity(ies)`);
1263
+ console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.singleSubscriptions.size} row(ies)`);
1266
1264
 
1267
1265
  // Re-subscribe collection subscriptions
1268
1266
  for (const [key, sub] of this.collectionSubscriptions.entries()) {
@@ -1286,8 +1284,8 @@ onError });
1286
1284
  });
1287
1285
  }
1288
1286
 
1289
- // Re-subscribe entity subscriptions
1290
- for (const [key, sub] of this.entitySubscriptions.entries()) {
1287
+ // Re-subscribe row subscriptions
1288
+ for (const [key, sub] of this.singleSubscriptions.entries()) {
1291
1289
  const oldBackendId = sub.backendSubscriptionId;
1292
1290
  const newBackendId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1293
1291
  sub.backendSubscriptionId = newBackendId;
@@ -1296,13 +1294,13 @@ onError });
1296
1294
  this.backendToEntityKey.set(newBackendId, key);
1297
1295
 
1298
1296
  this.sendMessage({
1299
- type: "subscribe_entity",
1297
+ type: "subscribe_one",
1300
1298
  payload: {
1301
1299
  ...sub.props,
1302
1300
  subscriptionId: newBackendId
1303
1301
  }
1304
1302
  }).catch(error => {
1305
- console.error("[WS] Failed to re-subscribe entity:", key, error);
1303
+ console.error("[WS] Failed to re-subscribe row:", key, error);
1306
1304
  });
1307
1305
  }
1308
1306
  }
@@ -1331,7 +1329,7 @@ onError });
1331
1329
  });
1332
1330
  }
1333
1331
 
1334
- private createEntitySubscriptionKey(props: FetchEntityProps): string {
1335
- return `${props.path}|${props.entityId}`;
1332
+ private createSingleSubscriptionKey(props: FetchOneProps): string {
1333
+ return `${props.path}|${props.id}`;
1336
1334
  }
1337
1335
  }