@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/README.md +11 -11
- package/dist/admin.d.ts +1 -0
- package/dist/auth.d.ts +35 -38
- package/dist/backups.d.ts +13 -0
- package/dist/collection.d.ts +9 -13
- package/dist/errors.d.ts +9 -0
- package/dist/index.d.ts +38 -22
- package/dist/index.es.js +985 -271
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +89 -0
- package/dist/sdk_query_builder.d.ts +63 -0
- package/dist/transport.d.ts +36 -6
- package/dist/websocket.d.ts +92 -22
- package/package.json +8 -9
- package/src/admin.ts +1 -1
- package/src/api-keys.ts +1 -1
- package/src/auth.ts +188 -64
- package/src/backups.ts +40 -0
- package/src/collection.test.ts +92 -3
- package/src/collection.ts +88 -75
- package/src/data-proxy.test.ts +167 -0
- package/src/errors.ts +9 -0
- package/src/index.ts +211 -28
- package/src/realtime-channel.test.ts +241 -0
- package/src/realtime-channel.ts +238 -0
- package/src/realtime-optout.test.ts +119 -0
- package/src/realtime-row-identity.test.ts +254 -0
- package/src/reviver.ts +2 -2
- package/src/sdk_query_builder.ts +141 -0
- package/src/storage.ts +64 -32
- package/src/transport.ts +59 -25
- package/src/websocket.ts +522 -193
- package/dist/collection.test.d.ts +0 -1
- package/dist/cron.test.d.ts +0 -1
- package/dist/index.umd.js +0 -2215
- package/dist/index.umd.js.map +0 -1
package/src/websocket.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
EntityCollection,
|
|
2
|
+
DeleteProps,
|
|
3
|
+
CollectionConfig,
|
|
5
4
|
FetchCollectionProps,
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
FetchOneProps,
|
|
6
|
+
SaveProps,
|
|
8
7
|
WebSocketMessage,
|
|
9
8
|
WebSocketErrorPayload,
|
|
10
9
|
CollectionUpdateMessage,
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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: (
|
|
113
|
+
onUpdate: (rows: Record<string, unknown>[]) => void;
|
|
90
114
|
onError?: (error: Error) => void;
|
|
91
115
|
}>;
|
|
92
116
|
props: FetchCollectionProps;
|
|
93
|
-
latestData?:
|
|
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
|
|
141
|
+
private singleSubscriptions = new Map<string, {
|
|
99
142
|
backendSubscriptionId: string;
|
|
100
143
|
callbacks: Map<string, {
|
|
101
|
-
onUpdate: (
|
|
144
|
+
onUpdate: (row: Record<string, unknown> | null) => void;
|
|
102
145
|
onError?: (error: Error) => void;
|
|
103
146
|
}>;
|
|
104
|
-
props:
|
|
105
|
-
latestData?:
|
|
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
|
|
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
|
|
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 |
|
|
450
|
+
props: FetchCollectionProps | FetchOneProps;
|
|
392
451
|
},
|
|
393
452
|
subscriptionKey: string,
|
|
394
|
-
idPrefix: "collection" | "
|
|
453
|
+
idPrefix: "collection" | "row",
|
|
395
454
|
backendKeyMap: Map<string, string>,
|
|
396
|
-
messageType: "subscribe_collection" | "
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
568
|
+
const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
|
|
480
569
|
|
|
481
570
|
// Cache the latest data with optimizations
|
|
482
|
-
collectionSub.latestData =
|
|
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(
|
|
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
|
|
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 === "
|
|
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
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
//
|
|
518
|
-
|
|
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] =
|
|
628
|
+
updated[idx] = patchRow;
|
|
523
629
|
} else {
|
|
524
|
-
// New
|
|
525
|
-
updated = [
|
|
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
|
|
549
|
-
if (subscriptionId && type === "
|
|
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.
|
|
658
|
+
const entitySub = this.singleSubscriptions.get(subscriptionKey);
|
|
553
659
|
if (entitySub) {
|
|
554
|
-
const
|
|
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 =
|
|
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(
|
|
673
|
+
callback.onUpdate(row);
|
|
564
674
|
} catch (error) {
|
|
565
|
-
console.error("Error in
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
732
|
+
"row",
|
|
615
733
|
this.backendToEntityKey,
|
|
616
|
-
"
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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<
|
|
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 {
|
|
810
|
-
return response.
|
|
935
|
+
}) as { rows?: Record<string, unknown>[] };
|
|
936
|
+
return (response.rows || []);
|
|
811
937
|
}
|
|
812
938
|
|
|
813
|
-
async
|
|
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: "
|
|
941
|
+
type: "FETCH_ONE",
|
|
816
942
|
payload: props
|
|
817
|
-
}) as {
|
|
818
|
-
|
|
943
|
+
}) as { row?: Record<string, unknown> };
|
|
944
|
+
const wireEntity = response.row;
|
|
945
|
+
return wireEntity ?? undefined;
|
|
819
946
|
}
|
|
820
947
|
|
|
821
|
-
async
|
|
948
|
+
async save<M extends Record<string, unknown>>(props: SaveProps<M>): Promise<Record<string, unknown>> {
|
|
822
949
|
const response = await this.sendMessage({
|
|
823
|
-
type: "
|
|
950
|
+
type: "SAVE",
|
|
824
951
|
payload: props
|
|
825
|
-
}) as {
|
|
826
|
-
return response.
|
|
952
|
+
}) as { row: Record<string, unknown> };
|
|
953
|
+
return response.row;
|
|
827
954
|
}
|
|
828
955
|
|
|
829
|
-
async
|
|
956
|
+
async delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void> {
|
|
830
957
|
await this.sendMessage({
|
|
831
|
-
type: "
|
|
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,
|
|
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
|
-
|
|
1001
|
+
id,
|
|
875
1002
|
collection
|
|
876
1003
|
}
|
|
877
1004
|
}) as { isUnique: boolean };
|
|
878
1005
|
return response.isUnique;
|
|
879
1006
|
}
|
|
880
1007
|
|
|
881
|
-
async
|
|
1008
|
+
async count<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number> {
|
|
882
1009
|
const response = await this.sendMessage({
|
|
883
|
-
type: "
|
|
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
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
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
|
|
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
|
|
1029
|
-
const cachedById = new Map<string
|
|
1030
|
-
for (const
|
|
1031
|
-
|
|
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(
|
|
1035
|
-
const
|
|
1036
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
|
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: (
|
|
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: (
|
|
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
|
-
//
|
|
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: (
|
|
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
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
1162
|
-
props:
|
|
1163
|
-
onUpdate: (
|
|
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.
|
|
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.
|
|
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: (
|
|
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
|
|
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.
|
|
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: (
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
1290
|
-
for (const [key, sub] of this.
|
|
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.
|
|
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
|
|
1335
|
-
return `${props.path}|${props.
|
|
1663
|
+
private createSingleSubscriptionKey(props: FetchOneProps): string {
|
|
1664
|
+
return `${props.path}|${props.id}`;
|
|
1336
1665
|
}
|
|
1337
1666
|
}
|