@rebasepro/client 0.17.3 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/auth.d.ts +80 -0
- package/dist/functions.d.ts +6 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.es.js +471 -94
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -1
- package/dist/offline.d.ts +23 -1
- package/dist/query-contract.types.d.ts +30 -0
- package/dist/realtime-channel.d.ts +29 -1
- package/dist/sdk_query_builder.d.ts +21 -2
- package/dist/transport.d.ts +24 -0
- package/package.json +28 -15
- package/src/admin.ts +0 -90
- package/src/anonymous-client-guard.test.ts +0 -190
- package/src/api-keys.ts +0 -87
- package/src/auth-listener-errors.test.ts +0 -57
- package/src/auth-refresh-overflow.test.ts +0 -89
- package/src/auth.ts +0 -982
- package/src/backups.ts +0 -40
- package/src/client-close.test.ts +0 -80
- package/src/collection-listen-meta.test.ts +0 -105
- package/src/collection-observe.test.ts +0 -138
- package/src/collection.test.ts +0 -293
- package/src/collection.ts +0 -525
- package/src/cron.test.ts +0 -164
- package/src/cron.ts +0 -62
- package/src/data-proxy.test.ts +0 -183
- package/src/errors.ts +0 -9
- package/src/functions.ts +0 -82
- package/src/index.ts +0 -639
- package/src/like-pattern-redos.test.ts +0 -61
- package/src/offline-codec.ts +0 -79
- package/src/offline-connectivity.test.ts +0 -191
- package/src/offline-connectivity.ts +0 -255
- package/src/offline-idb-store.test.ts +0 -340
- package/src/offline-integration.test.ts +0 -180
- package/src/offline-query.test.ts +0 -431
- package/src/offline-query.ts +0 -529
- package/src/offline-store.ts +0 -357
- package/src/offline-sync-engine.test.ts +0 -857
- package/src/offline.test.ts +0 -897
- package/src/offline.ts +0 -1928
- package/src/query-contract.types.ts +0 -206
- package/src/query_builder.ts +0 -1
- package/src/realtime-channel.test.ts +0 -542
- package/src/realtime-channel.ts +0 -539
- package/src/realtime-concurrent-subscribe.test.ts +0 -102
- package/src/realtime-error-surfacing.test.ts +0 -105
- package/src/realtime-optout.test.ts +0 -279
- package/src/realtime-row-identity.test.ts +0 -254
- package/src/realtime-subscription-key.test.ts +0 -92
- package/src/reviver.ts +0 -39
- package/src/sdk_query_builder.ts +0 -206
- package/src/storage-key-encoding.test.ts +0 -65
- package/src/storage-registry.ts +0 -102
- package/src/storage.ts +0 -253
- package/src/transport-baseurl.test.ts +0 -101
- package/src/transport.ts +0 -505
- package/src/vector-search-listen.test.ts +0 -42
- package/src/vector-search-query.test.ts +0 -55
- package/src/websocket-url.test.ts +0 -97
- package/src/websocket.ts +0 -1837
package/src/websocket.ts
DELETED
|
@@ -1,1837 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DeleteProps,
|
|
3
|
-
CollectionConfig,
|
|
4
|
-
FetchCollectionProps,
|
|
5
|
-
FetchOneProps,
|
|
6
|
-
SaveProps,
|
|
7
|
-
WebSocketMessage,
|
|
8
|
-
WebSocketErrorPayload,
|
|
9
|
-
CollectionUpdateMessage,
|
|
10
|
-
SingleUpdateMessage,
|
|
11
|
-
TableMetadata,
|
|
12
|
-
BranchInfo,
|
|
13
|
-
RebaseApiError
|
|
14
|
-
} from "@rebasepro/types";
|
|
15
|
-
import { buildCompositeId, COMPOSITE_ID_SEPARATOR, type PrimaryKeyInfo } from "@rebasepro/common";
|
|
16
|
-
import { rebaseReviver } from "./reviver";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Extract error message and code from a WebSocket message payload.
|
|
22
|
-
* Handles both `{ error: string }` and `{ error: { message, code } }` shapes.
|
|
23
|
-
*/
|
|
24
|
-
function extractMessageError(message: WebSocketMessage): { errorMessage: string; errorCode?: string } {
|
|
25
|
-
const payload = message.payload as WebSocketErrorPayload | undefined;
|
|
26
|
-
const errPayload = payload?.error;
|
|
27
|
-
const errorMessage = typeof errPayload === "object"
|
|
28
|
-
? errPayload.message
|
|
29
|
-
: payload?.message || (typeof errPayload === "string" ? errPayload : undefined) || message.error || "Unknown error";
|
|
30
|
-
const errorCode = typeof errPayload === "object"
|
|
31
|
-
? errPayload.code
|
|
32
|
-
: payload?.code;
|
|
33
|
-
// Callers treat this as a string (`.toLowerCase()` in isAuthError). A frame
|
|
34
|
-
// carrying a non-string here would throw inside the message handler, where
|
|
35
|
-
// the surrounding try/catch would swallow it — and a subscription error that
|
|
36
|
-
// never reaches its listener is a view stuck loading forever.
|
|
37
|
-
const safeMessage = typeof errorMessage === "string"
|
|
38
|
-
? errorMessage
|
|
39
|
-
: (errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage));
|
|
40
|
-
return { errorMessage: safeMessage,
|
|
41
|
-
errorCode };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface RebaseWebSocketConfig {
|
|
45
|
-
websocketUrl: string;
|
|
46
|
-
/** Optional auth token getter for WebSocket authentication */
|
|
47
|
-
getAuthToken?: () => Promise<string | null>;
|
|
48
|
-
/** Optional WebSocket constructor to override globalThis.WebSocket (e.g. for Node environments) */
|
|
49
|
-
WebSocket?: typeof WebSocket;
|
|
50
|
-
/** Callback to handle unauthorized requests or token expiration (refreshes auth session) */
|
|
51
|
-
onUnauthorized?: () => Promise<boolean>;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Broadcast and presence frames.
|
|
57
|
-
*
|
|
58
|
-
* Fire-and-forget (the server sends no response envelope), and exempt from the
|
|
59
|
-
* client-side auth gate — a public channel is usable without an account.
|
|
60
|
-
*/
|
|
61
|
-
const CHANNEL_MESSAGE_TYPES = new Set([
|
|
62
|
-
"join_channel",
|
|
63
|
-
"leave_channel",
|
|
64
|
-
"broadcast",
|
|
65
|
-
"presence_track",
|
|
66
|
-
"presence_untrack",
|
|
67
|
-
"presence_state",
|
|
68
|
-
// The catch-up request. Like `presence_state`, its answer comes back as a
|
|
69
|
-
// channel-addressed frame rather than a response envelope, so it must not
|
|
70
|
-
// be given a pending request to wait on.
|
|
71
|
-
"channel_history"
|
|
72
|
-
]);
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Low-level realtime WebSocket client.
|
|
76
|
-
*
|
|
77
|
-
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
78
|
-
* manages this internally (exposed as `client.ws`, typed by the minimal
|
|
79
|
-
* `RebaseWebSocket` contract in `@rebasepro/types`). It stays exported from the
|
|
80
|
-
* package root for the same reason it always was — a data-source driver may
|
|
81
|
-
* instantiate it directly — but nothing in this repo does since
|
|
82
|
-
* `@rebasepro/client-postgres` was removed; its surface may change without a
|
|
83
|
-
* major bump.
|
|
84
|
-
*/
|
|
85
|
-
export class RebaseWebSocketClient {
|
|
86
|
-
private websocketUrl: string;
|
|
87
|
-
private ws: WebSocket | null = null;
|
|
88
|
-
public getAuthToken?: () => Promise<string | null>;
|
|
89
|
-
private listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
|
90
|
-
|
|
91
|
-
/** Channel-name → handlers, for broadcast and presence frames. */
|
|
92
|
-
private channelHandlers = new Map<string, Set<(message: Record<string, unknown>) => void>>();
|
|
93
|
-
|
|
94
|
-
/** Set by `close()`. Blocks any later operation from silently redialling. */
|
|
95
|
-
private closedByCaller = false;
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Set when the backoff budget ran out, cleared by anything that earns a
|
|
99
|
-
* fresh one.
|
|
100
|
-
*
|
|
101
|
-
* Unlike {@link closedByCaller} this is not final — nobody *asked* for the
|
|
102
|
-
* socket to stay down. Five attempts with exponential backoff is about a
|
|
103
|
-
* minute, which a laptop lid, a wifi handover or a backend rollout all
|
|
104
|
-
* exceed routinely; treating that as permanent meant realtime silently
|
|
105
|
-
* stopped for the rest of the page's life, with a reload the only cure.
|
|
106
|
-
*/
|
|
107
|
-
private gaveUp = false;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Whether a socket exists at all (open or still opening).
|
|
111
|
-
*
|
|
112
|
-
* Lets callers distinguish "authenticate the live socket" from "there is
|
|
113
|
-
* nothing to authenticate yet", without that question forcing a dial.
|
|
114
|
-
*/
|
|
115
|
-
public get hasSocket(): boolean {
|
|
116
|
-
return this.ws !== null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** So the "no WebSocket in this environment" warning is said once, not per call. */
|
|
120
|
-
private warnedNoWebSocket = false;
|
|
121
|
-
|
|
122
|
-
/** Subscribe to broadcast/presence frames for one channel. */
|
|
123
|
-
public onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void {
|
|
124
|
-
if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, new Set());
|
|
125
|
-
this.channelHandlers.get(channel)!.add(handler);
|
|
126
|
-
return () => {
|
|
127
|
-
const handlers = this.channelHandlers.get(channel);
|
|
128
|
-
if (!handlers) return;
|
|
129
|
-
handlers.delete(handler);
|
|
130
|
-
if (handlers.size === 0) this.channelHandlers.delete(channel);
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Notified after the socket comes back, so channels can re-join. */
|
|
135
|
-
public onReconnect(handler: () => void): () => void {
|
|
136
|
-
return this.on("reconnect", handler);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
public on(event: "connect" | "disconnect" | "reconnect" | "error", cb: (...args: unknown[]) => void) {
|
|
140
|
-
if (!this.listeners.has(event)) {
|
|
141
|
-
this.listeners.set(event, new Set());
|
|
142
|
-
}
|
|
143
|
-
this.listeners.get(event)!.add(cb);
|
|
144
|
-
return () => this.listeners.get(event)!.delete(cb);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
private emit(event: string, ...args: unknown[]) {
|
|
148
|
-
if (this.listeners.has(event)) {
|
|
149
|
-
this.listeners.get(event)!.forEach(cb => cb(...args));
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// New: Subscription deduplication management with optimizations
|
|
154
|
-
private collectionSubscriptions = new Map<string, {
|
|
155
|
-
backendSubscriptionId: string;
|
|
156
|
-
callbacks: Map<string, {
|
|
157
|
-
onUpdate: (rows: Record<string, unknown>[]) => void;
|
|
158
|
-
onError?: (error: Error) => void;
|
|
159
|
-
}>;
|
|
160
|
-
props: FetchCollectionProps;
|
|
161
|
-
latestData?: Record<string, unknown>[]; // Cache the latest flat rows
|
|
162
|
-
lastUpdated?: number; // Timestamp for cache invalidation
|
|
163
|
-
isInitialDataReceived?: boolean; // Track if we got initial data
|
|
164
|
-
/**
|
|
165
|
-
* A `subscribe_collection` frame is on the wire and its initial payload
|
|
166
|
-
* has not arrived yet. Without this, a subscription whose subscribe
|
|
167
|
-
* failed is indistinguishable from one still loading, and every later
|
|
168
|
-
* listener attaches to it and waits forever.
|
|
169
|
-
*/
|
|
170
|
-
subscribeInFlight?: boolean;
|
|
171
|
-
/**
|
|
172
|
-
* Watchdog for the above. `subscribe_collection` expects no response
|
|
173
|
-
* envelope, so it is not covered by `pendingRequests`' timeout — a lost
|
|
174
|
-
* initial payload would otherwise hang the subscription indefinitely.
|
|
175
|
-
*/
|
|
176
|
-
subscribeTimeout?: ReturnType<typeof setTimeout>;
|
|
177
|
-
/**
|
|
178
|
-
* The key columns of this collection, as told by the server on a patch.
|
|
179
|
-
* Rows are columns only, and the SDK holds no collection config, so
|
|
180
|
-
* without this there is nothing to derive an address from.
|
|
181
|
-
*/
|
|
182
|
-
pks?: PrimaryKeyInfo[];
|
|
183
|
-
}>();
|
|
184
|
-
|
|
185
|
-
private singleSubscriptions = new Map<string, {
|
|
186
|
-
backendSubscriptionId: string;
|
|
187
|
-
callbacks: Map<string, {
|
|
188
|
-
onUpdate: (row: Record<string, unknown> | null) => void;
|
|
189
|
-
onError?: (error: Error) => void;
|
|
190
|
-
}>;
|
|
191
|
-
props: FetchOneProps;
|
|
192
|
-
latestData?: Record<string, unknown> | null; // Cache the latest flat row
|
|
193
|
-
lastUpdated?: number; // Timestamp for cache invalidation
|
|
194
|
-
isInitialDataReceived?: boolean; // Track if we got initial data
|
|
195
|
-
/** See the collection subscription counterparts. */
|
|
196
|
-
subscribeInFlight?: boolean;
|
|
197
|
-
subscribeTimeout?: ReturnType<typeof setTimeout>;
|
|
198
|
-
}>();
|
|
199
|
-
|
|
200
|
-
// Maps to quickly find subscription by backend subscription ID
|
|
201
|
-
private backendToCollectionKey = new Map<string, string>();
|
|
202
|
-
private backendToEntityKey = new Map<string, string>();
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
private pendingRequests = new Map<string, {
|
|
206
|
-
resolve: (p: unknown) => void;
|
|
207
|
-
reject: (p: Error) => void;
|
|
208
|
-
message?: Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void }
|
|
209
|
-
}>();
|
|
210
|
-
private reconnectAttempts = 0;
|
|
211
|
-
private maxReconnectAttempts = 5;
|
|
212
|
-
private isConnected = false;
|
|
213
|
-
private messageQueue: Record<string, unknown>[] = [];
|
|
214
|
-
private requestTimeoutMs = 30000;
|
|
215
|
-
private subscriptionTimeoutMs = 30000;
|
|
216
|
-
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
217
|
-
|
|
218
|
-
private isAuthenticated = false;
|
|
219
|
-
private authPromise: Promise<void> | null = null;
|
|
220
|
-
private WebSocketConstructor: typeof WebSocket | undefined;
|
|
221
|
-
public onUnauthorized?: () => Promise<boolean>;
|
|
222
|
-
private refreshInProgress: Promise<boolean> | null = null;
|
|
223
|
-
|
|
224
|
-
constructor(config: RebaseWebSocketConfig) {
|
|
225
|
-
this.websocketUrl = config.websocketUrl;
|
|
226
|
-
this.getAuthToken = config.getAuthToken;
|
|
227
|
-
this.onUnauthorized = config.onUnauthorized;
|
|
228
|
-
this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : undefined);
|
|
229
|
-
|
|
230
|
-
// Deliberately does NOT dial here. Constructing the client is not a
|
|
231
|
-
// statement that the app wants a socket — `createRebaseClient` builds
|
|
232
|
-
// one whenever realtime is not explicitly disabled, so connecting here
|
|
233
|
-
// opened a socket on every page load of every app that merely *might*
|
|
234
|
-
// subscribe later. Anonymous-first apps paid that on every visit, to
|
|
235
|
-
// authenticate with nothing, which left them choosing between "socket
|
|
236
|
-
// on every page load" and "no channels at all".
|
|
237
|
-
//
|
|
238
|
-
// The environment warning is also deferred: an app that never
|
|
239
|
-
// subscribes should say nothing at all. See `ensureConnected`.
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Open the socket if it is not open (or opening) already.
|
|
244
|
-
*
|
|
245
|
-
* Idempotent, synchronous, and safe to call on every operation that needs a
|
|
246
|
-
* live socket — `initWebSocket` already no-ops on an open socket and is
|
|
247
|
-
* re-entrant, since the reconnect path has always called it.
|
|
248
|
-
*/
|
|
249
|
-
public ensureConnected(): void {
|
|
250
|
-
// An explicit `close()` is final. Without this, one queued frame could
|
|
251
|
-
// redial a socket the caller just released and keep a Node process
|
|
252
|
-
// alive forever.
|
|
253
|
-
if (this.closedByCaller) return;
|
|
254
|
-
if (!this.WebSocketConstructor) {
|
|
255
|
-
if (!this.warnedNoWebSocket) {
|
|
256
|
-
this.warnedNoWebSocket = true;
|
|
257
|
-
console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
|
|
258
|
-
}
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
this.installOnlineListener();
|
|
262
|
-
if (this.ws || this.reconnectTimeout) return;
|
|
263
|
-
// A caller asking for a connection is a fresh reason to try, so it also
|
|
264
|
-
// buys a fresh backoff budget. Without this, the first `subscribe`
|
|
265
|
-
// after a give-up would exhaust the counter on attempt one.
|
|
266
|
-
if (this.gaveUp) {
|
|
267
|
-
this.gaveUp = false;
|
|
268
|
-
this.reconnectAttempts = 0;
|
|
269
|
-
}
|
|
270
|
-
this.initWebSocket();
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* The browser says the network is back — the usual reason the budget ran
|
|
275
|
-
* out in the first place. Registered lazily so a Node client, or a page
|
|
276
|
-
* that never subscribes, adds no listener.
|
|
277
|
-
*/
|
|
278
|
-
private installOnlineListener() {
|
|
279
|
-
if (this.onlineListener || typeof window === "undefined" || typeof window.addEventListener !== "function") return;
|
|
280
|
-
this.onlineListener = () => {
|
|
281
|
-
if (this.closedByCaller || !this.gaveUp) return;
|
|
282
|
-
console.debug("Network is back — retrying the realtime connection");
|
|
283
|
-
this.ensureConnected();
|
|
284
|
-
};
|
|
285
|
-
window.addEventListener("online", this.onlineListener);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
private onlineListener: (() => void) | null = null;
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Authenticate the WebSocket connection
|
|
292
|
-
*/
|
|
293
|
-
async authenticate(token: string): Promise<void> {
|
|
294
|
-
return new Promise((resolve, reject) => {
|
|
295
|
-
// Random suffix, like every other request id here. Two auth
|
|
296
|
-
// attempts started in the same millisecond produced the same id,
|
|
297
|
-
// and `pendingRequests` is a Map: the second registration replaced
|
|
298
|
-
// the first, so one caller's promise was never settled either way.
|
|
299
|
-
const requestId = `auth_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
300
|
-
|
|
301
|
-
const timeout = setTimeout(() => {
|
|
302
|
-
this.pendingRequests.delete(requestId);
|
|
303
|
-
// `authPromise` belongs to `ensureAuthenticated`, which clears
|
|
304
|
-
// it when the whole attempt — retries included — settles.
|
|
305
|
-
// Clearing it here let a second attempt start while this one
|
|
306
|
-
// was still retrying.
|
|
307
|
-
reject(new Error("Authentication timeout"));
|
|
308
|
-
}, 30000);
|
|
309
|
-
|
|
310
|
-
this.pendingRequests.set(requestId, {
|
|
311
|
-
resolve: () => {
|
|
312
|
-
clearTimeout(timeout);
|
|
313
|
-
this.isAuthenticated = true;
|
|
314
|
-
resolve();
|
|
315
|
-
},
|
|
316
|
-
reject: (error) => {
|
|
317
|
-
clearTimeout(timeout);
|
|
318
|
-
reject(error);
|
|
319
|
-
}
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
const message = {
|
|
323
|
-
type: "AUTHENTICATE",
|
|
324
|
-
requestId,
|
|
325
|
-
payload: { token }
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
if (!this.isConnected || !this.ws) {
|
|
329
|
-
this.messageQueue.unshift(message); // Auth should be first
|
|
330
|
-
} else {
|
|
331
|
-
this.ws.send(JSON.stringify(message));
|
|
332
|
-
}
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Set the auth token getter function
|
|
338
|
-
*/
|
|
339
|
-
setAuthTokenGetter(getAuthToken: () => Promise<string | null>): void {
|
|
340
|
-
this.getAuthToken = getAuthToken;
|
|
341
|
-
// Auto-authenticate if we are already connected but didn't have the token getter yet
|
|
342
|
-
if (this.isConnected && !this.isAuthenticated && !this.authPromise) {
|
|
343
|
-
console.debug("WebSocket auto-authenticating after token getter set");
|
|
344
|
-
this.getAuthToken().then(token => {
|
|
345
|
-
if (!this.ws) return; // Prevent memory leaks / actions after disconnect
|
|
346
|
-
if (token) {
|
|
347
|
-
this.authenticate(token).catch(e => {
|
|
348
|
-
if (this.ws) console.debug("WebSocket auto-auth skipped:", e?.message || e);
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
}).catch(e => {
|
|
352
|
-
// User not logged in or auth still loading — this is expected,
|
|
353
|
-
// the WebSocket will authenticate on-demand when a request is made.
|
|
354
|
-
if (this.ws) console.debug("WebSocket auto-auth skipped:", e?.message || e);
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Drop the socket.
|
|
361
|
-
*
|
|
362
|
-
* `permanent` distinguishes the two callers. Signing out drops the socket
|
|
363
|
-
* but the client stays usable — a later subscribe should reconnect
|
|
364
|
-
* anonymously. `client.close()` is the caller saying they are done, and
|
|
365
|
-
* must not be undone by a stray queued frame.
|
|
366
|
-
*/
|
|
367
|
-
public disconnect(permanent = false): void {
|
|
368
|
-
if (permanent) this.closedByCaller = true;
|
|
369
|
-
if (permanent && this.onlineListener && typeof window !== "undefined") {
|
|
370
|
-
window.removeEventListener("online", this.onlineListener);
|
|
371
|
-
this.onlineListener = null;
|
|
372
|
-
}
|
|
373
|
-
this.isAuthenticated = false;
|
|
374
|
-
this.authPromise = null;
|
|
375
|
-
if (this.reconnectTimeout) {
|
|
376
|
-
clearTimeout(this.reconnectTimeout);
|
|
377
|
-
this.reconnectTimeout = null;
|
|
378
|
-
}
|
|
379
|
-
if (this.ws) {
|
|
380
|
-
this.ws.onclose = null; // Prevent reconnect on explicit disconnect
|
|
381
|
-
this.ws.onerror = null; // Prevent errors on explicit disconnect
|
|
382
|
-
this.ws.onopen = null;
|
|
383
|
-
this.ws.onmessage = null;
|
|
384
|
-
this.ws.close();
|
|
385
|
-
this.ws = null;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// Initialize WebSocket connection
|
|
390
|
-
private initWebSocket() {
|
|
391
|
-
if (!this.WebSocketConstructor) return;
|
|
392
|
-
if (this.ws?.readyState === this.WebSocketConstructor.OPEN) return;
|
|
393
|
-
|
|
394
|
-
// Guard against race condition: if a previous socket is still connecting, tear it down
|
|
395
|
-
if (this.ws) {
|
|
396
|
-
this.ws.onclose = null;
|
|
397
|
-
this.ws.close();
|
|
398
|
-
this.ws = null;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
try {
|
|
402
|
-
// Captured so each handler can tell "my socket" from a later one:
|
|
403
|
-
// a close arriving after a redial must not clear the new socket.
|
|
404
|
-
const socket = new this.WebSocketConstructor(this.websocketUrl);
|
|
405
|
-
this.ws = socket;
|
|
406
|
-
|
|
407
|
-
this.ws!.onopen = async () => {
|
|
408
|
-
console.debug("Connected to PostgreSQL backend");
|
|
409
|
-
const wasReconnect = this.reconnectAttempts > 0;
|
|
410
|
-
this.isConnected = true;
|
|
411
|
-
this.reconnectAttempts = 0;
|
|
412
|
-
|
|
413
|
-
// Auto-authenticate if token getter is available
|
|
414
|
-
if (this.getAuthToken && !this.isAuthenticated) {
|
|
415
|
-
try {
|
|
416
|
-
const token = await this.getAuthToken();
|
|
417
|
-
if (token) {
|
|
418
|
-
await this.authenticate(token);
|
|
419
|
-
console.debug("WebSocket auto-authenticated");
|
|
420
|
-
}
|
|
421
|
-
} catch (error) {
|
|
422
|
-
// User not logged in or auth still loading — this is expected.
|
|
423
|
-
// Authentication will happen on-demand when the user logs in.
|
|
424
|
-
console.debug("WebSocket connected without auth:", (error as Error)?.message || error);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
this.emit(wasReconnect ? "reconnect" : "connect");
|
|
429
|
-
this.processMessageQueue();
|
|
430
|
-
|
|
431
|
-
// Re-subscribe all active subscriptions after reconnect.
|
|
432
|
-
// The server-side subscription state was lost when the connection dropped,
|
|
433
|
-
// so we need to re-register every active subscription.
|
|
434
|
-
if (wasReconnect) {
|
|
435
|
-
this.resubscribeAll();
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// Subscribes requested while offline have just gone out; they
|
|
439
|
-
// could not be watchdogged at request time.
|
|
440
|
-
this.armPendingSubscribeWatchdogs();
|
|
441
|
-
};
|
|
442
|
-
|
|
443
|
-
this.ws!.onmessage = (event) => {
|
|
444
|
-
try {
|
|
445
|
-
const message = JSON.parse(event.data, rebaseReviver);
|
|
446
|
-
this.handleWebSocketMessage(message);
|
|
447
|
-
} catch (error) {
|
|
448
|
-
console.error("Error parsing WebSocket message:", error);
|
|
449
|
-
}
|
|
450
|
-
};
|
|
451
|
-
|
|
452
|
-
this.ws!.onclose = () => {
|
|
453
|
-
console.debug("Disconnected from PostgreSQL backend");
|
|
454
|
-
// Release the dead socket. `ensureConnected` returns early
|
|
455
|
-
// while `this.ws` is set, so holding a closed one made the
|
|
456
|
-
// "give up after N attempts" state permanent: nothing could
|
|
457
|
-
// ever redial, not even a fresh `subscribe`.
|
|
458
|
-
if (this.ws === socket) this.ws = null;
|
|
459
|
-
this.isConnected = false;
|
|
460
|
-
this.isAuthenticated = false;
|
|
461
|
-
this.authPromise = null;
|
|
462
|
-
// The reconnect path re-subscribes everything; a watchdog firing
|
|
463
|
-
// in the meantime would tear down healthy subscriptions.
|
|
464
|
-
this.suspendSubscribeWatchdogs();
|
|
465
|
-
this.emit("disconnect");
|
|
466
|
-
|
|
467
|
-
// Re-queue pending requests so the UI doesn't hang indefinitely or crash
|
|
468
|
-
for (const [reqId, request] of this.pendingRequests.entries()) {
|
|
469
|
-
if (reqId.startsWith("auth_")) {
|
|
470
|
-
request.reject(new Error("Connection closed during authentication"));
|
|
471
|
-
} else if (request.message) {
|
|
472
|
-
request.message._queuedResolve = request.resolve;
|
|
473
|
-
request.message._queuedReject = request.reject;
|
|
474
|
-
this.messageQueue.push(request.message);
|
|
475
|
-
} else {
|
|
476
|
-
request.reject(new RebaseApiError("Connection closed"));
|
|
477
|
-
}
|
|
478
|
-
this.pendingRequests.delete(reqId);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
this.attemptReconnect();
|
|
482
|
-
};
|
|
483
|
-
|
|
484
|
-
this.ws!.onerror = (error) => {
|
|
485
|
-
console.error("WebSocket error:", error);
|
|
486
|
-
this.isConnected = false;
|
|
487
|
-
this.emit("error", error);
|
|
488
|
-
};
|
|
489
|
-
} catch (error) {
|
|
490
|
-
console.error("Failed to initialize WebSocket:", error);
|
|
491
|
-
this.attemptReconnect();
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
private processMessageQueue() {
|
|
496
|
-
while (this.messageQueue.length > 0 && this.isConnected) {
|
|
497
|
-
const message = this.messageQueue.shift();
|
|
498
|
-
if (message) this.sendMessage(message);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
private attemptReconnect() {
|
|
503
|
-
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
504
|
-
console.error("Max reconnection attempts reached");
|
|
505
|
-
// Nothing will re-subscribe now, so stop every subscription that
|
|
506
|
-
// never loaded from spinning forever.
|
|
507
|
-
this.gaveUp = true;
|
|
508
|
-
this.failAllPendingSubscriptions(
|
|
509
|
-
new RebaseApiError("Connection lost", { code: "CONNECTION_LOST" })
|
|
510
|
-
);
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
this.reconnectAttempts++;
|
|
515
|
-
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
|
|
516
|
-
|
|
517
|
-
console.debug(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
518
|
-
|
|
519
|
-
if (this.reconnectTimeout) {
|
|
520
|
-
clearTimeout(this.reconnectTimeout);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
this.reconnectTimeout = setTimeout(() => {
|
|
524
|
-
this.reconnectTimeout = null;
|
|
525
|
-
this.initWebSocket();
|
|
526
|
-
}, delay);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
private isAuthError(message: WebSocketMessage): boolean {
|
|
530
|
-
if (message.type === "AUTH_ERROR") return true;
|
|
531
|
-
const { errorMessage, errorCode } = extractMessageError(message);
|
|
532
|
-
if (errorCode === "UNAUTHORIZED" || errorCode === "JWT_EXPIRED" || errorCode === "AUTH_ERROR") return true;
|
|
533
|
-
const lowerMessage = errorMessage.toLowerCase();
|
|
534
|
-
return lowerMessage.includes("unauthorized") || lowerMessage.includes("token expired") || lowerMessage.includes("token is expired") || lowerMessage.includes("invalid token") || lowerMessage.includes("session expired") || lowerMessage.includes("auth error");
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
private async handleAuthFailure(): Promise<boolean> {
|
|
538
|
-
if (this.refreshInProgress) {
|
|
539
|
-
return this.refreshInProgress;
|
|
540
|
-
}
|
|
541
|
-
this.refreshInProgress = (async () => {
|
|
542
|
-
this.isAuthenticated = false;
|
|
543
|
-
this.authPromise = null;
|
|
544
|
-
if (this.onUnauthorized) {
|
|
545
|
-
try {
|
|
546
|
-
const refreshed = await this.onUnauthorized();
|
|
547
|
-
if (refreshed && this.getAuthToken) {
|
|
548
|
-
const token = await this.getAuthToken();
|
|
549
|
-
if (token) {
|
|
550
|
-
await this.authenticate(token);
|
|
551
|
-
return true;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
} catch (error) {
|
|
555
|
-
console.error("WebSocket auth refresh failed:", error);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
return false;
|
|
559
|
-
})();
|
|
560
|
-
try {
|
|
561
|
-
return await this.refreshInProgress;
|
|
562
|
-
} finally {
|
|
563
|
-
this.refreshInProgress = null;
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
/**
|
|
568
|
-
* Shared logic for re-subscribing a collection or row subscription
|
|
569
|
-
* after an auth error is resolved by refreshing credentials.
|
|
570
|
-
*/
|
|
571
|
-
private resubscribeAfterAuthRefresh(
|
|
572
|
-
message: WebSocketMessage,
|
|
573
|
-
subscription: {
|
|
574
|
-
backendSubscriptionId: string;
|
|
575
|
-
callbacks: Map<string, { onUpdate: (...args: never[]) => void; onError?: (error: Error) => void }>;
|
|
576
|
-
props: FetchCollectionProps | FetchOneProps;
|
|
577
|
-
},
|
|
578
|
-
subscriptionKey: string,
|
|
579
|
-
idPrefix: "collection" | "row",
|
|
580
|
-
backendKeyMap: Map<string, string>,
|
|
581
|
-
messageType: "subscribe_collection" | "subscribe_one"
|
|
582
|
-
): void {
|
|
583
|
-
this.handleAuthFailure().then(refreshed => {
|
|
584
|
-
if (refreshed) {
|
|
585
|
-
const oldBackendId = subscription.backendSubscriptionId;
|
|
586
|
-
const newBackendId = `${idPrefix}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
587
|
-
subscription.backendSubscriptionId = newBackendId;
|
|
588
|
-
backendKeyMap.delete(oldBackendId);
|
|
589
|
-
backendKeyMap.set(newBackendId, subscriptionKey);
|
|
590
|
-
|
|
591
|
-
// Route through the helpers so the retry is watchdogged too.
|
|
592
|
-
if (messageType === "subscribe_collection") {
|
|
593
|
-
this.sendCollectionSubscribe(subscriptionKey);
|
|
594
|
-
} else {
|
|
595
|
-
this.sendEntitySubscribe(subscriptionKey);
|
|
596
|
-
}
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// The refresh did not produce usable credentials. Report the original
|
|
601
|
-
// error and drop the registration, so a later mount can try again
|
|
602
|
-
// rather than attaching to a subscription that will never load.
|
|
603
|
-
const { errorMessage, errorCode } = extractMessageError(message);
|
|
604
|
-
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
605
|
-
if (messageType === "subscribe_collection") {
|
|
606
|
-
this.failCollectionSubscription(subscriptionKey, error);
|
|
607
|
-
} else {
|
|
608
|
-
this.failEntitySubscription(subscriptionKey, error);
|
|
609
|
-
}
|
|
610
|
-
}).catch(err => {
|
|
611
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
612
|
-
if (messageType === "subscribe_collection") {
|
|
613
|
-
this.failCollectionSubscription(subscriptionKey, error);
|
|
614
|
-
} else {
|
|
615
|
-
this.failEntitySubscription(subscriptionKey, error);
|
|
616
|
-
}
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
private handleWebSocketMessage(message: WebSocketMessage) {
|
|
621
|
-
const {
|
|
622
|
-
type,
|
|
623
|
-
requestId,
|
|
624
|
-
subscriptionId
|
|
625
|
-
} = message;
|
|
626
|
-
|
|
627
|
-
// Handle responses to pending requests
|
|
628
|
-
if (requestId && this.pendingRequests.has(requestId)) {
|
|
629
|
-
const pendingReq = this.pendingRequests.get(requestId)!;
|
|
630
|
-
if (type === "ERROR" || type === "AUTH_ERROR" || message.error) {
|
|
631
|
-
if (this.isAuthError(message)) {
|
|
632
|
-
this.pendingRequests.delete(requestId);
|
|
633
|
-
this.handleAuthFailure().then(refreshed => {
|
|
634
|
-
if (refreshed && pendingReq.message) {
|
|
635
|
-
this.doSendMessage(pendingReq.message, pendingReq.resolve, pendingReq.reject).catch(pendingReq.reject);
|
|
636
|
-
} else {
|
|
637
|
-
const { errorMessage, errorCode } = extractMessageError(message);
|
|
638
|
-
pendingReq.reject(new RebaseApiError(errorMessage, { code: errorCode }));
|
|
639
|
-
}
|
|
640
|
-
}).catch(err => {
|
|
641
|
-
pendingReq.reject(err);
|
|
642
|
-
});
|
|
643
|
-
} else {
|
|
644
|
-
this.pendingRequests.delete(requestId);
|
|
645
|
-
const { errorMessage, errorCode } = extractMessageError(message);
|
|
646
|
-
pendingReq.reject(new RebaseApiError(errorMessage, { code: errorCode }));
|
|
647
|
-
}
|
|
648
|
-
} else {
|
|
649
|
-
this.pendingRequests.delete(requestId);
|
|
650
|
-
pendingReq.resolve(message.payload || message);
|
|
651
|
-
}
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// Channel traffic (broadcast / presence) is addressed by channel name
|
|
656
|
-
// rather than by requestId or subscriptionId, so it is dispatched
|
|
657
|
-
// before the subscription paths — none of which would match it, and
|
|
658
|
-
// the message would otherwise fall through and be dropped silently.
|
|
659
|
-
if (typeof message.channel === "string" &&
|
|
660
|
-
(type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
|
|
661
|
-
const handlers = this.channelHandlers.get(message.channel);
|
|
662
|
-
if (handlers) {
|
|
663
|
-
for (const handler of [...handlers]) {
|
|
664
|
-
try {
|
|
665
|
-
handler(message as unknown as Record<string, unknown>);
|
|
666
|
-
} catch (error) {
|
|
667
|
-
console.error("Error in channel handler:", error);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
return;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
// Handle subscription updates for collection subscriptions
|
|
675
|
-
if (subscriptionId && type === "collection_update") {
|
|
676
|
-
const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
|
|
677
|
-
if (subscriptionKey) {
|
|
678
|
-
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
679
|
-
if (collectionSub) {
|
|
680
|
-
const wireEntities = (message.rows || []) as unknown as Record<string, unknown>[];
|
|
681
|
-
const incomingRows = wireEntities;
|
|
682
|
-
|
|
683
|
-
// The keys arrive with the rows, so they are known before the
|
|
684
|
-
// first merge — a CDC-driven change never sends a patch, and
|
|
685
|
-
// learning them from patches alone would leave every
|
|
686
|
-
// externally-written collection unable to match a thing.
|
|
687
|
-
const updatePks = (message as unknown as { pks?: PrimaryKeyInfo[] }).pks;
|
|
688
|
-
if (updatePks) collectionSub.pks = updatePks;
|
|
689
|
-
|
|
690
|
-
// Structural merge: preserve cached row references for rows
|
|
691
|
-
// whose values haven't changed. This prevents downstream React components
|
|
692
|
-
// from re-rendering (VirtualTableCell uses deepEqual on rowData —
|
|
693
|
-
// same reference = instant true, avoiding expensive deep comparison).
|
|
694
|
-
const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
|
|
695
|
-
|
|
696
|
-
// Cache the latest data with optimizations
|
|
697
|
-
collectionSub.latestData = rows;
|
|
698
|
-
collectionSub.lastUpdated = Date.now();
|
|
699
|
-
collectionSub.isInitialDataReceived = true;
|
|
700
|
-
// The subscribe landed — stand the watchdog down.
|
|
701
|
-
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
702
|
-
collectionSub.subscribeTimeout = undefined;
|
|
703
|
-
collectionSub.subscribeInFlight = false;
|
|
704
|
-
|
|
705
|
-
// Notify all callbacks for this subscription
|
|
706
|
-
collectionSub.callbacks.forEach(callback => {
|
|
707
|
-
try {
|
|
708
|
-
callback.onUpdate(rows);
|
|
709
|
-
} catch (error) {
|
|
710
|
-
console.error("Error in collection subscription callback:", error);
|
|
711
|
-
if (callback.onError) {
|
|
712
|
-
callback.onError(error instanceof Error ? error : new Error(String(error)));
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
});
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
// Handle instant row-level patches for collection subscriptions.
|
|
722
|
-
// These arrive before the full refetch and give immediate cross-tab feedback.
|
|
723
|
-
if (subscriptionId && type === "collection_patch") {
|
|
724
|
-
const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
|
|
725
|
-
if (subscriptionKey) {
|
|
726
|
-
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
727
|
-
if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
|
|
728
|
-
const patchWireEntity = message.row ?? null;
|
|
729
|
-
const patchMessage = message as unknown as { id: string; pks?: PrimaryKeyInfo[] };
|
|
730
|
-
const patchEntityId = patchMessage.id;
|
|
731
|
-
// The server knows the key columns; remember them, because the
|
|
732
|
-
// refetch reconciliation needs them too and carries no id.
|
|
733
|
-
if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
|
|
734
|
-
const patchRow = patchWireEntity ? (patchWireEntity as unknown as Record<string, unknown>) : null;
|
|
735
|
-
let updated: Record<string, unknown>[];
|
|
736
|
-
|
|
737
|
-
if (patchRow === null) {
|
|
738
|
-
// Row was deleted — remove it from the cached list
|
|
739
|
-
updated = collectionSub.latestData.filter(
|
|
740
|
-
e => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId)
|
|
741
|
-
);
|
|
742
|
-
} else {
|
|
743
|
-
// Row was created or updated — merge into the cached list.
|
|
744
|
-
// Matched against the patch's own address rather than
|
|
745
|
-
// anything read off the row: `patchRow.id` is undefined
|
|
746
|
-
// for a table not keyed on `id`, so every update looked
|
|
747
|
-
// like a new row and was prepended as a duplicate.
|
|
748
|
-
const idx = collectionSub.latestData.findIndex(
|
|
749
|
-
e => this.rowAddress(e, collectionSub.pks) === String(patchEntityId)
|
|
750
|
-
);
|
|
751
|
-
if (idx >= 0) {
|
|
752
|
-
// Update in place (preserve array position)
|
|
753
|
-
updated = [...collectionSub.latestData];
|
|
754
|
-
updated[idx] = patchRow;
|
|
755
|
-
} else {
|
|
756
|
-
// New row — prepend (most recently created first)
|
|
757
|
-
updated = [patchRow, ...collectionSub.latestData];
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
collectionSub.latestData = updated;
|
|
762
|
-
collectionSub.lastUpdated = Date.now();
|
|
763
|
-
|
|
764
|
-
// Fire all callbacks with the patched data
|
|
765
|
-
collectionSub.callbacks.forEach(callback => {
|
|
766
|
-
try {
|
|
767
|
-
callback.onUpdate(updated);
|
|
768
|
-
} catch (error) {
|
|
769
|
-
console.error("Error in collection patch callback:", error);
|
|
770
|
-
if (callback.onError) {
|
|
771
|
-
callback.onError(error instanceof Error ? error : new Error(String(error)));
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
});
|
|
775
|
-
return;
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// Handle subscription updates for row subscriptions
|
|
781
|
-
if (subscriptionId && type === "single_update") {
|
|
782
|
-
const subscriptionKey = this.backendToEntityKey.get(subscriptionId);
|
|
783
|
-
if (subscriptionKey) {
|
|
784
|
-
const entitySub = this.singleSubscriptions.get(subscriptionKey);
|
|
785
|
-
if (entitySub) {
|
|
786
|
-
const wireEntity = message.row ?? null;
|
|
787
|
-
const row = wireEntity ? (wireEntity as unknown as Record<string, unknown>) : null;
|
|
788
|
-
// Cache the latest data with optimizations
|
|
789
|
-
entitySub.latestData = row;
|
|
790
|
-
entitySub.lastUpdated = Date.now();
|
|
791
|
-
entitySub.isInitialDataReceived = true;
|
|
792
|
-
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
793
|
-
entitySub.subscribeTimeout = undefined;
|
|
794
|
-
entitySub.subscribeInFlight = false;
|
|
795
|
-
|
|
796
|
-
// Notify all callbacks for this subscription
|
|
797
|
-
entitySub.callbacks.forEach(callback => {
|
|
798
|
-
try {
|
|
799
|
-
callback.onUpdate(row);
|
|
800
|
-
} catch (error) {
|
|
801
|
-
console.error("Error in row subscription callback:", error);
|
|
802
|
-
if (callback.onError) {
|
|
803
|
-
callback.onError(error instanceof Error ? error : new Error(String(error)));
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
});
|
|
807
|
-
return;
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
// Handle subscription errors
|
|
813
|
-
if (subscriptionId && (type === "ERROR" || message.error)) {
|
|
814
|
-
const collectionKey = this.backendToCollectionKey.get(subscriptionId);
|
|
815
|
-
if (collectionKey) {
|
|
816
|
-
const collectionSub = this.collectionSubscriptions.get(collectionKey);
|
|
817
|
-
if (collectionSub) {
|
|
818
|
-
if (this.isAuthError(message)) {
|
|
819
|
-
this.resubscribeAfterAuthRefresh(
|
|
820
|
-
message,
|
|
821
|
-
collectionSub,
|
|
822
|
-
collectionKey,
|
|
823
|
-
"collection",
|
|
824
|
-
this.backendToCollectionKey,
|
|
825
|
-
"subscribe_collection"
|
|
826
|
-
);
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// The server answered, so nothing is in flight any more. Leave
|
|
831
|
-
// the registration in place (its listeners are still mounted
|
|
832
|
-
// and have been told), but marked idle so the next listener
|
|
833
|
-
// re-subscribes instead of attaching to a dead entry.
|
|
834
|
-
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
835
|
-
collectionSub.subscribeTimeout = undefined;
|
|
836
|
-
collectionSub.subscribeInFlight = false;
|
|
837
|
-
|
|
838
|
-
const { errorMessage, errorCode } = extractMessageError(message);
|
|
839
|
-
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
840
|
-
collectionSub.callbacks.forEach(callback => {
|
|
841
|
-
if (callback.onError) {
|
|
842
|
-
callback.onError(error);
|
|
843
|
-
}
|
|
844
|
-
});
|
|
845
|
-
return;
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
const entityKey = this.backendToEntityKey.get(subscriptionId);
|
|
850
|
-
if (entityKey) {
|
|
851
|
-
const entitySub = this.singleSubscriptions.get(entityKey);
|
|
852
|
-
if (entitySub) {
|
|
853
|
-
if (this.isAuthError(message)) {
|
|
854
|
-
this.resubscribeAfterAuthRefresh(
|
|
855
|
-
message,
|
|
856
|
-
entitySub,
|
|
857
|
-
entityKey,
|
|
858
|
-
"row",
|
|
859
|
-
this.backendToEntityKey,
|
|
860
|
-
"subscribe_one"
|
|
861
|
-
);
|
|
862
|
-
return;
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
866
|
-
entitySub.subscribeTimeout = undefined;
|
|
867
|
-
entitySub.subscribeInFlight = false;
|
|
868
|
-
|
|
869
|
-
const { errorMessage, errorCode } = extractMessageError(message);
|
|
870
|
-
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
871
|
-
entitySub.callbacks.forEach(callback => {
|
|
872
|
-
if (callback.onError) {
|
|
873
|
-
callback.onError(error);
|
|
874
|
-
}
|
|
875
|
-
});
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
// An error that matched no waiter used to fall off the end of this
|
|
882
|
-
// method and disappear. Channel frames are the ones that always do:
|
|
883
|
-
// they are fire-and-forget by design, so no `pendingRequests` entry
|
|
884
|
-
// exists to reject and the server's errors about them — RATE_LIMITED,
|
|
885
|
-
// CHANNEL_FORBIDDEN, CHANNEL_HISTORY_WRITE_FAILED — were dropped while
|
|
886
|
-
// `await channel.broadcast(...)` resolved as if it had been sent. A
|
|
887
|
-
// console warning is the floor, not the answer: an `onError` on
|
|
888
|
-
// `RebaseRealtimeChannel` is the shape this should eventually take.
|
|
889
|
-
if (type === "ERROR" || type === "error" || message.error) {
|
|
890
|
-
const { errorMessage, errorCode } = extractMessageError(message);
|
|
891
|
-
console.warn(
|
|
892
|
-
`[Rebase] Realtime error from the server${errorCode ? ` (${errorCode})` : ""}: ${errorMessage}`
|
|
893
|
-
);
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
private async ensureAuthenticated(retryCount = 3): Promise<void> {
|
|
898
|
-
// If already authenticated or no token getter, skip
|
|
899
|
-
if (this.isAuthenticated || !this.getAuthToken) return;
|
|
900
|
-
|
|
901
|
-
// If auth is in progress, wait for it.
|
|
902
|
-
//
|
|
903
|
-
// The share has to be published *before* the first await, which is why
|
|
904
|
-
// the work lives in its own method. The guard used to be read here and
|
|
905
|
-
// the promise only assigned after `await this.getAuthToken()` — so
|
|
906
|
-
// every caller that arrived during that gap saw `null` and started an
|
|
907
|
-
// attempt of its own. A queue flushing six subscriptions on connect
|
|
908
|
-
// does exactly that, and each extra attempt raced the others through a
|
|
909
|
-
// single `pendingRequests` slot: one settled, the rest hung until their
|
|
910
|
-
// own 30s timeout, and the frames waiting behind them were never sent.
|
|
911
|
-
// Their subscriptions then reported "Subscription timed out" — the
|
|
912
|
-
// board's columns loading one at a time, or not at all.
|
|
913
|
-
if (!this.authPromise) {
|
|
914
|
-
this.authPromise = this.runAuthentication(retryCount);
|
|
915
|
-
this.authPromise.finally(() => {
|
|
916
|
-
this.authPromise = null;
|
|
917
|
-
}).catch(() => undefined);
|
|
918
|
-
}
|
|
919
|
-
await this.authPromise;
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
private async runAuthentication(retryCount: number): Promise<void> {
|
|
923
|
-
// Try to authenticate with retries
|
|
924
|
-
let lastError: unknown = null;
|
|
925
|
-
|
|
926
|
-
for (let attempt = 0; attempt < retryCount; attempt++) {
|
|
927
|
-
try {
|
|
928
|
-
const token = await this.getAuthToken!();
|
|
929
|
-
if (!token) throw new Error("user not logged in");
|
|
930
|
-
await this.authenticate(token);
|
|
931
|
-
console.debug("WebSocket authenticated on demand");
|
|
932
|
-
return; // Success
|
|
933
|
-
} catch (error: unknown) {
|
|
934
|
-
lastError = error;
|
|
935
|
-
|
|
936
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
937
|
-
// "not logged in" / "Session expired" are definitive - don't retry
|
|
938
|
-
if (errMsg.includes("not logged in") || errMsg.includes("Session expired")) {
|
|
939
|
-
console.warn("WebSocket auth failed: user not logged in");
|
|
940
|
-
throw error;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
// "still loading" is transient - retry with backoff (auth controller
|
|
944
|
-
// is restoring tokens from localStorage; it will resolve shortly)
|
|
945
|
-
if (errMsg.includes("still loading")) {
|
|
946
|
-
if (attempt < retryCount - 1) {
|
|
947
|
-
const delay = Math.min(500 * (attempt + 1), 2000);
|
|
948
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
949
|
-
continue;
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
// For other errors, retry with backoff
|
|
954
|
-
if (attempt < retryCount - 1) {
|
|
955
|
-
const delay = Math.min(1000 * (attempt + 1), 3000);
|
|
956
|
-
console.debug(`WebSocket auth attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
|
|
957
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
console.warn("WebSocket on-demand auth failed after retries:", lastError);
|
|
963
|
-
throw lastError;
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
async reauthenticate(): Promise<void> {
|
|
967
|
-
if (!this.getAuthToken) return;
|
|
968
|
-
|
|
969
|
-
this.isAuthenticated = false;
|
|
970
|
-
try {
|
|
971
|
-
const token = await this.getAuthToken();
|
|
972
|
-
if (!token) throw new Error("user not logged in");
|
|
973
|
-
await this.authenticate(token);
|
|
974
|
-
console.debug("WebSocket reauthenticated successfully");
|
|
975
|
-
} catch (error) {
|
|
976
|
-
console.error("WebSocket reauthentication failed:", error);
|
|
977
|
-
throw error;
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
/**
|
|
982
|
-
* Public because `RebaseRealtimeChannel` sends channel frames through it.
|
|
983
|
-
* Not part of the stable surface — prefer `client.realtime.channel(name)`.
|
|
984
|
-
*/
|
|
985
|
-
public sendMessage(message: Record<string, unknown>): Promise<unknown> {
|
|
986
|
-
// If already has a requestId (re-sending from queue), use the stored promise handlers
|
|
987
|
-
const queuedMsg = message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void };
|
|
988
|
-
if (queuedMsg._queuedResolve && queuedMsg._queuedReject) {
|
|
989
|
-
return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
if (!this.isConnected || !this.ws) {
|
|
993
|
-
// The queue is only ever drained by a socket opening, so something
|
|
994
|
-
// has to open one. Before lazy connect this was guaranteed by the
|
|
995
|
-
// constructor; now the first frame is what asks for it.
|
|
996
|
-
this.ensureConnected();
|
|
997
|
-
// Queue the message and return a promise that will be resolved when actually sent
|
|
998
|
-
return new Promise<unknown>((resolve, reject) => {
|
|
999
|
-
const queueable = message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void };
|
|
1000
|
-
queueable._queuedResolve = resolve;
|
|
1001
|
-
queueable._queuedReject = reject;
|
|
1002
|
-
this.messageQueue.push(message);
|
|
1003
|
-
});
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
return new Promise<unknown>((resolve, reject) => {
|
|
1007
|
-
this.doSendMessage(message, resolve, reject);
|
|
1008
|
-
});
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
private async doSendMessage(message: Record<string, unknown>, resolve: (value: unknown) => void, reject: (error: Error) => void): Promise<void> {
|
|
1012
|
-
// Ensure authenticated before sending non-auth messages.
|
|
1013
|
-
//
|
|
1014
|
-
// Channel traffic is exempt. `ensureAuthenticated` throws "user not
|
|
1015
|
-
// logged in" when there is no token, which rejects the frame before it
|
|
1016
|
-
// is ever sent — so on an anonymous-first app (the kind this API was
|
|
1017
|
-
// added for) *every* channel operation failed client-side, and the
|
|
1018
|
-
// server never got to decide. Presence in a public room does not
|
|
1019
|
-
// require an account. A signed-in caller still authenticates: the
|
|
1020
|
-
// socket does it from `getAuthToken` on open, and the server authorizes
|
|
1021
|
-
// these frames either way.
|
|
1022
|
-
if (message.type !== "AUTHENTICATE"
|
|
1023
|
-
&& !CHANNEL_MESSAGE_TYPES.has(message.type as string)
|
|
1024
|
-
&& this.getAuthToken && !this.isAuthenticated) {
|
|
1025
|
-
try {
|
|
1026
|
-
await this.ensureAuthenticated();
|
|
1027
|
-
} catch (error: unknown) {
|
|
1028
|
-
const errorMessage = error instanceof Error ? error.message : "Authentication required";
|
|
1029
|
-
reject(new RebaseApiError(errorMessage));
|
|
1030
|
-
return;
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
const requestId = (message.requestId as string) || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1035
|
-
message.requestId = requestId;
|
|
1036
|
-
|
|
1037
|
-
const expectsResponse = !(
|
|
1038
|
-
message.type === "subscribe_collection"
|
|
1039
|
-
|| message.type === "subscribe_one"
|
|
1040
|
-
|| message.type === "unsubscribe"
|
|
1041
|
-
|| CHANNEL_MESSAGE_TYPES.has(message.type as string)
|
|
1042
|
-
);
|
|
1043
|
-
|
|
1044
|
-
if (expectsResponse && !this.pendingRequests.has(requestId)) {
|
|
1045
|
-
const timeoutHandle = setTimeout(() => {
|
|
1046
|
-
if (this.pendingRequests.has(requestId)) {
|
|
1047
|
-
this.pendingRequests.delete(requestId);
|
|
1048
|
-
reject(new RebaseApiError("Request timed out"));
|
|
1049
|
-
}
|
|
1050
|
-
}, this.requestTimeoutMs);
|
|
1051
|
-
|
|
1052
|
-
this.pendingRequests.set(requestId, {
|
|
1053
|
-
resolve: (value: unknown) => {
|
|
1054
|
-
clearTimeout(timeoutHandle);
|
|
1055
|
-
resolve(value);
|
|
1056
|
-
},
|
|
1057
|
-
reject: (error: Error) => {
|
|
1058
|
-
clearTimeout(timeoutHandle);
|
|
1059
|
-
reject(error);
|
|
1060
|
-
},
|
|
1061
|
-
message: message as Record<string, unknown> & { _queuedResolve?: (p: unknown) => void; _queuedReject?: (p: Error) => void }
|
|
1062
|
-
});
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
try {
|
|
1066
|
-
this.ws!.send(JSON.stringify(message));
|
|
1067
|
-
if (!expectsResponse) {
|
|
1068
|
-
resolve(undefined);
|
|
1069
|
-
}
|
|
1070
|
-
} catch (error) {
|
|
1071
|
-
if (expectsResponse) {
|
|
1072
|
-
this.pendingRequests.delete(requestId);
|
|
1073
|
-
}
|
|
1074
|
-
reject(new RebaseApiError("Failed to send message", { cause: error }));
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
// Data source methods
|
|
1079
|
-
async fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Record<string, unknown>[]> {
|
|
1080
|
-
const response = await this.sendMessage({
|
|
1081
|
-
type: "FETCH_COLLECTION",
|
|
1082
|
-
payload: props
|
|
1083
|
-
}) as { rows?: Record<string, unknown>[] };
|
|
1084
|
-
return (response.rows || []);
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
async fetchOne<M extends Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined> {
|
|
1088
|
-
const response = await this.sendMessage({
|
|
1089
|
-
type: "FETCH_ONE",
|
|
1090
|
-
payload: props
|
|
1091
|
-
}) as { row?: Record<string, unknown> };
|
|
1092
|
-
const wireEntity = response.row;
|
|
1093
|
-
return wireEntity ?? undefined;
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
async save<M extends Record<string, unknown>>(props: SaveProps<M>): Promise<Record<string, unknown>> {
|
|
1097
|
-
const response = await this.sendMessage({
|
|
1098
|
-
type: "SAVE",
|
|
1099
|
-
payload: props
|
|
1100
|
-
}) as { row: Record<string, unknown> };
|
|
1101
|
-
return response.row;
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
async delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void> {
|
|
1105
|
-
await this.sendMessage({
|
|
1106
|
-
type: "DELETE",
|
|
1107
|
-
payload: props
|
|
1108
|
-
});
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
async executeSql(sql: string, options?: { database?: string, role?: string }): Promise<Record<string, unknown>[]> {
|
|
1112
|
-
const response = await this.sendMessage({
|
|
1113
|
-
type: "EXECUTE_SQL",
|
|
1114
|
-
payload: { sql,
|
|
1115
|
-
options }
|
|
1116
|
-
}) as { result?: Record<string, unknown>[] };
|
|
1117
|
-
return response.result || [];
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
async fetchAvailableDatabases(): Promise<string[]> {
|
|
1121
|
-
const response = await this.sendMessage({
|
|
1122
|
-
type: "FETCH_DATABASES",
|
|
1123
|
-
payload: {}
|
|
1124
|
-
}) as { databases?: string[] };
|
|
1125
|
-
return response.databases || [];
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
async fetchAvailableRoles(): Promise<string[]> {
|
|
1129
|
-
const response = await this.sendMessage({
|
|
1130
|
-
type: "FETCH_ROLES"
|
|
1131
|
-
}) as { roles?: string[] };
|
|
1132
|
-
return response.roles || [];
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
async fetchApplicationRoles(): Promise<string[]> {
|
|
1136
|
-
const response = await this.sendMessage({
|
|
1137
|
-
type: "FETCH_APPLICATION_ROLES"
|
|
1138
|
-
}) as { roles?: string[] };
|
|
1139
|
-
return response.roles || [];
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
async fetchCurrentDatabase(): Promise<string | undefined> {
|
|
1143
|
-
const response = await this.sendMessage({
|
|
1144
|
-
type: "FETCH_CURRENT_DATABASE"
|
|
1145
|
-
}) as { database?: string };
|
|
1146
|
-
return response.database;
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
async checkUniqueField(path: string, name: string, value: unknown, id?: string, collection?: CollectionConfig): Promise<boolean> {
|
|
1150
|
-
const response = await this.sendMessage({
|
|
1151
|
-
type: "CHECK_UNIQUE_FIELD",
|
|
1152
|
-
payload: {
|
|
1153
|
-
path,
|
|
1154
|
-
name,
|
|
1155
|
-
value,
|
|
1156
|
-
id,
|
|
1157
|
-
collection
|
|
1158
|
-
}
|
|
1159
|
-
}) as { isUnique: boolean };
|
|
1160
|
-
return response.isUnique;
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
async count<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number> {
|
|
1164
|
-
const response = await this.sendMessage({
|
|
1165
|
-
type: "COUNT",
|
|
1166
|
-
payload: props
|
|
1167
|
-
}) as { count: number };
|
|
1168
|
-
return response.count;
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
async fetchUnmappedTables(mappedPaths?: string[]): Promise<string[]> {
|
|
1172
|
-
const response = await this.sendMessage({
|
|
1173
|
-
type: "FETCH_UNMAPPED_TABLES",
|
|
1174
|
-
payload: { mappedPaths }
|
|
1175
|
-
}) as { tables?: string[] };
|
|
1176
|
-
return response.tables || [];
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
async fetchTableMetadata(tableName: string): Promise<TableMetadata> {
|
|
1180
|
-
const response = await this.sendMessage({
|
|
1181
|
-
type: "FETCH_TABLE_METADATA",
|
|
1182
|
-
payload: { tableName }
|
|
1183
|
-
}) as { metadata?: TableMetadata };
|
|
1184
|
-
|
|
1185
|
-
return response.metadata || ({ columns: [],
|
|
1186
|
-
foreignKeys: [],
|
|
1187
|
-
junctions: [],
|
|
1188
|
-
policies: [] } as TableMetadata);
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
async createBranch(name: string, options?: { source?: string }): Promise<BranchInfo> {
|
|
1192
|
-
const response = await this.sendMessage({
|
|
1193
|
-
type: "CREATE_BRANCH",
|
|
1194
|
-
payload: { name,
|
|
1195
|
-
options }
|
|
1196
|
-
}) as { branch: BranchInfo };
|
|
1197
|
-
return response.branch;
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
async deleteBranch(name: string): Promise<void> {
|
|
1201
|
-
await this.sendMessage({
|
|
1202
|
-
type: "DELETE_BRANCH",
|
|
1203
|
-
payload: { name }
|
|
1204
|
-
});
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
async listBranches(): Promise<BranchInfo[]> {
|
|
1208
|
-
const response = await this.sendMessage({
|
|
1209
|
-
type: "LIST_BRANCHES",
|
|
1210
|
-
payload: {}
|
|
1211
|
-
}) as { branches?: BranchInfo[] };
|
|
1212
|
-
return response.branches || [];
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
/**
|
|
1216
|
-
* Recursively compare two values for structural equality.
|
|
1217
|
-
* Handles primitives, null, undefined, Date, RegExp, arrays, and plain objects.
|
|
1218
|
-
*/
|
|
1219
|
-
private deepEqual(a: unknown, b: unknown): boolean {
|
|
1220
|
-
// Same reference or same primitive
|
|
1221
|
-
if (a === b) return true;
|
|
1222
|
-
|
|
1223
|
-
// Handle null/undefined
|
|
1224
|
-
if (a === null || b === null || a === undefined || b === undefined) return false;
|
|
1225
|
-
|
|
1226
|
-
// Different types
|
|
1227
|
-
if (typeof a !== typeof b) return false;
|
|
1228
|
-
|
|
1229
|
-
// Non-object primitives (number, string, boolean, bigint, symbol)
|
|
1230
|
-
// that weren't caught by === above (e.g. NaN !== NaN)
|
|
1231
|
-
if (typeof a !== "object") return false;
|
|
1232
|
-
|
|
1233
|
-
// Date comparison
|
|
1234
|
-
if (a instanceof Date && b instanceof Date) {
|
|
1235
|
-
return a.getTime() === b.getTime();
|
|
1236
|
-
}
|
|
1237
|
-
if (a instanceof Date || b instanceof Date) return false;
|
|
1238
|
-
|
|
1239
|
-
// RegExp comparison
|
|
1240
|
-
if (a instanceof RegExp && b instanceof RegExp) {
|
|
1241
|
-
return a.source === b.source && a.flags === b.flags;
|
|
1242
|
-
}
|
|
1243
|
-
if (a instanceof RegExp || b instanceof RegExp) return false;
|
|
1244
|
-
|
|
1245
|
-
// Array comparison
|
|
1246
|
-
const aIsArray = Array.isArray(a);
|
|
1247
|
-
const bIsArray = Array.isArray(b);
|
|
1248
|
-
if (aIsArray !== bIsArray) return false;
|
|
1249
|
-
|
|
1250
|
-
if (aIsArray && bIsArray) {
|
|
1251
|
-
if (a.length !== b.length) return false;
|
|
1252
|
-
for (let i = 0; i < a.length; i++) {
|
|
1253
|
-
if (!this.deepEqual(a[i], b[i])) return false;
|
|
1254
|
-
}
|
|
1255
|
-
return true;
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
// Plain object comparison
|
|
1259
|
-
const aObj = a as Record<string, unknown>;
|
|
1260
|
-
const bObj = b as Record<string, unknown>;
|
|
1261
|
-
const aKeys = Object.keys(aObj);
|
|
1262
|
-
const bKeys = Object.keys(bObj);
|
|
1263
|
-
|
|
1264
|
-
if (aKeys.length !== bKeys.length) return false;
|
|
1265
|
-
|
|
1266
|
-
for (const key of aKeys) {
|
|
1267
|
-
if (!Object.prototype.hasOwnProperty.call(bObj, key)) return false;
|
|
1268
|
-
if (!this.deepEqual(aObj[key], bObj[key])) return false;
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
return true;
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
private normalizeForComparison(val: unknown): unknown {
|
|
1275
|
-
if (!val) return val;
|
|
1276
|
-
|
|
1277
|
-
if (Array.isArray(val)) {
|
|
1278
|
-
return val.map(item => this.normalizeForComparison(item));
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
if (typeof val === "object") {
|
|
1282
|
-
if (val instanceof Date) return val;
|
|
1283
|
-
if (val instanceof RegExp) return val;
|
|
1284
|
-
|
|
1285
|
-
const obj = val as Record<string, unknown>;
|
|
1286
|
-
if (obj.__type === "relation") {
|
|
1287
|
-
// `data` is dropped on purpose: a relation compares by the
|
|
1288
|
-
// reference it holds, not by the row it happens to have loaded.
|
|
1289
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1290
|
-
const { data, ...rest } = obj;
|
|
1291
|
-
return rest;
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
const result: Record<string, unknown> = {};
|
|
1295
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
1296
|
-
result[k] = this.normalizeForComparison(v);
|
|
1297
|
-
}
|
|
1298
|
-
return result;
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
return val;
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
/**
|
|
1305
|
-
* The address of a row, for matching it against another copy of itself.
|
|
1306
|
-
*
|
|
1307
|
-
* A row is exactly its columns and carries no address, so it is derived
|
|
1308
|
-
* from the key columns the server named — including the ordinary case where
|
|
1309
|
-
* that key is `id`, which the server reports like any other.
|
|
1310
|
-
*
|
|
1311
|
-
* Undefined when there are no keys, which means the server could not
|
|
1312
|
-
* resolve any: such rows genuinely cannot be recognised, and guessing at a
|
|
1313
|
-
* column called `id` would be inventing an identity for a table that has
|
|
1314
|
-
* none.
|
|
1315
|
-
*/
|
|
1316
|
-
private rowAddress(row: Record<string, unknown>, pks: PrimaryKeyInfo[] | undefined): string | undefined {
|
|
1317
|
-
if (!pks || pks.length === 0) return undefined;
|
|
1318
|
-
const address = buildCompositeId(row, pks);
|
|
1319
|
-
if (!address || address.split(COMPOSITE_ID_SEPARATOR).every(part => part === "")) return undefined;
|
|
1320
|
-
return address;
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
/**
|
|
1324
|
-
* Merge incoming rows with cached data, preserving cached references
|
|
1325
|
-
* for rows whose values haven't changed. This avoids unnecessary
|
|
1326
|
-
* React re-renders when the server refetches all rows but most
|
|
1327
|
-
* haven't actually changed.
|
|
1328
|
-
*/
|
|
1329
|
-
private mergeRows(
|
|
1330
|
-
cached: Record<string, unknown>[] | undefined,
|
|
1331
|
-
incoming: Record<string, unknown>[],
|
|
1332
|
-
pks?: PrimaryKeyInfo[]
|
|
1333
|
-
): Record<string, unknown>[] {
|
|
1334
|
-
if (!cached || cached.length === 0) return incoming;
|
|
1335
|
-
|
|
1336
|
-
// Build a lookup from cached rows by address for O(1) access
|
|
1337
|
-
const cachedById = new Map<string, Record<string, unknown>>();
|
|
1338
|
-
for (const row of cached) {
|
|
1339
|
-
const address = this.rowAddress(row, pks);
|
|
1340
|
-
if (address !== undefined) cachedById.set(address, row);
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
return incoming.map(incomingRow => {
|
|
1344
|
-
const address = this.rowAddress(incomingRow, pks);
|
|
1345
|
-
const cachedRow = address === undefined ? undefined : cachedById.get(address);
|
|
1346
|
-
if (!cachedRow) return incomingRow;
|
|
1347
|
-
|
|
1348
|
-
// Compare flat rows directly (no more path/values nesting)
|
|
1349
|
-
const normCached = this.normalizeForComparison(cachedRow) as Record<string, unknown>;
|
|
1350
|
-
const normIncoming = this.normalizeForComparison(incomingRow) as Record<string, unknown>;
|
|
1351
|
-
|
|
1352
|
-
if (this.deepEqual(normCached, normIncoming)) {
|
|
1353
|
-
return cachedRow;
|
|
1354
|
-
} else {
|
|
1355
|
-
// Deep debug: Why did it fail?
|
|
1356
|
-
const mismatches: Record<string, { cached: unknown, incoming: unknown }> = {};
|
|
1357
|
-
const allKeys = new Set([...Object.keys(normCached), ...Object.keys(normIncoming)]);
|
|
1358
|
-
for (const key of allKeys) {
|
|
1359
|
-
if (!this.deepEqual(normCached[key], normIncoming[key])) {
|
|
1360
|
-
mismatches[key] = { cached: normCached[key],
|
|
1361
|
-
incoming: normIncoming[key] };
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
|
|
1365
|
-
}
|
|
1366
|
-
return incomingRow;
|
|
1367
|
-
});
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
// Subscription methods
|
|
1371
|
-
listenCollection<M extends Record<string, unknown>>(
|
|
1372
|
-
props: FetchCollectionProps<M>,
|
|
1373
|
-
onUpdate: (rows: Record<string, unknown>[]) => void,
|
|
1374
|
-
onError?: (error: Error) => void
|
|
1375
|
-
): () => void {
|
|
1376
|
-
// A subscription is the app asking for live data, so this is where the
|
|
1377
|
-
// socket is wanted. Called before the dedup check below: joining an
|
|
1378
|
-
// existing subscription must still work if the socket has since gone.
|
|
1379
|
-
this.ensureConnected();
|
|
1380
|
-
|
|
1381
|
-
const subscriptionKey = this.createCollectionSubscriptionKey(props);
|
|
1382
|
-
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1383
|
-
|
|
1384
|
-
// Check if we already have a subscription for these exact parameters
|
|
1385
|
-
const existingSubscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1386
|
-
|
|
1387
|
-
if (existingSubscription) {
|
|
1388
|
-
// Reuse existing subscription - just add the new callback
|
|
1389
|
-
const callbackMap = existingSubscription.callbacks as Map<string, {
|
|
1390
|
-
onUpdate: (rows: Record<string, unknown>[]) => void;
|
|
1391
|
-
onError?: (error: Error) => void;
|
|
1392
|
-
}>;
|
|
1393
|
-
callbackMap.set(callbackId, { onUpdate,
|
|
1394
|
-
onError });
|
|
1395
|
-
|
|
1396
|
-
// Immediately fire the callback with cached data if available
|
|
1397
|
-
if (existingSubscription.latestData !== undefined && existingSubscription.isInitialDataReceived) {
|
|
1398
|
-
try {
|
|
1399
|
-
onUpdate(existingSubscription.latestData);
|
|
1400
|
-
} catch (error) {
|
|
1401
|
-
console.error("Error in collection subscription callback:", error);
|
|
1402
|
-
if (onError) {
|
|
1403
|
-
onError(error instanceof Error ? error : new Error(String(error)));
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
} else if (!existingSubscription.subscribeInFlight) {
|
|
1407
|
-
// Registered but idle: its subscribe never landed (the send failed,
|
|
1408
|
-
// or the server answered with an error). Nothing is coming, so
|
|
1409
|
-
// re-issue it — otherwise this listener waits forever.
|
|
1410
|
-
this.sendCollectionSubscribe(subscriptionKey);
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
// Return unsubscribe function
|
|
1414
|
-
return () => {
|
|
1415
|
-
callbackMap.delete(callbackId);
|
|
1416
|
-
if (callbackMap.size === 0) {
|
|
1417
|
-
// Only tear down if this is still the same registration — a
|
|
1418
|
-
// failed subscribe may have replaced it in the meantime.
|
|
1419
|
-
if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
1420
|
-
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
1421
|
-
this.collectionSubscriptions.delete(subscriptionKey);
|
|
1422
|
-
this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
|
|
1423
|
-
if (this.isConnected && this.ws) {
|
|
1424
|
-
this.sendMessage({
|
|
1425
|
-
type: "unsubscribe",
|
|
1426
|
-
payload: { subscriptionId: existingSubscription.backendSubscriptionId }
|
|
1427
|
-
}).catch(console.error);
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
};
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
// Create new subscription
|
|
1434
|
-
const backendSubscriptionId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1435
|
-
const callbackMap = new Map<string, {
|
|
1436
|
-
onUpdate: (rows: Record<string, unknown>[]) => void;
|
|
1437
|
-
onError?: (error: Error) => void;
|
|
1438
|
-
}>();
|
|
1439
|
-
callbackMap.set(callbackId, { onUpdate,
|
|
1440
|
-
onError });
|
|
1441
|
-
|
|
1442
|
-
this.collectionSubscriptions.set(subscriptionKey, {
|
|
1443
|
-
backendSubscriptionId,
|
|
1444
|
-
callbacks: callbackMap,
|
|
1445
|
-
props
|
|
1446
|
-
});
|
|
1447
|
-
|
|
1448
|
-
// Add reverse lookup
|
|
1449
|
-
this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
|
|
1450
|
-
|
|
1451
|
-
// Send subscription request to backend. A failure here drops the
|
|
1452
|
-
// registration and notifies every listener, so the next mount retries.
|
|
1453
|
-
this.sendCollectionSubscribe(subscriptionKey);
|
|
1454
|
-
|
|
1455
|
-
// Return unsubscribe function
|
|
1456
|
-
return () => {
|
|
1457
|
-
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1458
|
-
if (subscription) {
|
|
1459
|
-
const callbacks = subscription.callbacks;
|
|
1460
|
-
callbacks.delete(callbackId);
|
|
1461
|
-
if (callbacks.size === 0) {
|
|
1462
|
-
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1463
|
-
this.collectionSubscriptions.delete(subscriptionKey);
|
|
1464
|
-
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
1465
|
-
if (this.isConnected && this.ws) {
|
|
1466
|
-
this.sendMessage({
|
|
1467
|
-
type: "unsubscribe",
|
|
1468
|
-
payload: { subscriptionId: subscription.backendSubscriptionId }
|
|
1469
|
-
}).catch(console.error);
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
};
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
listenOne<M extends Record<string, unknown>>(
|
|
1477
|
-
props: FetchOneProps<M>,
|
|
1478
|
-
onUpdate: (row: Record<string, unknown> | null) => void,
|
|
1479
|
-
onError?: (error: Error) => void
|
|
1480
|
-
): () => void {
|
|
1481
|
-
this.ensureConnected();
|
|
1482
|
-
|
|
1483
|
-
const subscriptionKey = this.createSingleSubscriptionKey(props);
|
|
1484
|
-
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1485
|
-
|
|
1486
|
-
// Check if we already have a subscription for these exact parameters
|
|
1487
|
-
const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1488
|
-
|
|
1489
|
-
if (existingSubscription) {
|
|
1490
|
-
// Reuse existing subscription - just add the new callback
|
|
1491
|
-
const callbackMap = existingSubscription.callbacks as Map<string, {
|
|
1492
|
-
onUpdate: (row: Record<string, unknown> | null) => void;
|
|
1493
|
-
onError?: (error: Error) => void;
|
|
1494
|
-
}>;
|
|
1495
|
-
callbackMap.set(callbackId, { onUpdate,
|
|
1496
|
-
onError });
|
|
1497
|
-
|
|
1498
|
-
// Immediately fire the callback with cached data if available
|
|
1499
|
-
if (existingSubscription.latestData !== undefined && existingSubscription.isInitialDataReceived) {
|
|
1500
|
-
try {
|
|
1501
|
-
onUpdate(existingSubscription.latestData);
|
|
1502
|
-
} catch (error) {
|
|
1503
|
-
console.error("Error in row subscription callback:", error);
|
|
1504
|
-
if (onError) {
|
|
1505
|
-
onError(error instanceof Error ? error : new Error(String(error)));
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
} else if (!existingSubscription.subscribeInFlight) {
|
|
1509
|
-
// See listenCollection: a registration with nothing in flight is
|
|
1510
|
-
// dead, and attaching to it silently would hang this listener.
|
|
1511
|
-
this.sendEntitySubscribe(subscriptionKey);
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
// Return unsubscribe function
|
|
1515
|
-
return () => {
|
|
1516
|
-
callbackMap.delete(callbackId);
|
|
1517
|
-
if (callbackMap.size === 0) {
|
|
1518
|
-
if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
1519
|
-
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
1520
|
-
// No more callbacks, unsubscribe from backend
|
|
1521
|
-
this.singleSubscriptions.delete(subscriptionKey);
|
|
1522
|
-
this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
|
|
1523
|
-
if (this.isConnected && this.ws) {
|
|
1524
|
-
this.sendMessage({
|
|
1525
|
-
type: "unsubscribe",
|
|
1526
|
-
payload: { subscriptionId: existingSubscription.backendSubscriptionId }
|
|
1527
|
-
}).catch(console.error);
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
};
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
// Create new subscription
|
|
1534
|
-
const backendSubscriptionId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1535
|
-
const callbackMap = new Map<string, {
|
|
1536
|
-
onUpdate: (row: Record<string, unknown> | null) => void;
|
|
1537
|
-
onError?: (error: Error) => void;
|
|
1538
|
-
}>();
|
|
1539
|
-
callbackMap.set(callbackId, { onUpdate,
|
|
1540
|
-
onError });
|
|
1541
|
-
|
|
1542
|
-
this.singleSubscriptions.set(subscriptionKey, {
|
|
1543
|
-
backendSubscriptionId,
|
|
1544
|
-
callbacks: callbackMap,
|
|
1545
|
-
props
|
|
1546
|
-
});
|
|
1547
|
-
|
|
1548
|
-
// Add reverse lookup
|
|
1549
|
-
this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
|
|
1550
|
-
|
|
1551
|
-
// Send subscription request to backend
|
|
1552
|
-
this.sendEntitySubscribe(subscriptionKey);
|
|
1553
|
-
|
|
1554
|
-
// Return unsubscribe function
|
|
1555
|
-
return () => {
|
|
1556
|
-
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1557
|
-
if (subscription) {
|
|
1558
|
-
const callbacks = subscription.callbacks;
|
|
1559
|
-
callbacks.delete(callbackId);
|
|
1560
|
-
if (callbacks.size === 0) {
|
|
1561
|
-
this.singleSubscriptions.delete(subscriptionKey);
|
|
1562
|
-
this.backendToEntityKey.delete(subscription.backendSubscriptionId);
|
|
1563
|
-
if (this.isConnected && this.ws) {
|
|
1564
|
-
this.sendMessage({
|
|
1565
|
-
type: "unsubscribe",
|
|
1566
|
-
payload: { subscriptionId: subscription.backendSubscriptionId }
|
|
1567
|
-
}).catch(console.error);
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1571
|
-
};
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
/**
|
|
1575
|
-
* Send a `subscribe_collection` for an already-registered subscription and
|
|
1576
|
-
* arm its watchdog.
|
|
1577
|
-
*
|
|
1578
|
-
* Every path that registers a collection subscription goes through here, so
|
|
1579
|
-
* that a subscribe which never lands — a rejected send, or a server that
|
|
1580
|
-
* never answers — always ends up in `failCollectionSubscription` rather than
|
|
1581
|
-
* leaving the entry parked with `isInitialDataReceived === false` forever.
|
|
1582
|
-
*/
|
|
1583
|
-
private sendCollectionSubscribe(subscriptionKey: string): void {
|
|
1584
|
-
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1585
|
-
if (!subscription) return;
|
|
1586
|
-
|
|
1587
|
-
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1588
|
-
subscription.subscribeInFlight = true;
|
|
1589
|
-
|
|
1590
|
-
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1591
|
-
subscription.subscribeTimeout = undefined;
|
|
1592
|
-
// Only time out a frame that is actually on the wire. While offline the
|
|
1593
|
-
// message just sits in the queue, and reconnect backoff can exceed the
|
|
1594
|
-
// timeout — `armPendingSubscribeWatchdogs` picks these up on connect.
|
|
1595
|
-
if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
|
|
1596
|
-
|
|
1597
|
-
this.sendMessage({
|
|
1598
|
-
type: "subscribe_collection",
|
|
1599
|
-
payload: {
|
|
1600
|
-
...subscription.props,
|
|
1601
|
-
subscriptionId: backendSubscriptionId
|
|
1602
|
-
}
|
|
1603
|
-
}).catch(error => {
|
|
1604
|
-
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
1605
|
-
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1606
|
-
this.failCollectionSubscription(
|
|
1607
|
-
subscriptionKey,
|
|
1608
|
-
error instanceof Error ? error : new Error(String(error))
|
|
1609
|
-
);
|
|
1610
|
-
});
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
/** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
|
|
1614
|
-
private sendEntitySubscribe(subscriptionKey: string): void {
|
|
1615
|
-
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1616
|
-
if (!subscription) return;
|
|
1617
|
-
|
|
1618
|
-
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1619
|
-
subscription.subscribeInFlight = true;
|
|
1620
|
-
|
|
1621
|
-
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1622
|
-
subscription.subscribeTimeout = undefined;
|
|
1623
|
-
if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
|
|
1624
|
-
|
|
1625
|
-
this.sendMessage({
|
|
1626
|
-
type: "subscribe_one",
|
|
1627
|
-
payload: {
|
|
1628
|
-
...subscription.props,
|
|
1629
|
-
subscriptionId: backendSubscriptionId
|
|
1630
|
-
}
|
|
1631
|
-
}).catch(error => {
|
|
1632
|
-
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
1633
|
-
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1634
|
-
this.failEntitySubscription(
|
|
1635
|
-
subscriptionKey,
|
|
1636
|
-
error instanceof Error ? error : new Error(String(error))
|
|
1637
|
-
);
|
|
1638
|
-
});
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
/**
|
|
1642
|
-
* Report a subscribe failure to every listener and drop the registration.
|
|
1643
|
-
*
|
|
1644
|
-
* Dropping it is the point: the callbacks stay live (their components are
|
|
1645
|
-
* still mounted and have been told), but the next `listenCollection` for
|
|
1646
|
-
* these params finds no entry and issues a fresh subscribe instead of
|
|
1647
|
-
* silently attaching to a dead one.
|
|
1648
|
-
*/
|
|
1649
|
-
private failCollectionSubscription(subscriptionKey: string, error: Error): void {
|
|
1650
|
-
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1651
|
-
if (!subscription) return;
|
|
1652
|
-
|
|
1653
|
-
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1654
|
-
subscription.subscribeInFlight = false;
|
|
1655
|
-
|
|
1656
|
-
this.collectionSubscriptions.delete(subscriptionKey);
|
|
1657
|
-
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
1658
|
-
|
|
1659
|
-
subscription.callbacks.forEach(callback => {
|
|
1660
|
-
if (callback.onError) {
|
|
1661
|
-
try {
|
|
1662
|
-
callback.onError(error);
|
|
1663
|
-
} catch (callbackError) {
|
|
1664
|
-
console.error("Error in collection subscription error callback:", callbackError);
|
|
1665
|
-
}
|
|
1666
|
-
}
|
|
1667
|
-
});
|
|
1668
|
-
}
|
|
1669
|
-
|
|
1670
|
-
/** The `listenOne` counterpart of {@link failCollectionSubscription}. */
|
|
1671
|
-
private failEntitySubscription(subscriptionKey: string, error: Error): void {
|
|
1672
|
-
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1673
|
-
if (!subscription) return;
|
|
1674
|
-
|
|
1675
|
-
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
1676
|
-
subscription.subscribeInFlight = false;
|
|
1677
|
-
|
|
1678
|
-
this.singleSubscriptions.delete(subscriptionKey);
|
|
1679
|
-
this.backendToEntityKey.delete(subscription.backendSubscriptionId);
|
|
1680
|
-
|
|
1681
|
-
subscription.callbacks.forEach(callback => {
|
|
1682
|
-
if (callback.onError) {
|
|
1683
|
-
try {
|
|
1684
|
-
callback.onError(error);
|
|
1685
|
-
} catch (callbackError) {
|
|
1686
|
-
console.error("Error in row subscription error callback:", callbackError);
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
|
-
});
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
/**
|
|
1693
|
-
* Stop the watchdogs without failing anything — used when the socket drops,
|
|
1694
|
-
* since the reconnect path re-subscribes everything anyway and a watchdog
|
|
1695
|
-
* firing mid-reconnect would tear down healthy subscriptions.
|
|
1696
|
-
*/
|
|
1697
|
-
private suspendSubscribeWatchdogs(): void {
|
|
1698
|
-
for (const sub of this.collectionSubscriptions.values()) {
|
|
1699
|
-
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
1700
|
-
sub.subscribeTimeout = undefined;
|
|
1701
|
-
sub.subscribeInFlight = false;
|
|
1702
|
-
}
|
|
1703
|
-
for (const sub of this.singleSubscriptions.values()) {
|
|
1704
|
-
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
1705
|
-
sub.subscribeTimeout = undefined;
|
|
1706
|
-
sub.subscribeInFlight = false;
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
/**
|
|
1711
|
-
* Arm watchdogs for subscribes that were requested while offline and have
|
|
1712
|
-
* just been flushed to the socket. Their timers were deliberately not set at
|
|
1713
|
-
* request time, so without this they would have no timeout at all.
|
|
1714
|
-
*/
|
|
1715
|
-
private armPendingSubscribeWatchdogs(): void {
|
|
1716
|
-
for (const [key, sub] of this.collectionSubscriptions.entries()) {
|
|
1717
|
-
if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
|
|
1718
|
-
}
|
|
1719
|
-
for (const [key, sub] of this.singleSubscriptions.entries()) {
|
|
1720
|
-
if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
|
|
1721
|
-
}
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
private sendCollectionSubscribeWatchdog(subscriptionKey: string): void {
|
|
1725
|
-
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
1726
|
-
if (!subscription) return;
|
|
1727
|
-
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1728
|
-
subscription.subscribeTimeout = setTimeout(() => {
|
|
1729
|
-
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
1730
|
-
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1731
|
-
if (!current.subscribeInFlight) return;
|
|
1732
|
-
this.failCollectionSubscription(
|
|
1733
|
-
subscriptionKey,
|
|
1734
|
-
new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" })
|
|
1735
|
-
);
|
|
1736
|
-
}, this.subscriptionTimeoutMs);
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
private sendEntitySubscribeWatchdog(subscriptionKey: string): void {
|
|
1740
|
-
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
1741
|
-
if (!subscription) return;
|
|
1742
|
-
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
1743
|
-
subscription.subscribeTimeout = setTimeout(() => {
|
|
1744
|
-
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
1745
|
-
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
1746
|
-
if (!current.subscribeInFlight) return;
|
|
1747
|
-
this.failEntitySubscription(
|
|
1748
|
-
subscriptionKey,
|
|
1749
|
-
new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" })
|
|
1750
|
-
);
|
|
1751
|
-
}, this.subscriptionTimeoutMs);
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
/**
|
|
1755
|
-
* Fail every subscription that never received data. Called when reconnection
|
|
1756
|
-
* is given up on, so views surface an error instead of spinning forever.
|
|
1757
|
-
*/
|
|
1758
|
-
private failAllPendingSubscriptions(error: Error): void {
|
|
1759
|
-
for (const key of [...this.collectionSubscriptions.keys()]) {
|
|
1760
|
-
const sub = this.collectionSubscriptions.get(key);
|
|
1761
|
-
if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
|
|
1762
|
-
}
|
|
1763
|
-
for (const key of [...this.singleSubscriptions.keys()]) {
|
|
1764
|
-
const sub = this.singleSubscriptions.get(key);
|
|
1765
|
-
if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
/**
|
|
1770
|
-
* Re-send all active subscriptions to the backend after a reconnect.
|
|
1771
|
-
* The server wipes subscription state when a client disconnects, so
|
|
1772
|
-
* we need to re-register everything to resume receiving updates.
|
|
1773
|
-
*/
|
|
1774
|
-
private resubscribeAll(): void {
|
|
1775
|
-
console.debug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.singleSubscriptions.size} row(ies)`);
|
|
1776
|
-
|
|
1777
|
-
// Re-subscribe collection subscriptions
|
|
1778
|
-
for (const [key, sub] of this.collectionSubscriptions.entries()) {
|
|
1779
|
-
// Generate a fresh backend ID since the old one is no longer valid on the server
|
|
1780
|
-
const oldBackendId = sub.backendSubscriptionId;
|
|
1781
|
-
const newBackendId = `collection_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1782
|
-
sub.backendSubscriptionId = newBackendId;
|
|
1783
|
-
|
|
1784
|
-
// Update reverse lookup
|
|
1785
|
-
this.backendToCollectionKey.delete(oldBackendId);
|
|
1786
|
-
this.backendToCollectionKey.set(newBackendId, key);
|
|
1787
|
-
|
|
1788
|
-
this.sendCollectionSubscribe(key);
|
|
1789
|
-
}
|
|
1790
|
-
|
|
1791
|
-
// Re-subscribe row subscriptions
|
|
1792
|
-
for (const [key, sub] of this.singleSubscriptions.entries()) {
|
|
1793
|
-
const oldBackendId = sub.backendSubscriptionId;
|
|
1794
|
-
const newBackendId = `entity_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1795
|
-
sub.backendSubscriptionId = newBackendId;
|
|
1796
|
-
|
|
1797
|
-
this.backendToEntityKey.delete(oldBackendId);
|
|
1798
|
-
this.backendToEntityKey.set(newBackendId, key);
|
|
1799
|
-
|
|
1800
|
-
this.sendEntitySubscribe(key);
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
private createCollectionSubscriptionKey(props: FetchCollectionProps): string {
|
|
1805
|
-
// Derived from the props, not a hand-listed subset of them.
|
|
1806
|
-
//
|
|
1807
|
-
// Two subscriptions share one server subscription when their keys
|
|
1808
|
-
// match, so a field left off the key makes two different queries
|
|
1809
|
-
// collide and hands the second listener the first one's rows. `offset`
|
|
1810
|
-
// and `logical` were both missing: page two of a live list showed page
|
|
1811
|
-
// one, and two views filtered by different `or(...)` groups saw the
|
|
1812
|
-
// same rows. Listing fields by hand is what let that happen, so the key
|
|
1813
|
-
// now covers whatever `FetchCollectionProps` carries.
|
|
1814
|
-
//
|
|
1815
|
-
// `collection` is the exception: it is the whole collection config,
|
|
1816
|
-
// property thunks and all, so it contributes its name as before.
|
|
1817
|
-
const { collection, ...query } = props as FetchCollectionProps & Record<string, unknown>;
|
|
1818
|
-
const key = {
|
|
1819
|
-
...query,
|
|
1820
|
-
collection: collection?.name
|
|
1821
|
-
};
|
|
1822
|
-
// Use replacer function (not array) to sort keys at all levels for deterministic output
|
|
1823
|
-
return JSON.stringify(key, (_, value) => {
|
|
1824
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
1825
|
-
return Object.keys(value).sort().reduce((sorted: Record<string, unknown>, k) => {
|
|
1826
|
-
sorted[k] = value[k];
|
|
1827
|
-
return sorted;
|
|
1828
|
-
}, {});
|
|
1829
|
-
}
|
|
1830
|
-
return value;
|
|
1831
|
-
});
|
|
1832
|
-
}
|
|
1833
|
-
|
|
1834
|
-
private createSingleSubscriptionKey(props: FetchOneProps): string {
|
|
1835
|
-
return `${props.path}|${props.id}`;
|
|
1836
|
-
}
|
|
1837
|
-
}
|