@rebasepro/client 0.0.1-canary.4d4fb3e

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