@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.
- package/LICENSE +6 -0
- package/dist/admin.d.ts +94 -0
- package/dist/auth.d.ts +95 -0
- package/dist/collection.d.ts +19 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.es.js +1882 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +1886 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/query_builder.d.ts +53 -0
- package/dist/storage.d.ts +3 -0
- package/dist/transport.d.ts +33 -0
- package/dist/websocket.d.ts +93 -0
- package/package.json +79 -0
- package/src/admin.ts +119 -0
- package/src/auth.ts +400 -0
- package/src/collection.ts +198 -0
- package/src/index.ts +154 -0
- package/src/query_builder.ts +126 -0
- package/src/storage.ts +181 -0
- package/src/transport.ts +173 -0
- package/src/websocket.ts +1114 -0
package/src/websocket.ts
ADDED
|
@@ -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
|
+
}
|