@rebasepro/client 0.0.1-canary.09e5ec5

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.
@@ -0,0 +1,1176 @@
1
+ import {
2
+ DeleteEntityProps,
3
+ Entity,
4
+ EntityCollection,
5
+ FetchCollectionProps,
6
+ FetchEntityProps,
7
+ SaveEntityProps,
8
+ WebSocketMessage,
9
+ WebSocketErrorPayload,
10
+ CollectionUpdateMessage,
11
+ EntityUpdateMessage,
12
+ TableMetadata,
13
+ BranchInfo
14
+ } from "@rebasepro/types";
15
+ import { rebaseReviver } from "./reviver";
16
+
17
+ /**
18
+ * Rehydrate all serialised types inside an Entity's `values`.
19
+ * (Now obsolete as JSON.parse with rebaseReviver handles this globally,
20
+ * kept as pass-through for API compatibility)
21
+ */
22
+ function rehydrateEntity<M extends Record<string, unknown>>(entity: Entity<M>): Entity<M> {
23
+ return entity;
24
+ }
25
+
26
+ /**
27
+ * Extract error message and code from a WebSocket message payload.
28
+ * Handles both `{ error: string }` and `{ error: { message, code } }` shapes.
29
+ */
30
+ function extractMessageError(message: WebSocketMessage): { errorMessage: string; errorCode?: string } {
31
+ const payload = message.payload as WebSocketErrorPayload | undefined;
32
+ const errPayload = payload?.error;
33
+ const errorMessage = typeof errPayload === "object"
34
+ ? errPayload.message
35
+ : payload?.message || (typeof errPayload === "string" ? errPayload : undefined) || message.error || "Unknown error";
36
+ const errorCode = typeof errPayload === "object"
37
+ ? errPayload.code
38
+ : payload?.code;
39
+ return { errorMessage,
40
+ errorCode };
41
+ }
42
+
43
+ export interface RebaseWebSocketConfig {
44
+ websocketUrl: string;
45
+ /** Optional auth token getter for WebSocket authentication */
46
+ getAuthToken?: () => Promise<string>;
47
+ /** Optional WebSocket constructor to override globalThis.WebSocket (e.g. for Node environments) */
48
+ WebSocket?: typeof WebSocket;
49
+ }
50
+
51
+
52
+ export class ApiError extends Error {
53
+ public code?: string;
54
+ public error?: string;
55
+
56
+ constructor(message: string, error?: string, code?: string) {
57
+ super(message);
58
+ this.name = "ApiError";
59
+ this.code = code;
60
+ this.error = error;
61
+ }
62
+ }
63
+
64
+
65
+ export class RebaseWebSocketClient {
66
+ private websocketUrl: string;
67
+ private ws: WebSocket | null = null;
68
+ public getAuthToken?: () => Promise<string>;
69
+ private subscriptions = new Map<string, {
70
+ onUpdate: (data: WebSocketMessage) => void,
71
+ onError?: (error: Error) => void
72
+ }>();
73
+
74
+ private listeners = new Map<string, Set<(...args: unknown[]) => void>>();
75
+
76
+ public on(event: "connect" | "disconnect" | "reconnect" | "error", cb: (...args: unknown[]) => void) {
77
+ if (!this.listeners.has(event)) {
78
+ this.listeners.set(event, new Set());
79
+ }
80
+ this.listeners.get(event)!.add(cb);
81
+ return () => this.listeners.get(event)!.delete(cb);
82
+ }
83
+
84
+ private emit(event: string, ...args: unknown[]) {
85
+ if (this.listeners.has(event)) {
86
+ this.listeners.get(event)!.forEach(cb => cb(...args));
87
+ }
88
+ }
89
+
90
+ // New: Subscription deduplication management with optimizations
91
+ private collectionSubscriptions = new Map<string, {
92
+ backendSubscriptionId: string;
93
+ callbacks: Map<string, {
94
+ onUpdate: (entities: Entity[]) => void;
95
+ onError?: (error: Error) => void;
96
+ }>;
97
+ props: FetchCollectionProps;
98
+ latestData?: Entity[]; // Cache the latest data
99
+ lastUpdated?: number; // Timestamp for cache invalidation
100
+ isInitialDataReceived?: boolean; // Track if we got initial data
101
+ }>();
102
+
103
+ private entitySubscriptions = new Map<string, {
104
+ backendSubscriptionId: string;
105
+ callbacks: Map<string, {
106
+ onUpdate: (entity: Entity | null) => void;
107
+ onError?: (error: Error) => void;
108
+ }>;
109
+ props: FetchEntityProps;
110
+ latestData?: Entity | null; // Cache the latest data
111
+ lastUpdated?: number; // Timestamp for cache invalidation
112
+ isInitialDataReceived?: boolean; // Track if we got initial data
113
+ }>();
114
+
115
+ // Maps to quickly find subscription by backend subscription ID
116
+ private backendToCollectionKey = new Map<string, string>();
117
+ private backendToEntityKey = new Map<string, string>();
118
+
119
+
120
+ private pendingRequests = new Map<string, {
121
+ resolve: (p: unknown) => void;
122
+ reject: (p: Error) => void;
123
+ message?: Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void }
124
+ }>();
125
+ private reconnectAttempts = 0;
126
+ private maxReconnectAttempts = 5;
127
+ private isConnected = false;
128
+ private messageQueue: Record<string, unknown>[] = [];
129
+ private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
130
+
131
+ private isAuthenticated = false;
132
+ private authPromise: Promise<void> | null = null;
133
+ private WebSocketConstructor: typeof WebSocket | undefined;
134
+
135
+ constructor(config: RebaseWebSocketConfig) {
136
+ this.websocketUrl = config.websocketUrl;
137
+ this.getAuthToken = config.getAuthToken;
138
+ this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : undefined);
139
+
140
+ if (!this.WebSocketConstructor) {
141
+ console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
142
+ } else {
143
+ this.initWebSocket();
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Authenticate the WebSocket connection
149
+ */
150
+ async authenticate(token: string): Promise<void> {
151
+ return new Promise((resolve, reject) => {
152
+ const requestId = `auth_${Date.now()}`;
153
+
154
+ const timeout = setTimeout(() => {
155
+ this.pendingRequests.delete(requestId);
156
+ this.authPromise = null; // Clear promise so we can retry later
157
+ reject(new Error("Authentication timeout"));
158
+ }, 30000);
159
+
160
+ this.pendingRequests.set(requestId, {
161
+ resolve: () => {
162
+ clearTimeout(timeout);
163
+ this.isAuthenticated = true;
164
+ resolve();
165
+ },
166
+ reject: (error) => {
167
+ clearTimeout(timeout);
168
+ reject(error);
169
+ }
170
+ });
171
+
172
+ const message = {
173
+ type: "AUTHENTICATE",
174
+ requestId,
175
+ payload: { token }
176
+ };
177
+
178
+ if (!this.isConnected || !this.ws) {
179
+ this.messageQueue.unshift(message); // Auth should be first
180
+ } else {
181
+ this.ws.send(JSON.stringify(message));
182
+ }
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Set the auth token getter function
188
+ */
189
+ setAuthTokenGetter(getAuthToken: () => Promise<string>): void {
190
+ this.getAuthToken = getAuthToken;
191
+ // Auto-authenticate if we are already connected but didn't have the token getter yet
192
+ if (this.isConnected && !this.isAuthenticated && !this.authPromise) {
193
+ console.log("WebSocket auto-authenticating after token getter set");
194
+ this.getAuthToken().then(token => {
195
+ if (!this.ws) return; // Prevent memory leaks / actions after disconnect
196
+ if (token) {
197
+ this.authenticate(token).catch(e => {
198
+ if (this.ws) console.warn("WebSocket auto-auth failed:", e);
199
+ });
200
+ }
201
+ }).catch(e => {
202
+ if (this.ws) console.warn("WebSocket auto-auth failed:", e);
203
+ });
204
+ }
205
+ }
206
+
207
+ public disconnect(): void {
208
+ this.isAuthenticated = false;
209
+ this.authPromise = null;
210
+ if (this.reconnectTimeout) {
211
+ clearTimeout(this.reconnectTimeout);
212
+ this.reconnectTimeout = null;
213
+ }
214
+ if (this.ws) {
215
+ this.ws.onclose = null; // Prevent reconnect on explicit disconnect
216
+ this.ws.onerror = null; // Prevent errors on explicit disconnect
217
+ this.ws.onopen = null;
218
+ this.ws.onmessage = null;
219
+ this.ws.close();
220
+ this.ws = null;
221
+ }
222
+ }
223
+
224
+ // Initialize WebSocket connection
225
+ private initWebSocket() {
226
+ if (!this.WebSocketConstructor) return;
227
+ if (this.ws?.readyState === this.WebSocketConstructor.OPEN) return;
228
+
229
+ try {
230
+ this.ws = new this.WebSocketConstructor(this.websocketUrl);
231
+
232
+ this.ws!.onopen = async () => {
233
+ console.log("Connected to PostgreSQL backend");
234
+ const wasReconnect = this.reconnectAttempts > 0;
235
+ this.isConnected = true;
236
+ this.reconnectAttempts = 0;
237
+
238
+ // Auto-authenticate if token getter is available
239
+ if (this.getAuthToken && !this.isAuthenticated) {
240
+ try {
241
+ const token = await this.getAuthToken();
242
+ if (token) {
243
+ await this.authenticate(token);
244
+ console.log("WebSocket auto-authenticated");
245
+ }
246
+ } catch (error) {
247
+ console.warn("WebSocket auto-auth failed, requests may fail:", error);
248
+ }
249
+ }
250
+
251
+ this.emit(wasReconnect ? "reconnect" : "connect");
252
+ this.processMessageQueue();
253
+
254
+ // Re-subscribe all active subscriptions after reconnect.
255
+ // The server-side subscription state was lost when the connection dropped,
256
+ // so we need to re-register every active subscription.
257
+ if (wasReconnect) {
258
+ this.resubscribeAll();
259
+ }
260
+ };
261
+
262
+ this.ws!.onmessage = (event) => {
263
+ try {
264
+ const message = JSON.parse(event.data, rebaseReviver);
265
+ this.handleWebSocketMessage(message);
266
+ } catch (error) {
267
+ console.error("Error parsing WebSocket message:", error);
268
+ }
269
+ };
270
+
271
+ this.ws!.onclose = () => {
272
+ console.log("Disconnected from PostgreSQL backend");
273
+ this.isConnected = false;
274
+ this.isAuthenticated = false;
275
+ this.authPromise = null;
276
+ this.emit("disconnect");
277
+
278
+ // Re-queue pending requests so the UI doesn't hang indefinitely or crash
279
+ for (const [reqId, request] of this.pendingRequests.entries()) {
280
+ if (reqId.startsWith("auth_")) {
281
+ request.reject(new Error("Connection closed during authentication"));
282
+ } else if (request.message) {
283
+ request.message._queuedResolve = request.resolve;
284
+ request.message._queuedReject = request.reject;
285
+ this.messageQueue.push(request.message);
286
+ } else {
287
+ request.reject(new ApiError("Connection closed", "Connection closed"));
288
+ }
289
+ this.pendingRequests.delete(reqId);
290
+ }
291
+
292
+ this.attemptReconnect();
293
+ };
294
+
295
+ this.ws!.onerror = (error) => {
296
+ console.error("WebSocket error:", error);
297
+ this.isConnected = false;
298
+ this.emit("error", error);
299
+ };
300
+ } catch (error) {
301
+ console.error("Failed to initialize WebSocket:", error);
302
+ this.attemptReconnect();
303
+ }
304
+ }
305
+
306
+ private processMessageQueue() {
307
+ while (this.messageQueue.length > 0 && this.isConnected) {
308
+ const message = this.messageQueue.shift();
309
+ if (message) this.sendMessage(message);
310
+ }
311
+ }
312
+
313
+ private attemptReconnect() {
314
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
315
+ console.error("Max reconnection attempts reached");
316
+ return;
317
+ }
318
+
319
+ this.reconnectAttempts++;
320
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
321
+
322
+ console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
323
+
324
+ if (this.reconnectTimeout) {
325
+ clearTimeout(this.reconnectTimeout);
326
+ }
327
+
328
+ this.reconnectTimeout = setTimeout(() => {
329
+ this.reconnectTimeout = null;
330
+ this.initWebSocket();
331
+ }, delay);
332
+ }
333
+
334
+ private handleWebSocketMessage(message: WebSocketMessage) {
335
+ const {
336
+ type,
337
+ requestId,
338
+ subscriptionId
339
+ } = message;
340
+
341
+ // Handle responses to pending requests
342
+ if (requestId && this.pendingRequests.has(requestId)) {
343
+ const {
344
+ resolve,
345
+ reject
346
+ } = this.pendingRequests.get(requestId)!;
347
+ this.pendingRequests.delete(requestId);
348
+
349
+ if (type === "ERROR" || type === "AUTH_ERROR" || message.error) {
350
+ const { errorMessage, errorCode } = extractMessageError(message);
351
+ reject(new ApiError(errorMessage, errorMessage, errorCode));
352
+ } else {
353
+ resolve(message.payload || message);
354
+ }
355
+ return;
356
+ }
357
+
358
+ // Handle subscription updates for collection subscriptions
359
+ if (subscriptionId && type === "collection_update") {
360
+ const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
361
+ if (subscriptionKey) {
362
+ const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
363
+ if (collectionSub) {
364
+ const incomingEntities = (message.entities || []).map((e: Entity) => rehydrateEntity(e));
365
+
366
+ // Structural merge: preserve cached entity references for entities
367
+ // whose values haven't changed. This prevents downstream React components
368
+ // from re-rendering (VirtualTableCell uses deepEqual on rowData —
369
+ // same reference = instant true, avoiding expensive deep comparison).
370
+ const entities = this.mergeEntities(collectionSub.latestData, incomingEntities);
371
+
372
+ // Cache the latest data with optimizations
373
+ collectionSub.latestData = entities;
374
+ collectionSub.lastUpdated = Date.now();
375
+ collectionSub.isInitialDataReceived = true;
376
+
377
+ // Notify all callbacks for this subscription
378
+ collectionSub.callbacks.forEach(callback => {
379
+ try {
380
+ callback.onUpdate(entities);
381
+ } catch (error) {
382
+ console.error("Error in collection subscription callback:", error);
383
+ if (callback.onError) {
384
+ callback.onError(error instanceof Error ? error : new Error(String(error)));
385
+ }
386
+ }
387
+ });
388
+ return;
389
+ }
390
+ }
391
+ }
392
+
393
+ // Handle instant entity-level patches for collection subscriptions.
394
+ // These arrive before the full refetch and give immediate cross-tab feedback.
395
+ if (subscriptionId && type === "collection_entity_patch") {
396
+ const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
397
+ if (subscriptionKey) {
398
+ const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
399
+ if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
400
+ const patchEntity = message.entity ? rehydrateEntity(message.entity) : message.entity;
401
+ const patchEntityId = (message as unknown as { entityId: string }).entityId;
402
+ let updated: Entity[];
403
+
404
+ if (patchEntity === null || patchEntity === undefined) {
405
+ // Entity was deleted — remove it from the cached list
406
+ updated = collectionSub.latestData.filter(e => String(e.id) !== String(patchEntityId));
407
+ } else {
408
+ // Entity was created or updated — merge into the cached list
409
+ const idx = collectionSub.latestData.findIndex(e => String(e.id) === String(patchEntity.id));
410
+ if (idx >= 0) {
411
+ // Update in place (preserve array position)
412
+ updated = [...collectionSub.latestData];
413
+ updated[idx] = patchEntity;
414
+ } else {
415
+ // New entity — prepend (most recently created entities first)
416
+ updated = [patchEntity, ...collectionSub.latestData];
417
+ }
418
+ }
419
+
420
+ collectionSub.latestData = updated;
421
+ collectionSub.lastUpdated = Date.now();
422
+
423
+ // Fire all callbacks with the patched data
424
+ collectionSub.callbacks.forEach(callback => {
425
+ try {
426
+ callback.onUpdate(updated);
427
+ } catch (error) {
428
+ console.error("Error in collection patch callback:", error);
429
+ if (callback.onError) {
430
+ callback.onError(error instanceof Error ? error : new Error(String(error)));
431
+ }
432
+ }
433
+ });
434
+ return;
435
+ }
436
+ }
437
+ }
438
+
439
+ // Handle subscription updates for entity subscriptions
440
+ if (subscriptionId && type === "entity_update") {
441
+ const subscriptionKey = this.backendToEntityKey.get(subscriptionId);
442
+ if (subscriptionKey) {
443
+ const entitySub = this.entitySubscriptions.get(subscriptionKey);
444
+ if (entitySub) {
445
+ const entity = message.entity ? rehydrateEntity(message.entity) : null;
446
+ // Cache the latest data with optimizations
447
+ entitySub.latestData = entity;
448
+ entitySub.lastUpdated = Date.now();
449
+ entitySub.isInitialDataReceived = true;
450
+
451
+ // Notify all callbacks for this subscription
452
+ entitySub.callbacks.forEach(callback => {
453
+ try {
454
+ callback.onUpdate(entity);
455
+ } catch (error) {
456
+ console.error("Error in entity subscription callback:", error);
457
+ if (callback.onError) {
458
+ callback.onError(error instanceof Error ? error : new Error(String(error)));
459
+ }
460
+ }
461
+ });
462
+ return;
463
+ }
464
+ }
465
+ }
466
+
467
+ // Handle subscription errors
468
+ if (subscriptionId && (type === "ERROR" || message.error)) {
469
+ const collectionKey = this.backendToCollectionKey.get(subscriptionId);
470
+ if (collectionKey) {
471
+ const collectionSub = this.collectionSubscriptions.get(collectionKey);
472
+ if (collectionSub) {
473
+ const { errorMessage, errorCode } = extractMessageError(message);
474
+ const error = new ApiError(errorMessage, errorMessage, errorCode);
475
+ collectionSub.callbacks.forEach(callback => {
476
+ if (callback.onError) {
477
+ callback.onError(error);
478
+ }
479
+ });
480
+ return;
481
+ }
482
+ }
483
+
484
+ const entityKey = this.backendToEntityKey.get(subscriptionId);
485
+ if (entityKey) {
486
+ const entitySub = this.entitySubscriptions.get(entityKey);
487
+ if (entitySub) {
488
+ const { errorMessage, errorCode } = extractMessageError(message);
489
+ const error = new ApiError(errorMessage, errorMessage, errorCode);
490
+ entitySub.callbacks.forEach(callback => {
491
+ if (callback.onError) {
492
+ callback.onError(error);
493
+ }
494
+ });
495
+ return;
496
+ }
497
+ }
498
+ }
499
+
500
+ // Legacy subscription handling (for backward compatibility)
501
+ if (subscriptionId && this.subscriptions.has(subscriptionId)) {
502
+ const callback = this.subscriptions.get(subscriptionId);
503
+ if (!callback) {
504
+ throw new Error(`Subscription callback not found for subscriptionId: ${subscriptionId}`);
505
+ }
506
+ if (message.type === "ERROR" || message.error) {
507
+ if (callback.onError) {
508
+ const { errorMessage, errorCode } = extractMessageError(message);
509
+ callback.onError(new ApiError(errorMessage, errorMessage, errorCode));
510
+ }
511
+ } else {
512
+ callback.onUpdate(message);
513
+ }
514
+ }
515
+ }
516
+
517
+ private async ensureAuthenticated(retryCount = 3): Promise<void> {
518
+ // If already authenticated or no token getter, skip
519
+ if (this.isAuthenticated || !this.getAuthToken) return;
520
+
521
+ // If auth is in progress, wait for it
522
+ if (this.authPromise) {
523
+ await this.authPromise;
524
+ return;
525
+ }
526
+
527
+ // Try to authenticate with retries
528
+ let lastError: unknown = null;
529
+
530
+ for (let attempt = 0; attempt < retryCount; attempt++) {
531
+ try {
532
+ const token = await this.getAuthToken();
533
+ if (!token) throw new Error("user not logged in");
534
+ this.authPromise = this.authenticate(token);
535
+ await this.authPromise;
536
+ this.authPromise = null;
537
+ console.log("WebSocket authenticated on demand");
538
+ return; // Success
539
+ } catch (error: unknown) {
540
+ this.authPromise = null;
541
+ lastError = error;
542
+
543
+ const errMsg = error instanceof Error ? error.message : String(error);
544
+ // "not logged in" / "Session expired" are definitive - don't retry
545
+ if (errMsg.includes("not logged in") || errMsg.includes("Session expired")) {
546
+ console.warn("WebSocket auth failed: user not logged in");
547
+ throw error;
548
+ }
549
+
550
+ // "still loading" is transient - retry with backoff (auth controller
551
+ // is restoring tokens from localStorage; it will resolve shortly)
552
+ if (errMsg.includes("still loading")) {
553
+ if (attempt < retryCount - 1) {
554
+ const delay = Math.min(500 * (attempt + 1), 2000);
555
+ await new Promise(resolve => setTimeout(resolve, delay));
556
+ continue;
557
+ }
558
+ }
559
+
560
+ // For other errors, retry with backoff
561
+ if (attempt < retryCount - 1) {
562
+ const delay = Math.min(1000 * (attempt + 1), 3000);
563
+ console.log(`WebSocket auth attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
564
+ await new Promise(resolve => setTimeout(resolve, delay));
565
+ }
566
+ }
567
+ }
568
+
569
+ console.warn("WebSocket on-demand auth failed after retries:", lastError);
570
+ throw lastError;
571
+ }
572
+
573
+ /**
574
+ * Force re-authentication (call after token refresh)
575
+ */
576
+ async reauthenticate(): Promise<void> {
577
+ if (!this.getAuthToken) return;
578
+
579
+ this.isAuthenticated = false;
580
+ try {
581
+ const token = await this.getAuthToken();
582
+ await this.authenticate(token);
583
+ console.log("WebSocket reauthenticated successfully");
584
+ } catch (error) {
585
+ console.error("WebSocket reauthentication failed:", error);
586
+ throw error;
587
+ }
588
+ }
589
+
590
+ private sendMessage(message: Record<string, unknown>): Promise<unknown> {
591
+ // If already has a requestId (re-sending from queue), use the stored promise handlers
592
+ const queuedMsg = message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void };
593
+ if (queuedMsg._queuedResolve && queuedMsg._queuedReject) {
594
+ return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
595
+ }
596
+
597
+ if (!this.isConnected || !this.ws) {
598
+ // Queue the message and return a promise that will be resolved when actually sent
599
+ return new Promise<unknown>((resolve, reject) => {
600
+ const queueable = message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void };
601
+ queueable._queuedResolve = resolve;
602
+ queueable._queuedReject = reject;
603
+ this.messageQueue.push(message);
604
+ });
605
+ }
606
+
607
+ return new Promise<unknown>((resolve, reject) => {
608
+ this.doSendMessage(message, resolve, reject);
609
+ });
610
+ }
611
+
612
+ private async doSendMessage(message: Record<string, unknown>, resolve: (value: unknown) => void, reject: (error: Error) => void): Promise<void> {
613
+ // Ensure authenticated before sending non-auth messages
614
+ if (message.type !== "AUTHENTICATE" && this.getAuthToken && !this.isAuthenticated) {
615
+ try {
616
+ await this.ensureAuthenticated();
617
+ } catch (error: unknown) {
618
+ const errorMessage = error instanceof Error ? error.message : "Authentication required";
619
+ reject(new ApiError(errorMessage, errorMessage));
620
+ return;
621
+ }
622
+ }
623
+
624
+ const requestId = (message.requestId as string) || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
625
+ message.requestId = requestId;
626
+
627
+ if (!this.pendingRequests.has(requestId)) {
628
+ this.pendingRequests.set(requestId, {
629
+ resolve,
630
+ reject,
631
+ message: message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void }
632
+ });
633
+ }
634
+
635
+ try {
636
+ this.ws!.send(JSON.stringify(message));
637
+ } catch (error) {
638
+ this.pendingRequests.delete(requestId);
639
+ reject(new ApiError("Failed to send message", error instanceof Error ? error.message : "Unknown error"));
640
+ }
641
+ }
642
+
643
+ // Data source methods
644
+ async fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Entity<M>[]> {
645
+ const response = await this.sendMessage({
646
+ type: "FETCH_COLLECTION",
647
+ payload: props
648
+ }) as { entities?: Entity<M>[] };
649
+ return (response.entities || []).map(e => rehydrateEntity(e));
650
+ }
651
+
652
+ async fetchEntity<M extends Record<string, unknown>>(props: FetchEntityProps<M>): Promise<Entity<M> | undefined> {
653
+ const response = await this.sendMessage({
654
+ type: "FETCH_ENTITY",
655
+ payload: props
656
+ }) as { entity?: Entity<M> };
657
+ return response.entity ? rehydrateEntity(response.entity) : undefined;
658
+ }
659
+
660
+ async saveEntity<M extends Record<string, unknown>>(props: SaveEntityProps<M>): Promise<Entity<M>> {
661
+ const response = await this.sendMessage({
662
+ type: "SAVE_ENTITY",
663
+ payload: props
664
+ }) as { entity: Entity<M> };
665
+ return rehydrateEntity(response.entity);
666
+ }
667
+
668
+ async deleteEntity<M extends Record<string, unknown>>(props: DeleteEntityProps<M>): Promise<void> {
669
+ await this.sendMessage({
670
+ type: "DELETE_ENTITY",
671
+ payload: props
672
+ });
673
+ }
674
+
675
+ async executeSql(sql: string, options?: { database?: string, role?: string }): Promise<Record<string, unknown>[]> {
676
+ const response = await this.sendMessage({
677
+ type: "EXECUTE_SQL",
678
+ payload: { sql,
679
+ options }
680
+ }) as { result?: Record<string, unknown>[] };
681
+ return response.result || [];
682
+ }
683
+
684
+ async fetchAvailableDatabases(): Promise<string[]> {
685
+ const response = await this.sendMessage({
686
+ type: "FETCH_DATABASES",
687
+ payload: {}
688
+ }) as { databases?: string[] };
689
+ return response.databases || [];
690
+ }
691
+
692
+ async fetchAvailableRoles(): Promise<string[]> {
693
+ const response = await this.sendMessage({
694
+ type: "FETCH_ROLES"
695
+ }) as { roles?: string[] };
696
+ return response.roles || [];
697
+ }
698
+
699
+ async fetchCurrentDatabase(): Promise<string | undefined> {
700
+ const response = await this.sendMessage({
701
+ type: "FETCH_CURRENT_DATABASE"
702
+ }) as { database?: string };
703
+ return response.database;
704
+ }
705
+
706
+ async checkUniqueField(path: string, name: string, value: unknown, entityId?: string, collection?: EntityCollection): Promise<boolean> {
707
+ const response = await this.sendMessage({
708
+ type: "CHECK_UNIQUE_FIELD",
709
+ payload: {
710
+ path,
711
+ name,
712
+ value,
713
+ entityId,
714
+ collection
715
+ }
716
+ }) as { isUnique: boolean };
717
+ return response.isUnique;
718
+ }
719
+
720
+ async countEntities<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number> {
721
+ const response = await this.sendMessage({
722
+ type: "COUNT_ENTITIES",
723
+ payload: props
724
+ }) as { count: number };
725
+ return response.count;
726
+ }
727
+
728
+ async fetchUnmappedTables(mappedPaths?: string[]): Promise<string[]> {
729
+ const response = await this.sendMessage({
730
+ type: "FETCH_UNMAPPED_TABLES",
731
+ payload: { mappedPaths }
732
+ }) as { tables?: string[] };
733
+ return response.tables || [];
734
+ }
735
+
736
+ async fetchTableMetadata(tableName: string): Promise<TableMetadata> {
737
+ const response = await this.sendMessage({
738
+ type: "FETCH_TABLE_METADATA",
739
+ payload: { tableName }
740
+ }) as { metadata?: TableMetadata };
741
+
742
+ return response.metadata || ({ columns: [],
743
+ foreignKeys: [],
744
+ junctions: [],
745
+ policies: [] } as TableMetadata);
746
+ }
747
+
748
+ async createBranch(name: string, options?: { source?: string }): Promise<BranchInfo> {
749
+ const response = await this.sendMessage({
750
+ type: "CREATE_BRANCH",
751
+ payload: { name,
752
+ options }
753
+ }) as { branch: BranchInfo };
754
+ return response.branch;
755
+ }
756
+
757
+ async deleteBranch(name: string): Promise<void> {
758
+ await this.sendMessage({
759
+ type: "DELETE_BRANCH",
760
+ payload: { name }
761
+ });
762
+ }
763
+
764
+ async listBranches(): Promise<BranchInfo[]> {
765
+ const response = await this.sendMessage({
766
+ type: "LIST_BRANCHES",
767
+ payload: {}
768
+ }) as { branches?: BranchInfo[] };
769
+ return response.branches || [];
770
+ }
771
+
772
+ /**
773
+ * Recursively compare two values for structural equality.
774
+ * Handles primitives, null, undefined, Date, RegExp, arrays, and plain objects.
775
+ */
776
+ private deepEqual(a: unknown, b: unknown): boolean {
777
+ // Same reference or same primitive
778
+ if (a === b) return true;
779
+
780
+ // Handle null/undefined
781
+ if (a === null || b === null || a === undefined || b === undefined) return false;
782
+
783
+ // Different types
784
+ if (typeof a !== typeof b) return false;
785
+
786
+ // Non-object primitives (number, string, boolean, bigint, symbol)
787
+ // that weren't caught by === above (e.g. NaN !== NaN)
788
+ if (typeof a !== "object") return false;
789
+
790
+ // Date comparison
791
+ if (a instanceof Date && b instanceof Date) {
792
+ return a.getTime() === b.getTime();
793
+ }
794
+ if (a instanceof Date || b instanceof Date) return false;
795
+
796
+ // RegExp comparison
797
+ if (a instanceof RegExp && b instanceof RegExp) {
798
+ return a.source === b.source && a.flags === b.flags;
799
+ }
800
+ if (a instanceof RegExp || b instanceof RegExp) return false;
801
+
802
+ // Array comparison
803
+ const aIsArray = Array.isArray(a);
804
+ const bIsArray = Array.isArray(b);
805
+ if (aIsArray !== bIsArray) return false;
806
+
807
+ if (aIsArray && bIsArray) {
808
+ if (a.length !== b.length) return false;
809
+ for (let i = 0; i < a.length; i++) {
810
+ if (!this.deepEqual(a[i], b[i])) return false;
811
+ }
812
+ return true;
813
+ }
814
+
815
+ // Plain object comparison
816
+ const aObj = a as Record<string, unknown>;
817
+ const bObj = b as Record<string, unknown>;
818
+ const aKeys = Object.keys(aObj);
819
+ const bKeys = Object.keys(bObj);
820
+
821
+ if (aKeys.length !== bKeys.length) return false;
822
+
823
+ for (const key of aKeys) {
824
+ if (!Object.prototype.hasOwnProperty.call(bObj, key)) return false;
825
+ if (!this.deepEqual(aObj[key], bObj[key])) return false;
826
+ }
827
+
828
+ return true;
829
+ }
830
+
831
+ private normalizeForComparison(val: unknown): unknown {
832
+ if (!val) return val;
833
+
834
+ if (Array.isArray(val)) {
835
+ return val.map(item => this.normalizeForComparison(item));
836
+ }
837
+
838
+ if (typeof val === "object") {
839
+ if (val instanceof Date) return val;
840
+ if (val instanceof RegExp) return val;
841
+
842
+ const obj = val as Record<string, unknown>;
843
+ if (obj.__type === "relation") {
844
+ const { data, ...rest } = obj;
845
+ return rest;
846
+ }
847
+
848
+ const result: Record<string, unknown> = {};
849
+ for (const [k, v] of Object.entries(obj)) {
850
+ result[k] = this.normalizeForComparison(v);
851
+ }
852
+ return result;
853
+ }
854
+
855
+ return val;
856
+ }
857
+
858
+ /**
859
+ * Merge incoming entities with cached data, preserving cached references
860
+ * for entities whose values haven't changed. This avoids unnecessary
861
+ * React re-renders when the server refetches all entities but most
862
+ * haven't actually changed.
863
+ */
864
+ private mergeEntities(cached: Entity[] | undefined, incoming: Entity[]): Entity[] {
865
+ if (!cached || cached.length === 0) return incoming;
866
+
867
+ // Build a lookup from cached entities by ID for O(1) access
868
+ const cachedById = new Map<string | number, Entity>();
869
+ for (const entity of cached) {
870
+ cachedById.set(entity.id, entity);
871
+ }
872
+
873
+ return incoming.map(incomingEntity => {
874
+ const cachedEntity = cachedById.get(incomingEntity.id);
875
+ if (!cachedEntity) return incomingEntity;
876
+
877
+ if (cachedEntity.path === incomingEntity.path) {
878
+ const normCached = this.normalizeForComparison(cachedEntity.values) as Record<string, unknown>;
879
+ const normIncoming = this.normalizeForComparison(incomingEntity.values) as Record<string, unknown>;
880
+
881
+ if (this.deepEqual(normCached, normIncoming)) {
882
+ return cachedEntity;
883
+ } else {
884
+ // Deep debug: Why did it fail? Let's check which exact property differs
885
+ // so the user can see it in their browser console if flashing still occurs.
886
+ const mismatches: Record<string, { cached: unknown, incoming: unknown }> = {};
887
+ const allKeys = new Set([...Object.keys(normCached), ...Object.keys(normIncoming)]);
888
+ for (const key of allKeys) {
889
+ if (!this.deepEqual(normCached[key], normIncoming[key])) {
890
+ mismatches[key] = { cached: normCached[key],
891
+ incoming: normIncoming[key] };
892
+ }
893
+ }
894
+ console.log(`[RebaseWS] Row ${incomingEntity.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
895
+ }
896
+ }
897
+ return incomingEntity;
898
+ });
899
+ }
900
+
901
+ // Subscription methods
902
+ listenCollection<M extends Record<string, unknown>>(
903
+ props: FetchCollectionProps<M>,
904
+ onUpdate: (entities: Entity[]) => void,
905
+ onError?: (error: Error) => void
906
+ ): () => void {
907
+ const subscriptionKey = this.createCollectionSubscriptionKey(props);
908
+ const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
909
+
910
+ // Check if we already have a subscription for these exact parameters
911
+ const existingSubscription = this.collectionSubscriptions.get(subscriptionKey);
912
+
913
+ if (existingSubscription) {
914
+ // Reuse existing subscription - just add the new callback
915
+ const callbackMap = existingSubscription.callbacks as Map<string, {
916
+ onUpdate: (entities: Entity[]) => void;
917
+ onError?: (error: Error) => void;
918
+ }>;
919
+ callbackMap.set(callbackId, { onUpdate,
920
+ onError });
921
+
922
+ // Immediately fire the callback with cached data if available
923
+ if (existingSubscription.latestData !== undefined && existingSubscription.isInitialDataReceived) {
924
+ try {
925
+ onUpdate(existingSubscription.latestData);
926
+ } catch (error) {
927
+ console.error("Error in collection subscription callback:", error);
928
+ if (onError) {
929
+ onError(error instanceof Error ? error : new Error(String(error)));
930
+ }
931
+ }
932
+ }
933
+
934
+ // Return unsubscribe function
935
+ return () => {
936
+ callbackMap.delete(callbackId);
937
+ if (callbackMap.size === 0) {
938
+ // No more callbacks, unsubscribe from backend
939
+ this.collectionSubscriptions.delete(subscriptionKey);
940
+ this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
941
+ if (this.isConnected && this.ws) {
942
+ this.sendMessage({
943
+ type: "unsubscribe",
944
+ payload: { subscriptionId: existingSubscription.backendSubscriptionId }
945
+ }).catch(console.error);
946
+ }
947
+ }
948
+ };
949
+ }
950
+
951
+ // Create new subscription
952
+ const backendSubscriptionId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
953
+ const callbackMap = new Map<string, {
954
+ onUpdate: (entities: Entity[]) => void;
955
+ onError?: (error: Error) => void;
956
+ }>();
957
+ callbackMap.set(callbackId, { onUpdate,
958
+ onError });
959
+
960
+ this.collectionSubscriptions.set(subscriptionKey, {
961
+ backendSubscriptionId,
962
+ callbacks: callbackMap,
963
+ props
964
+ });
965
+
966
+ // Add reverse lookup
967
+ this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
968
+
969
+ // Send subscription request to backend
970
+ this.sendMessage({
971
+ type: "subscribe_collection",
972
+ payload: {
973
+ ...props,
974
+ subscriptionId: backendSubscriptionId
975
+ }
976
+ }).catch(error => {
977
+ if (onError) onError(error);
978
+ });
979
+
980
+ // Return unsubscribe function
981
+ return () => {
982
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
983
+ if (subscription) {
984
+ const callbacks = subscription.callbacks;
985
+ callbacks.delete(callbackId);
986
+ if (callbacks.size === 0) {
987
+ this.collectionSubscriptions.delete(subscriptionKey);
988
+ this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
989
+ if (this.isConnected && this.ws) {
990
+ this.sendMessage({
991
+ type: "unsubscribe",
992
+ payload: { subscriptionId: subscription.backendSubscriptionId }
993
+ }).catch(console.error);
994
+ }
995
+ }
996
+ }
997
+ };
998
+ }
999
+
1000
+ listenEntity<M extends Record<string, unknown>>(
1001
+ props: FetchEntityProps<M>,
1002
+ onUpdate: (entity: Entity | null) => void,
1003
+ onError?: (error: Error) => void
1004
+ ): () => void {
1005
+ const subscriptionKey = this.createEntitySubscriptionKey(props);
1006
+ const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1007
+
1008
+ // Check if we already have a subscription for these exact parameters
1009
+ const existingSubscription = this.entitySubscriptions.get(subscriptionKey);
1010
+
1011
+ if (existingSubscription) {
1012
+ // Reuse existing subscription - just add the new callback
1013
+ const callbackMap = existingSubscription.callbacks as Map<string, {
1014
+ onUpdate: (entity: Entity | null) => void;
1015
+ onError?: (error: Error) => void;
1016
+ }>;
1017
+ callbackMap.set(callbackId, { onUpdate,
1018
+ onError });
1019
+
1020
+ // Immediately fire the callback with cached data if available
1021
+ if (existingSubscription.latestData !== undefined && existingSubscription.isInitialDataReceived) {
1022
+ try {
1023
+ onUpdate(existingSubscription.latestData);
1024
+ } catch (error) {
1025
+ console.error("Error in entity subscription callback:", error);
1026
+ if (onError) {
1027
+ onError(error instanceof Error ? error : new Error(String(error)));
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ // Return unsubscribe function
1033
+ return () => {
1034
+ callbackMap.delete(callbackId);
1035
+ if (callbackMap.size === 0) {
1036
+ // No more callbacks, unsubscribe from backend
1037
+ this.entitySubscriptions.delete(subscriptionKey);
1038
+ this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
1039
+ if (this.isConnected && this.ws) {
1040
+ this.sendMessage({
1041
+ type: "unsubscribe",
1042
+ payload: { subscriptionId: existingSubscription.backendSubscriptionId }
1043
+ }).catch(console.error);
1044
+ }
1045
+ }
1046
+ };
1047
+ }
1048
+
1049
+ // Create new subscription
1050
+ const backendSubscriptionId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1051
+ const callbackMap = new Map<string, {
1052
+ onUpdate: (entity: Entity | null) => void;
1053
+ onError?: (error: Error) => void;
1054
+ }>();
1055
+ callbackMap.set(callbackId, { onUpdate,
1056
+ onError });
1057
+
1058
+ this.entitySubscriptions.set(subscriptionKey, {
1059
+ backendSubscriptionId,
1060
+ callbacks: callbackMap,
1061
+ props
1062
+ });
1063
+
1064
+ // Add reverse lookup
1065
+ this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
1066
+
1067
+ // Send subscription request to backend
1068
+ this.sendMessage({
1069
+ type: "subscribe_entity",
1070
+ payload: {
1071
+ ...props,
1072
+ subscriptionId: backendSubscriptionId
1073
+ }
1074
+ }).catch(error => {
1075
+ if (onError) onError(error);
1076
+ });
1077
+
1078
+ // Return unsubscribe function
1079
+ return () => {
1080
+ const subscription = this.entitySubscriptions.get(subscriptionKey);
1081
+ if (subscription) {
1082
+ const callbacks = subscription.callbacks;
1083
+ callbacks.delete(callbackId);
1084
+ if (callbacks.size === 0) {
1085
+ this.entitySubscriptions.delete(subscriptionKey);
1086
+ this.backendToEntityKey.delete(subscription.backendSubscriptionId);
1087
+ if (this.isConnected && this.ws) {
1088
+ this.sendMessage({
1089
+ type: "unsubscribe",
1090
+ payload: { subscriptionId: subscription.backendSubscriptionId }
1091
+ }).catch(console.error);
1092
+ }
1093
+ }
1094
+ }
1095
+ };
1096
+ }
1097
+
1098
+ /**
1099
+ * Re-send all active subscriptions to the backend after a reconnect.
1100
+ * The server wipes subscription state when a client disconnects, so
1101
+ * we need to re-register everything to resume receiving updates.
1102
+ */
1103
+ private resubscribeAll(): void {
1104
+ console.log(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.entitySubscriptions.size} entity(ies)`);
1105
+
1106
+ // Re-subscribe collection subscriptions
1107
+ for (const [key, sub] of this.collectionSubscriptions.entries()) {
1108
+ // Generate a fresh backend ID since the old one is no longer valid on the server
1109
+ const oldBackendId = sub.backendSubscriptionId;
1110
+ const newBackendId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1111
+ sub.backendSubscriptionId = newBackendId;
1112
+
1113
+ // Update reverse lookup
1114
+ this.backendToCollectionKey.delete(oldBackendId);
1115
+ this.backendToCollectionKey.set(newBackendId, key);
1116
+
1117
+ this.sendMessage({
1118
+ type: "subscribe_collection",
1119
+ payload: {
1120
+ ...sub.props,
1121
+ subscriptionId: newBackendId
1122
+ }
1123
+ }).catch(error => {
1124
+ console.error("[WS] Failed to re-subscribe collection:", key, error);
1125
+ });
1126
+ }
1127
+
1128
+ // Re-subscribe entity subscriptions
1129
+ for (const [key, sub] of this.entitySubscriptions.entries()) {
1130
+ const oldBackendId = sub.backendSubscriptionId;
1131
+ const newBackendId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1132
+ sub.backendSubscriptionId = newBackendId;
1133
+
1134
+ this.backendToEntityKey.delete(oldBackendId);
1135
+ this.backendToEntityKey.set(newBackendId, key);
1136
+
1137
+ this.sendMessage({
1138
+ type: "subscribe_entity",
1139
+ payload: {
1140
+ ...sub.props,
1141
+ subscriptionId: newBackendId
1142
+ }
1143
+ }).catch(error => {
1144
+ console.error("[WS] Failed to re-subscribe entity:", key, error);
1145
+ });
1146
+ }
1147
+ }
1148
+
1149
+ private createCollectionSubscriptionKey(props: FetchCollectionProps): string {
1150
+ // Create a deterministic key based on subscription parameters
1151
+ const key = {
1152
+ path: props.path,
1153
+ filter: props.filter,
1154
+ limit: props.limit,
1155
+ startAfter: props.startAfter,
1156
+ orderBy: props.orderBy,
1157
+ order: props.order,
1158
+ searchString: props.searchString,
1159
+ collection: props.collection?.name
1160
+ };
1161
+ // Use replacer function (not array) to sort keys at all levels for deterministic output
1162
+ return JSON.stringify(key, (_, value) => {
1163
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1164
+ return Object.keys(value).sort().reduce((sorted: Record<string, unknown>, k) => {
1165
+ sorted[k] = value[k];
1166
+ return sorted;
1167
+ }, {});
1168
+ }
1169
+ return value;
1170
+ });
1171
+ }
1172
+
1173
+ private createEntitySubscriptionKey(props: FetchEntityProps): string {
1174
+ return `${props.path}|${props.entityId}`;
1175
+ }
1176
+ }