@rebasepro/server-mongo 0.14.0 → 0.14.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/dist/auth/services.d.ts.map +1 -1
- package/dist/db/MongoConditionBuilder.d.ts +28 -4
- package/dist/db/MongoConditionBuilder.d.ts.map +1 -1
- package/dist/db/MongoDataService.d.ts +3 -3
- package/dist/db/MongoDataService.d.ts.map +1 -1
- package/dist/index.es.js +132 -48
- package/dist/index.es.js.map +1 -1
- package/dist/services/MongoDriver.d.ts +1 -1
- package/dist/services/MongoDriver.d.ts.map +1 -1
- package/dist/services/MongoRealtimeService.d.ts +36 -0
- package/dist/services/MongoRealtimeService.d.ts.map +1 -1
- package/dist/{websocket-Bs54-gR-.js → websocket-Do3I42Uc.js} +22 -2
- package/dist/websocket-Do3I42Uc.js.map +1 -0
- package/dist/websocket.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/auth/services.ts +60 -9
- package/src/db/MongoConditionBuilder.ts +46 -7
- package/src/db/MongoDataService.ts +3 -3
- package/src/services/MongoDriver.ts +14 -23
- package/src/services/MongoRealtimeService.ts +116 -28
- package/src/websocket.ts +25 -1
- package/dist/websocket-Bs54-gR-.js.map +0 -1
|
@@ -29,6 +29,22 @@ export interface SubscriptionAuthContext {
|
|
|
29
29
|
roles: string[];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The query half of a subscription config — everything except who is watching.
|
|
34
|
+
*
|
|
35
|
+
* Spread into the re-fetch rather than re-listed field by field. Re-listing is
|
|
36
|
+
* how `logical` and `offset` went missing twice on this path: the type declares
|
|
37
|
+
* them, every boundary accepted them, and each hand-written list quietly named
|
|
38
|
+
* a subset. A function that removes the two non-query fields cannot fall behind
|
|
39
|
+
* the type the way a list of the other nine can.
|
|
40
|
+
*/
|
|
41
|
+
const queryOf = <T extends { clientId: string; authContext?: SubscriptionAuthContext }>(
|
|
42
|
+
config: T
|
|
43
|
+
): Omit<T, "clientId" | "authContext"> => {
|
|
44
|
+
const { clientId: _clientId, authContext: _authContext, ...query } = config;
|
|
45
|
+
return query;
|
|
46
|
+
};
|
|
47
|
+
|
|
32
48
|
interface Subscription {
|
|
33
49
|
type: "collection" | "single";
|
|
34
50
|
/**
|
|
@@ -40,6 +56,22 @@ interface Subscription {
|
|
|
40
56
|
config: (CollectionSubscriptionConfig | SingleSubscriptionConfig) & { authContext?: SubscriptionAuthContext };
|
|
41
57
|
changeStream?: ChangeStream;
|
|
42
58
|
callback?: (data: any) => void;
|
|
59
|
+
/**
|
|
60
|
+
* How many deliveries have been started for this subscription, and the
|
|
61
|
+
* highest that has already reached the callback.
|
|
62
|
+
*
|
|
63
|
+
* Every delivery here is a re-fetch, and three independent things start one
|
|
64
|
+
* for the same subscription: the initial fetch, the change stream, and
|
|
65
|
+
* `notifyUpdate` after a save. They overlap, and a fetch that started
|
|
66
|
+
* earlier can finish later — at which point the callback replaces the
|
|
67
|
+
* client's whole list with the state before the change. Nothing corrects it
|
|
68
|
+
* until something else happens to that collection.
|
|
69
|
+
*
|
|
70
|
+
* A counter taken before the await and checked after it is what makes the
|
|
71
|
+
* last *started* delivery the last *delivered* one.
|
|
72
|
+
*/
|
|
73
|
+
started: number;
|
|
74
|
+
delivered: number;
|
|
43
75
|
}
|
|
44
76
|
|
|
45
77
|
/**
|
|
@@ -66,6 +98,35 @@ export class MongoRealtimeService implements RealtimeProvider {
|
|
|
66
98
|
return path.replace(/\//g, "_");
|
|
67
99
|
}
|
|
68
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Claim a delivery slot for a subscription, before doing the work.
|
|
103
|
+
*
|
|
104
|
+
* Returns the check to run immediately before calling the callback. It
|
|
105
|
+
* refuses in three cases, all of which used to deliver:
|
|
106
|
+
*
|
|
107
|
+
* - **Out of order.** A newer fetch has already delivered, so this one is
|
|
108
|
+
* stale — the client would go back to the state before the change.
|
|
109
|
+
* - **Unsubscribed.** The subscription was cancelled while the fetch was in
|
|
110
|
+
* flight, and its callback belongs to a client that stopped listening.
|
|
111
|
+
* - **Re-subscribed.** `subscribeToCollection` unsubscribes first, so the
|
|
112
|
+
* same id can name a *different* subscription by the time a fetch lands —
|
|
113
|
+
* with a different filter, and a different caller's rows.
|
|
114
|
+
*
|
|
115
|
+
* Synchronous deliveries claim a slot too. A `delete` notification with no
|
|
116
|
+
* fetch behind it is the newest thing known about the row, so it must also
|
|
117
|
+
* be the thing that closes the door on an older fetch still in flight —
|
|
118
|
+
* otherwise the deleted row reappears a moment after it vanished.
|
|
119
|
+
*/
|
|
120
|
+
private beginDelivery(subscriptionId: string, subscription: Subscription): () => boolean {
|
|
121
|
+
const seq = ++subscription.started;
|
|
122
|
+
return () => {
|
|
123
|
+
if (this.subscriptions.get(subscriptionId) !== subscription) return false;
|
|
124
|
+
if (seq <= subscription.delivered) return false;
|
|
125
|
+
subscription.delivered = seq;
|
|
126
|
+
return true;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
69
130
|
/**
|
|
70
131
|
* Subscribe to collection changes
|
|
71
132
|
*/
|
|
@@ -100,19 +161,21 @@ export class MongoRealtimeService implements RealtimeProvider {
|
|
|
100
161
|
type: "collection",
|
|
101
162
|
config,
|
|
102
163
|
changeStream,
|
|
103
|
-
callback
|
|
164
|
+
callback,
|
|
165
|
+
started: 0,
|
|
166
|
+
delivered: 0
|
|
104
167
|
};
|
|
105
168
|
|
|
106
169
|
this.subscriptions.set(subscriptionId, subscription);
|
|
107
170
|
|
|
108
171
|
// Fetch initial data
|
|
109
|
-
this.fetchAndNotifyCollection(subscriptionId,
|
|
172
|
+
this.fetchAndNotifyCollection(subscriptionId, subscription);
|
|
110
173
|
|
|
111
174
|
// Listen for changes
|
|
112
175
|
changeStream.on("change", async (change: ChangeStreamDocument) => {
|
|
113
176
|
// Re-fetch the entire collection when any change happens
|
|
114
177
|
// This is simpler and ensures consistent sorting/filtering
|
|
115
|
-
await this.fetchAndNotifyCollection(subscriptionId,
|
|
178
|
+
await this.fetchAndNotifyCollection(subscriptionId, subscription);
|
|
116
179
|
});
|
|
117
180
|
|
|
118
181
|
changeStream.on("error", (error: Error) => {
|
|
@@ -127,13 +190,15 @@ export class MongoRealtimeService implements RealtimeProvider {
|
|
|
127
190
|
const subscription: Subscription = {
|
|
128
191
|
type: "collection",
|
|
129
192
|
config,
|
|
130
|
-
callback
|
|
193
|
+
callback,
|
|
194
|
+
started: 0,
|
|
195
|
+
delivered: 0
|
|
131
196
|
};
|
|
132
197
|
|
|
133
198
|
this.subscriptions.set(subscriptionId, subscription);
|
|
134
199
|
|
|
135
200
|
// Fetch initial data
|
|
136
|
-
this.fetchAndNotifyCollection(subscriptionId,
|
|
201
|
+
this.fetchAndNotifyCollection(subscriptionId, subscription);
|
|
137
202
|
}
|
|
138
203
|
}
|
|
139
204
|
|
|
@@ -142,9 +207,11 @@ export class MongoRealtimeService implements RealtimeProvider {
|
|
|
142
207
|
*/
|
|
143
208
|
private async fetchAndNotifyCollection(
|
|
144
209
|
subscriptionId: string,
|
|
145
|
-
|
|
146
|
-
callback?: (rows: Record<string, unknown>[]) => void
|
|
210
|
+
subscription: Subscription
|
|
147
211
|
): Promise<void> {
|
|
212
|
+
const config = subscription.config as CollectionSubscriptionConfig & { authContext?: SubscriptionAuthContext };
|
|
213
|
+
const callback = subscription.callback;
|
|
214
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
148
215
|
try {
|
|
149
216
|
const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
|
|
150
217
|
// One path, authenticated or not. The `else` branch used to reach
|
|
@@ -155,18 +222,17 @@ export class MongoRealtimeService implements RealtimeProvider {
|
|
|
155
222
|
// against the anonymous uid, and a rule that needs a real one
|
|
156
223
|
// matches nothing.
|
|
157
224
|
const driver = await this.scopedDriver(config.authContext);
|
|
225
|
+
// The stored config forwarded whole. Re-listing its fields here is
|
|
226
|
+
// how `logical` and `offset` went missing a second time, one layer
|
|
227
|
+
// below where they went missing the first time: the subscription
|
|
228
|
+
// carried them and the re-fetch did not ask for them.
|
|
158
229
|
const rows = await driver.fetchCollection({
|
|
159
|
-
|
|
160
|
-
collection: registryCollection,
|
|
230
|
+
...queryOf(config),
|
|
161
231
|
filter: config.filter as FilterValues<string> | undefined,
|
|
162
|
-
|
|
163
|
-
order: config.order,
|
|
164
|
-
limit: config.limit,
|
|
165
|
-
startAfter: config.startAfter,
|
|
166
|
-
searchString: config.searchString
|
|
232
|
+
collection: registryCollection
|
|
167
233
|
});
|
|
168
234
|
|
|
169
|
-
if (callback) {
|
|
235
|
+
if (callback && canDeliver()) {
|
|
170
236
|
callback(rows);
|
|
171
237
|
}
|
|
172
238
|
} catch (error) {
|
|
@@ -226,22 +292,28 @@ roles: authContext?.roles ?? [] } as User;
|
|
|
226
292
|
type: "single",
|
|
227
293
|
config,
|
|
228
294
|
changeStream,
|
|
229
|
-
callback
|
|
295
|
+
callback,
|
|
296
|
+
started: 0,
|
|
297
|
+
delivered: 0
|
|
230
298
|
};
|
|
231
299
|
|
|
232
300
|
this.subscriptions.set(subscriptionId, subscription);
|
|
233
301
|
|
|
234
302
|
// Fetch initial data
|
|
235
|
-
this.fetchAndNotifyOne(subscriptionId,
|
|
303
|
+
this.fetchAndNotifyOne(subscriptionId, subscription);
|
|
236
304
|
|
|
237
305
|
// Listen for changes
|
|
238
306
|
changeStream.on("change", async (change: ChangeStreamDocument) => {
|
|
239
307
|
if (change.operationType === "delete") {
|
|
240
|
-
|
|
308
|
+
// Claims a slot like any other delivery: the deletion is the
|
|
309
|
+
// newest fact about this row, so an older fetch still in
|
|
310
|
+
// flight must not put it back.
|
|
311
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
312
|
+
if (callback && canDeliver()) {
|
|
241
313
|
callback(null);
|
|
242
314
|
}
|
|
243
315
|
} else {
|
|
244
|
-
await this.fetchAndNotifyOne(subscriptionId,
|
|
316
|
+
await this.fetchAndNotifyOne(subscriptionId, subscription);
|
|
245
317
|
}
|
|
246
318
|
});
|
|
247
319
|
|
|
@@ -255,13 +327,15 @@ roles: authContext?.roles ?? [] } as User;
|
|
|
255
327
|
const subscription: Subscription = {
|
|
256
328
|
type: "single",
|
|
257
329
|
config,
|
|
258
|
-
callback
|
|
330
|
+
callback,
|
|
331
|
+
started: 0,
|
|
332
|
+
delivered: 0
|
|
259
333
|
};
|
|
260
334
|
|
|
261
335
|
this.subscriptions.set(subscriptionId, subscription);
|
|
262
336
|
|
|
263
337
|
// Fetch initial data
|
|
264
|
-
this.fetchAndNotifyOne(subscriptionId,
|
|
338
|
+
this.fetchAndNotifyOne(subscriptionId, subscription);
|
|
265
339
|
}
|
|
266
340
|
}
|
|
267
341
|
|
|
@@ -270,9 +344,11 @@ roles: authContext?.roles ?? [] } as User;
|
|
|
270
344
|
*/
|
|
271
345
|
private async fetchAndNotifyOne(
|
|
272
346
|
subscriptionId: string,
|
|
273
|
-
|
|
274
|
-
callback?: (row: Record<string, unknown> | null) => void
|
|
347
|
+
subscription: Subscription
|
|
275
348
|
): Promise<void> {
|
|
349
|
+
const config = subscription.config as SingleSubscriptionConfig & { authContext?: SubscriptionAuthContext };
|
|
350
|
+
const callback = subscription.callback;
|
|
351
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
276
352
|
try {
|
|
277
353
|
const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
|
|
278
354
|
const driver = await this.scopedDriver(config.authContext);
|
|
@@ -282,7 +358,7 @@ roles: authContext?.roles ?? [] } as User;
|
|
|
282
358
|
collection: registryCollection
|
|
283
359
|
});
|
|
284
360
|
|
|
285
|
-
if (callback) {
|
|
361
|
+
if (callback && canDeliver()) {
|
|
286
362
|
callback(row || null);
|
|
287
363
|
}
|
|
288
364
|
} catch (error) {
|
|
@@ -319,21 +395,24 @@ roles: authContext?.roles ?? [] } as User;
|
|
|
319
395
|
const config = subscription.config as SingleSubscriptionConfig & { authContext?: SubscriptionAuthContext };
|
|
320
396
|
if (config.path === path && config.id.toString() === id) {
|
|
321
397
|
if (row === null) {
|
|
322
|
-
// A deletion carries no row to authorize
|
|
323
|
-
|
|
398
|
+
// A deletion carries no row to authorize — but it still
|
|
399
|
+
// claims a delivery slot, so a re-fetch already in
|
|
400
|
+
// flight cannot land after it and resurrect the row.
|
|
401
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
402
|
+
if (canDeliver()) subscription.callback?.(null);
|
|
324
403
|
} else {
|
|
325
404
|
// Re-fetched through the subscriber's own driver rather
|
|
326
405
|
// than pushed verbatim: `notifyUpdate` runs after every
|
|
327
406
|
// save, and handing it the row as written broadcast any
|
|
328
407
|
// document to whoever happened to be watching its id.
|
|
329
|
-
await this.fetchAndNotifyOne(subscriptionId,
|
|
408
|
+
await this.fetchAndNotifyOne(subscriptionId, subscription);
|
|
330
409
|
}
|
|
331
410
|
}
|
|
332
411
|
} else if (subscription.type === "collection") {
|
|
333
412
|
const config = subscription.config as CollectionSubscriptionConfig & { authContext?: SubscriptionAuthContext };
|
|
334
413
|
if (config.path === path) {
|
|
335
414
|
// Re-fetch the collection to get updated data
|
|
336
|
-
await this.fetchAndNotifyCollection(subscriptionId,
|
|
415
|
+
await this.fetchAndNotifyCollection(subscriptionId, subscription);
|
|
337
416
|
}
|
|
338
417
|
}
|
|
339
418
|
}
|
|
@@ -429,11 +508,20 @@ roles: (_authContext.roles ?? []).map(String) } : undefined;
|
|
|
429
508
|
clientId,
|
|
430
509
|
path: message.payload?.path,
|
|
431
510
|
filter: message.payload?.filter,
|
|
511
|
+
// `logical` and `offset` were absent from this list, so
|
|
512
|
+
// an `or(...)` subscription was pushed every row the
|
|
513
|
+
// caller's policies allowed and a subscription to page
|
|
514
|
+
// two was pushed page one. The client has been sending
|
|
515
|
+
// both since it stopped stringifying `offset` into
|
|
516
|
+
// `startAfter`; nothing here read them.
|
|
517
|
+
logical: message.payload?.logical,
|
|
518
|
+
offset: message.payload?.offset,
|
|
432
519
|
orderBy: message.payload?.orderBy,
|
|
433
520
|
order: message.payload?.order,
|
|
434
521
|
limit: boundedLimit,
|
|
435
522
|
startAfter: message.payload?.startAfter,
|
|
436
523
|
searchString: message.payload?.searchString,
|
|
524
|
+
searchExplain: message.payload?.searchExplain,
|
|
437
525
|
authContext
|
|
438
526
|
},
|
|
439
527
|
(rows) => {
|
package/src/websocket.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { RealtimeProvider, DataDriver, FetchCollectionProps, FetchOneProps, Save
|
|
|
2
2
|
import { WebSocketServer, WebSocket } from "ws";
|
|
3
3
|
import { Server } from "http";
|
|
4
4
|
import { inspect } from "util";
|
|
5
|
-
import { extractUserFromToken, resolveRequireAuth } from "@rebasepro/server";
|
|
5
|
+
import { extractUserFromToken, resolveRequireAuth, assertWriteRequestValid, ApiError } from "@rebasepro/server";
|
|
6
6
|
import type { RebaseAuthConfig } from "@rebasepro/server";
|
|
7
7
|
import { MongoRealtimeService } from "./services/MongoRealtimeService";
|
|
8
8
|
import { MongoDriver } from "./services/MongoDriver";
|
|
@@ -216,6 +216,14 @@ roles: verifiedUser.roles } }));
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
/** @see the Postgres socket — same rule, same reason. */
|
|
220
|
+
const assertWriteRequest = (path: string | undefined, values: unknown): void => {
|
|
221
|
+
if (!path || !values || typeof values !== "object") return;
|
|
222
|
+
const collection = driver.registry?.getCollectionByPath(path);
|
|
223
|
+
if (!collection) return;
|
|
224
|
+
assertWriteRequestValid(values as Record<string, unknown>, collection);
|
|
225
|
+
};
|
|
226
|
+
|
|
219
227
|
const getScopedDelegate = async (): Promise<DataDriver> => {
|
|
220
228
|
const session = clientSessions.get(clientId);
|
|
221
229
|
if (session?.user && isDriverWithAuth(driver)) {
|
|
@@ -259,6 +267,11 @@ requestId }));
|
|
|
259
267
|
}
|
|
260
268
|
case "SAVE": {
|
|
261
269
|
const request: SaveProps = payload;
|
|
270
|
+
// The REST layer's write checks, at this boundary too —
|
|
271
|
+
// the socket is a second way in, and it used to be the
|
|
272
|
+
// unchecked one. Collection from the registry by path,
|
|
273
|
+
// never from the client's `request.collection`.
|
|
274
|
+
assertWriteRequest(request.path, request.values as Record<string, unknown>);
|
|
262
275
|
const delegate = await getScopedDelegate();
|
|
263
276
|
const row = await delegate.save(request);
|
|
264
277
|
ws.send(JSON.stringify({ type: "SAVE_SUCCESS",
|
|
@@ -352,6 +365,17 @@ roles: session.user.roles ?? [] } : undefined;
|
|
|
352
365
|
logger.error("❌ [WebSocket Server] Unknown message type", { detail: type });
|
|
353
366
|
}
|
|
354
367
|
} catch (error: unknown) {
|
|
368
|
+
// A refused write keeps its message: it is the only thing that
|
|
369
|
+
// tells the caller what to send instead, and the generic branch
|
|
370
|
+
// below drops it in production.
|
|
371
|
+
if (error instanceof ApiError || (error as Error)?.name === "ApiError") {
|
|
372
|
+
const apiError = error as ApiError;
|
|
373
|
+
ws.send(JSON.stringify({ type: "ERROR",
|
|
374
|
+
requestId,
|
|
375
|
+
payload: { error: { message: apiError.message,
|
|
376
|
+
code: apiError.code } } }));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
355
379
|
const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : (error instanceof Error ? error.message : "An unexpected error occurred");
|
|
356
380
|
ws.send(JSON.stringify({ type: "ERROR",
|
|
357
381
|
requestId,
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"websocket-Bs54-gR-.js","names":[],"sources":["../src/websocket.ts"],"sourcesContent":["import { RealtimeProvider, DataDriver, FetchCollectionProps, FetchOneProps, SaveProps, DeleteProps, TableMetadata, DatabaseAdmin, isSchemaAdmin, isDocumentAdmin, User, AuthAdapter } from \"@rebasepro/types\";\nimport { WebSocketServer, WebSocket } from \"ws\";\nimport { Server } from \"http\";\nimport { inspect } from \"util\";\nimport { extractUserFromToken, resolveRequireAuth } from \"@rebasepro/server\";\nimport type { RebaseAuthConfig } from \"@rebasepro/server\";\nimport { MongoRealtimeService } from \"./services/MongoRealtimeService\";\nimport { MongoDriver } from \"./services/MongoDriver\";\nimport { logger } from \"@rebasepro/server\";\n\ninterface DriverWithAuth extends DataDriver {\n withAuth(user: Record<string, unknown>): Promise<DataDriver>;\n}\n\nfunction isDriverWithAuth(driver: DataDriver): driver is DriverWithAuth {\n return \"withAuth\" in driver && typeof (driver as Record<string, unknown>).withAuth === \"function\";\n}\n\n/**\n * Normalized user identity for WebSocket sessions — the same shape the Postgres\n * socket keeps, because an `AuthAdapter` user is not an access-token payload.\n */\ninterface WsUserIdentity {\n uid: string;\n email?: string;\n displayName?: string;\n photoURL?: string;\n roles: string[];\n isAdmin: boolean;\n}\n\ninterface ClientSession {\n ws: WebSocket;\n user?: WsUserIdentity;\n authenticated: boolean;\n messageCount: number;\n messageWindowStart: number;\n}\n\nconst WS_RATE_LIMIT = 2000;\nconst WS_RATE_WINDOW_MS = 60_000;\n\nconst ADMIN_ONLY_TYPES = new Set([\n \"EXECUTE_SQL\",\n \"FETCH_DATABASES\",\n \"FETCH_ROLES\",\n \"FETCH_UNMAPPED_TABLES\",\n \"FETCH_TABLE_METADATA\",\n \"FETCH_CURRENT_DATABASE\",\n \"CREATE_BRANCH\",\n \"DELETE_BRANCH\",\n \"LIST_BRANCHES\"\n]);\n\nfunction isAdminSession(session: ClientSession | undefined): boolean {\n if (!session?.user) return false;\n // The adapter's own answer first; a role *named* `admin` is only the\n // fallback for the built-in JWT path.\n if (session.user.isAdmin) return true;\n return (session.user.roles ?? []).some((r) => r === \"admin\");\n}\n\nexport function createMongoWebSocket(\n server: Server,\n realtimeService: MongoRealtimeService,\n driver: MongoDriver,\n authConfig?: RebaseAuthConfig,\n admin?: DatabaseAdmin,\n authAdapter?: AuthAdapter\n) {\n // Scoped to this factory invocation rather than the module, so sessions do\n // not leak across hot reloads or a second server on the same process — the\n // Postgres socket keeps it here for the same reason.\n const clientSessions = new Map<string, ClientSession>();\n\n const isProduction = process.env.NODE_ENV === \"production\";\n const wsDebug = (...args: unknown[]) => { if (!isProduction) console.debug(...args); };\n const wss = new WebSocketServer({ server });\n\n wss.on(\"error\", (err: NodeJS.ErrnoException) => {\n if (err.code === \"EADDRINUSE\") return;\n logger.error(\"❌ [WebSocket Server] Error\", { error: err });\n });\n\n // The same predicate the HTTP data routes use, from the same function. See\n // `resolveRequireAuth` for what this socket's local copy got wrong — most\n // importantly that a `false` here does not skip a check, it marks every\n // session `authenticated` at connect time.\n const requireAuth = !!authAdapter || resolveRequireAuth(authConfig);\n\n if (requireAuth && !authAdapter && !authConfig?.jwtSecret) {\n logger.warn(\n \"🔐 [WebSocket Server] Authentication is required but no adapter or jwtSecret is \" +\n \"configured — no client can complete AUTH, so every realtime message will be refused.\"\n );\n }\n\n wss.on(\"connection\", (ws) => {\n const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;\n wsDebug(`WebSocket client connected: ${clientId}`);\n\n clientSessions.set(clientId, { ws,\nauthenticated: !requireAuth,\nmessageCount: 0,\nmessageWindowStart: Date.now() });\n realtimeService.addClient(clientId, ws);\n\n ws.on(\"close\", () => {\n wsDebug(`WebSocket client disconnected: ${clientId}`);\n clientSessions.delete(clientId);\n });\n\n ws.on(\"message\", async (message) => {\n let requestId: string | undefined;\n try {\n const { type, payload, requestId: reqId } = JSON.parse(message.toString());\n requestId = reqId;\n\n wsDebug(`[WS] ${clientId} → ${type}`, requestId ? `(${requestId})` : \"\");\n\n const sendError = (errType: \"ERROR\" | \"AUTH_ERROR\", code: string, msg: string) => {\n ws.send(JSON.stringify({ type: errType,\nrequestId,\npayload: { error: { message: msg,\ncode } } }));\n };\n\n if (type === \"AUTHENTICATE\") {\n const { token } = payload || {};\n if (!token) {\n sendError(\"AUTH_ERROR\", \"INVALID_INPUT\", \"Token is required\");\n return;\n }\n\n // The adapter verifies when one is configured, exactly as the\n // HTTP routes do; the built-in JWT path is the fallback.\n let verifiedUser: WsUserIdentity | null = null;\n\n if (authAdapter) {\n try {\n const adapterUser = authAdapter.verifyToken\n ? await authAdapter.verifyToken(token)\n : await authAdapter.verifyRequest(new Request(\"http://localhost/_ws_auth\", {\n headers: { Authorization: `Bearer ${token}` }\n }));\n if (adapterUser) {\n verifiedUser = {\n uid: adapterUser.uid,\n email: adapterUser.email,\n roles: adapterUser.roles ?? [],\n isAdmin: !!adapterUser.isAdmin\n };\n }\n } catch {\n // Adapter threw — treat as invalid token\n }\n } else {\n const jwtPayload = extractUserFromToken(token);\n if (jwtPayload) {\n verifiedUser = {\n uid: jwtPayload.uid,\n email: jwtPayload.email,\n displayName: jwtPayload.displayName,\n photoURL: jwtPayload.photoURL,\n roles: jwtPayload.roles ?? [],\n isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === \"admin\")\n };\n }\n }\n\n if (verifiedUser) {\n const session = clientSessions.get(clientId);\n if (session) {\n session.user = verifiedUser;\n session.authenticated = true;\n }\n ws.send(JSON.stringify({ type: \"AUTH_SUCCESS\",\nrequestId,\npayload: { uid: verifiedUser.uid,\nroles: verifiedUser.roles } }));\n } else {\n sendError(\"AUTH_ERROR\", \"INVALID_TOKEN\", \"Invalid or expired token\");\n }\n return;\n }\n\n if (requireAuth) {\n const session = clientSessions.get(clientId);\n if (!session?.authenticated) {\n sendError(\"ERROR\", \"UNAUTHORIZED\", \"Authentication required\");\n return;\n }\n }\n\n {\n const session = clientSessions.get(clientId);\n if (session) {\n const now = Date.now();\n if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {\n session.messageCount = 0;\n session.messageWindowStart = now;\n }\n session.messageCount++;\n if (session.messageCount > WS_RATE_LIMIT) {\n sendError(\"ERROR\", \"RATE_LIMITED\", \"Too many requests. Please slow down.\");\n return;\n }\n }\n }\n\n if (ADMIN_ONLY_TYPES.has(type)) {\n const session = clientSessions.get(clientId);\n if (!isAdminSession(session)) {\n sendError(\"ERROR\", \"FORBIDDEN\", \"Admin access required for this operation\");\n return;\n }\n }\n\n const getScopedDelegate = async (): Promise<DataDriver> => {\n const session = clientSessions.get(clientId);\n if (session?.user && isDriverWithAuth(driver)) {\n try {\n const userForAuth: User = {\n uid: session.user.uid,\n email: session.user.email ?? \"\",\n displayName: session.user.displayName ?? \"\",\n photoURL: session.user.photoURL ?? \"\",\n providerId: \"jwt\",\n isAnonymous: false,\n roles: session.user.roles ?? []\n };\n return await driver.withAuth(userForAuth);\n } catch (e) {\n logger.error(\"Failed to create authenticated delegate for WS request\", { error: e });\n return driver;\n }\n }\n return driver;\n };\n\n switch (type) {\n case \"FETCH_COLLECTION\": {\n const request: FetchCollectionProps = payload;\n const delegate = await getScopedDelegate();\n const rows = await delegate.fetchCollection(request);\n ws.send(JSON.stringify({ type: \"FETCH_COLLECTION_SUCCESS\",\npayload: { rows },\nrequestId }));\n break;\n }\n case \"FETCH_ONE\": {\n const request: FetchOneProps = payload;\n const delegate = await getScopedDelegate();\n const row = await delegate.fetchOne(request);\n ws.send(JSON.stringify({ type: \"FETCH_ONE_SUCCESS\",\npayload: { row },\nrequestId }));\n break;\n }\n case \"SAVE\": {\n const request: SaveProps = payload;\n const delegate = await getScopedDelegate();\n const row = await delegate.save(request);\n ws.send(JSON.stringify({ type: \"SAVE_SUCCESS\",\npayload: { row },\nrequestId }));\n break;\n }\n case \"DELETE\": {\n const request: DeleteProps = payload;\n const delegate = await getScopedDelegate();\n await delegate.delete(request);\n ws.send(JSON.stringify({ type: \"DELETE_SUCCESS\",\npayload: { success: true },\nrequestId }));\n break;\n }\n case \"CHECK_UNIQUE_FIELD\": {\n const { path, name, value, id, collection } = payload;\n const delegate = await getScopedDelegate();\n const isUnique = await delegate.checkUniqueField(path, name, value, id, collection);\n ws.send(JSON.stringify({ type: \"CHECK_UNIQUE_FIELD_SUCCESS\",\npayload: { isUnique },\nrequestId }));\n break;\n }\n case \"COUNT\": {\n const request: FetchCollectionProps = payload;\n const delegate = await getScopedDelegate();\n const count = await delegate.count!(request);\n ws.send(JSON.stringify({ type: \"COUNT_SUCCESS\",\npayload: { count },\nrequestId }));\n break;\n }\n case \"EXECUTE_SQL\": {\n const { sql, options } = payload;\n if (admin && isDocumentAdmin(admin) && admin.executeAggregate) {\n const result = await admin.executeAggregate(sql as Record<string, unknown>[]);\n ws.send(JSON.stringify({ type: \"EXECUTE_SQL_SUCCESS\",\npayload: { result },\nrequestId }));\n } else {\n ws.send(JSON.stringify({ type: \"ERROR\",\nrequestId,\npayload: { error: { message: \"SQL execution not supported for this driver\",\ncode: \"NOT_SUPPORTED\" } } }));\n }\n break;\n }\n case \"FETCH_UNMAPPED_TABLES\": {\n if (admin && isSchemaAdmin(admin)) {\n const tables = await admin.fetchUnmappedTables?.(payload?.mappedPaths) || [];\n ws.send(JSON.stringify({ type: \"FETCH_UNMAPPED_TABLES_SUCCESS\",\npayload: { tables },\nrequestId }));\n } else {\n ws.send(JSON.stringify({ type: \"FETCH_UNMAPPED_TABLES_SUCCESS\",\npayload: { tables: [] },\nrequestId }));\n }\n break;\n }\n case \"FETCH_TABLE_METADATA\": {\n const { tableName } = payload;\n if (admin && isSchemaAdmin(admin)) {\n const metadata = await admin.fetchTableMetadata?.(tableName);\n ws.send(JSON.stringify({ type: \"FETCH_TABLE_METADATA_SUCCESS\",\npayload: { metadata },\nrequestId }));\n } else {\n ws.send(JSON.stringify({ type: \"FETCH_TABLE_METADATA_SUCCESS\",\npayload: { metadata: null },\nrequestId }));\n }\n break;\n }\n case \"subscribe_collection\":\n case \"subscribe_one\":\n case \"unsubscribe\": {\n const session = clientSessions.get(clientId);\n const authContext = session?.user ? { uid: session.user.uid,\nroles: session.user.roles ?? [] } : undefined;\n await realtimeService.handleClientMessage(clientId, {\n type,\n payload,\n subscriptionId: payload?.subscriptionId\n }, authContext);\n break;\n }\n default:\n logger.error(\"❌ [WebSocket Server] Unknown message type\", { detail: type });\n }\n } catch (error: unknown) {\n const errorMessage = process.env.NODE_ENV === \"production\" ? \"An unexpected error occurred\" : (error instanceof Error ? error.message : \"An unexpected error occurred\");\n ws.send(JSON.stringify({ type: \"ERROR\",\nrequestId,\npayload: { error: { message: errorMessage,\ncode: \"INTERNAL_ERROR\" } } }));\n }\n });\n });\n}\n"],"mappings":"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"}
|