@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.
@@ -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, config, callback);
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, config, callback);
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, config, callback);
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
- config: CollectionSubscriptionConfig & { authContext?: SubscriptionAuthContext },
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
- path: config.path,
160
- collection: registryCollection,
230
+ ...queryOf(config),
161
231
  filter: config.filter as FilterValues<string> | undefined,
162
- orderBy: config.orderBy,
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, config, callback);
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
- if (callback) {
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, config, callback);
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, config, callback);
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
- config: SingleSubscriptionConfig & { authContext?: SubscriptionAuthContext },
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
- subscription.callback?.(null);
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, config, subscription.callback);
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, config, subscription.callback);
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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"}