@rebasepro/server-postgres 0.0.1-canary.4829d6e

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.
Files changed (121) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +86 -0
  3. package/dist/PostgresAdapter.d.ts +6 -0
  4. package/dist/PostgresBackendDriver.d.ts +150 -0
  5. package/dist/PostgresBootstrapper.d.ts +51 -0
  6. package/dist/auth/ensure-tables.d.ts +10 -0
  7. package/dist/auth/services.d.ts +250 -0
  8. package/dist/backup/backup-cli.d.ts +3 -0
  9. package/dist/backup/backup-cron.d.ts +53 -0
  10. package/dist/backup/backup-service.d.ts +85 -0
  11. package/dist/backup/index.d.ts +12 -0
  12. package/dist/backup/pg-tools.d.ts +110 -0
  13. package/dist/backup/retention.d.ts +35 -0
  14. package/dist/chunk-DSJWtz9O.js +40 -0
  15. package/dist/cli-errors.d.ts +42 -0
  16. package/dist/cli-helpers.d.ts +7 -0
  17. package/dist/cli.d.ts +1 -0
  18. package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
  19. package/dist/connection.d.ts +65 -0
  20. package/dist/data-transformer.d.ts +55 -0
  21. package/dist/databasePoolManager.d.ts +20 -0
  22. package/dist/history/HistoryService.d.ts +71 -0
  23. package/dist/history/ensure-history-table.d.ts +7 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.es.js +23535 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/interfaces.d.ts +18 -0
  28. package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
  29. package/dist/schema/auth-default-policies.d.ts +10 -0
  30. package/dist/schema/auth-schema.d.ts +2376 -0
  31. package/dist/schema/doctor-cli.d.ts +2 -0
  32. package/dist/schema/doctor.d.ts +58 -0
  33. package/dist/schema/dynamic-tables.d.ts +31 -0
  34. package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
  35. package/dist/schema/generate-drizzle-schema.d.ts +1 -0
  36. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  37. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  38. package/dist/schema/introspect-db-inference.d.ts +5 -0
  39. package/dist/schema/introspect-db-logic.d.ts +118 -0
  40. package/dist/schema/introspect-db.d.ts +1 -0
  41. package/dist/schema/introspect-runtime.d.ts +57 -0
  42. package/dist/schema/test-schema.d.ts +24 -0
  43. package/dist/security/policy-drift.d.ts +57 -0
  44. package/dist/security/rls-enforcement.d.ts +122 -0
  45. package/dist/services/BranchService.d.ts +47 -0
  46. package/dist/services/FetchService.d.ts +214 -0
  47. package/dist/services/PersistService.d.ts +39 -0
  48. package/dist/services/RelationService.d.ts +109 -0
  49. package/dist/services/cdc/CdcListener.d.ts +54 -0
  50. package/dist/services/cdc/trigger-cdc.d.ts +64 -0
  51. package/dist/services/collection-helpers.d.ts +38 -0
  52. package/dist/services/dataService.d.ts +110 -0
  53. package/dist/services/index.d.ts +4 -0
  54. package/dist/services/realtimeService.d.ts +298 -0
  55. package/dist/src-Eh-CZosp.js +595 -0
  56. package/dist/src-Eh-CZosp.js.map +1 -0
  57. package/dist/types.d.ts +3 -0
  58. package/dist/utils/drizzle-conditions.d.ts +138 -0
  59. package/dist/utils/pg-array-null-patch.d.ts +16 -0
  60. package/dist/utils/pg-error-utils.d.ts +65 -0
  61. package/dist/utils/table-classification.d.ts +8 -0
  62. package/dist/websocket.d.ts +18 -0
  63. package/package.json +113 -0
  64. package/src/PostgresAdapter.ts +58 -0
  65. package/src/PostgresBackendDriver.ts +1387 -0
  66. package/src/PostgresBootstrapper.ts +581 -0
  67. package/src/auth/ensure-tables.ts +367 -0
  68. package/src/auth/services.ts +1321 -0
  69. package/src/backup/backup-cli.ts +383 -0
  70. package/src/backup/backup-cron.ts +189 -0
  71. package/src/backup/backup-service.ts +299 -0
  72. package/src/backup/index.ts +12 -0
  73. package/src/backup/pg-tools.ts +231 -0
  74. package/src/backup/retention.ts +75 -0
  75. package/src/cli-errors.ts +265 -0
  76. package/src/cli-helpers.ts +196 -0
  77. package/src/cli.ts +786 -0
  78. package/src/collections/PostgresCollectionRegistry.ts +103 -0
  79. package/src/connection.ts +166 -0
  80. package/src/data-transformer.ts +733 -0
  81. package/src/databasePoolManager.ts +87 -0
  82. package/src/history/HistoryService.ts +272 -0
  83. package/src/history/ensure-history-table.ts +46 -0
  84. package/src/index.ts +15 -0
  85. package/src/interfaces.ts +60 -0
  86. package/src/schema/auth-bootstrap-sql.ts +41 -0
  87. package/src/schema/auth-default-policies.ts +125 -0
  88. package/src/schema/auth-schema.ts +233 -0
  89. package/src/schema/doctor-cli.ts +113 -0
  90. package/src/schema/doctor.ts +733 -0
  91. package/src/schema/dynamic-tables.test.ts +302 -0
  92. package/src/schema/dynamic-tables.ts +293 -0
  93. package/src/schema/generate-drizzle-schema-logic.ts +850 -0
  94. package/src/schema/generate-drizzle-schema.ts +131 -0
  95. package/src/schema/generate-postgres-ddl-logic.ts +490 -0
  96. package/src/schema/generate-postgres-ddl.ts +92 -0
  97. package/src/schema/introspect-db-inference.ts +238 -0
  98. package/src/schema/introspect-db-logic.ts +910 -0
  99. package/src/schema/introspect-db.ts +266 -0
  100. package/src/schema/introspect-runtime.test.ts +212 -0
  101. package/src/schema/introspect-runtime.ts +293 -0
  102. package/src/schema/test-schema.ts +11 -0
  103. package/src/security/policy-drift.test.ts +122 -0
  104. package/src/security/policy-drift.ts +159 -0
  105. package/src/security/rls-enforcement.ts +295 -0
  106. package/src/services/BranchService.ts +251 -0
  107. package/src/services/FetchService.ts +1661 -0
  108. package/src/services/PersistService.ts +329 -0
  109. package/src/services/RelationService.ts +1306 -0
  110. package/src/services/cdc/CdcListener.ts +167 -0
  111. package/src/services/cdc/trigger-cdc.ts +169 -0
  112. package/src/services/collection-helpers.ts +151 -0
  113. package/src/services/dataService.ts +246 -0
  114. package/src/services/index.ts +13 -0
  115. package/src/services/realtimeService.ts +1502 -0
  116. package/src/types.ts +4 -0
  117. package/src/utils/drizzle-conditions.ts +1162 -0
  118. package/src/utils/pg-array-null-patch.ts +42 -0
  119. package/src/utils/pg-error-utils.ts +227 -0
  120. package/src/utils/table-classification.ts +16 -0
  121. package/src/websocket.ts +640 -0
@@ -0,0 +1,640 @@
1
+ import { RealtimeService } from "./services/realtimeService";
2
+ import { PostgresBackendDriver } from "./PostgresBackendDriver";
3
+ import type { DataDriver, DeleteProps, FetchCollectionProps, FetchOneProps, SaveProps, TableMetadata, BranchInfo, AuthAdapter } from "@rebasepro/types";
4
+ import { isSQLAdmin, isSchemaAdmin } from "@rebasepro/types";
5
+ import type { User } from "@rebasepro/types";
6
+
7
+ import { WebSocketServer, WebSocket } from "ws";
8
+ import { Server } from "http";
9
+ import { inspect } from "util";
10
+ import { extractUserFromToken, AccessTokenPayload, safeCompare } from "@rebasepro/server";
11
+ import { logger } from "@rebasepro/server";
12
+
13
+ /** Minimal subset of RebaseAuthConfig used by the WebSocket layer. */
14
+ interface WsAuthConfig {
15
+ requireAuth?: boolean;
16
+ jwtSecret?: string;
17
+ /**
18
+ * Same static server-to-server secret the HTTP middleware accepts. Without
19
+ * it here, a service key authenticates over HTTP but not over the socket —
20
+ * so any SDK client using one (scripts, cron, server-to-server) connects,
21
+ * fails realtime auth with "jwt malformed", and silently gets no events.
22
+ */
23
+ serviceKey?: string;
24
+ }
25
+
26
+ /**
27
+ * Normalized user identity for WebSocket sessions.
28
+ */
29
+ interface WsUserIdentity {
30
+ userId: string;
31
+ roles: string[];
32
+ isAdmin: boolean;
33
+ }
34
+
35
+ interface ClientSession {
36
+ ws: WebSocket;
37
+ user?: WsUserIdentity;
38
+ authenticated: boolean;
39
+ /** Sliding window message counter for rate limiting */
40
+ messageCount: number;
41
+ messageWindowStart: number;
42
+ }
43
+
44
+
45
+ /** Maximum messages per client per window */
46
+ const WS_RATE_LIMIT = 2000;
47
+ /** Rate limit window in milliseconds (60 seconds) */
48
+ const WS_RATE_WINDOW_MS = 60_000;
49
+
50
+ /** Admin-only WebSocket message types */
51
+ const ADMIN_ONLY_TYPES = new Set([
52
+ "EXECUTE_SQL",
53
+ "FETCH_DATABASES",
54
+ "FETCH_ROLES",
55
+ "FETCH_UNMAPPED_TABLES",
56
+ "FETCH_TABLE_METADATA",
57
+ "FETCH_CURRENT_DATABASE",
58
+ "CREATE_BRANCH",
59
+ "DELETE_BRANCH",
60
+ "LIST_BRANCHES"
61
+ ]);
62
+
63
+ /**
64
+ * Recursively extract the deepest error message from an error's cause chain (e.g., Drizzle wrapping a PG error).
65
+ */
66
+ function extractErrorMessage(error: unknown): string {
67
+ if (!error) return "Unknown error";
68
+ if (error instanceof Error) {
69
+ if ("cause" in error && error.cause) {
70
+ return extractErrorMessage(error.cause);
71
+ }
72
+ return error.message;
73
+ }
74
+ if (typeof error === "object" && "message" in error && typeof (error as { message: unknown }).message === "string") {
75
+ return (error as { message: string }).message;
76
+ }
77
+ return String(error);
78
+ }
79
+
80
+ /**
81
+ * Check if the current session belongs to an admin user.
82
+ */
83
+ function isAdminSession(session: ClientSession | undefined): boolean {
84
+ if (!session?.user) return false;
85
+ // Fast path: new adapter-aware sessions set isAdmin directly
86
+ if (session.user.isAdmin) return true;
87
+ if (!session.user.roles) return false;
88
+ return session.user.roles.some((r) => r === "admin");
89
+ }
90
+
91
+ export function createPostgresWebSocket(
92
+ server: Server,
93
+ realtimeService: RealtimeService,
94
+ driver: PostgresBackendDriver,
95
+ authConfig?: WsAuthConfig,
96
+ authAdapter?: AuthAdapter
97
+ ) {
98
+ // Session map scoped to this factory invocation — prevents stale sessions
99
+ // leaking across hot reloads or multiple factory calls.
100
+ const clientSessions = new Map<string, ClientSession>();
101
+
102
+ const isProduction = process.env.NODE_ENV === "production";
103
+ /** Debug logger that is suppressed in production to prevent PII/data leaks */
104
+ const wsDebug = (...args: unknown[]) => { if (!isProduction) console.debug(...args); };
105
+ const wss = new WebSocketServer({ server });
106
+
107
+ // Handle errors on the WSS so that EADDRINUSE from the underlying HTTP
108
+ // server doesn't surface as an unhandled 'error' event and crash the
109
+ // process. The dev-mode `listenWithPortRetry` utility handles retry
110
+ // logic on the HTTP server side — we just need the WSS not to throw.
111
+ wss.on("error", (err: NodeJS.ErrnoException) => {
112
+ if (err.code === "EADDRINUSE") {
113
+ // Silently absorbed — listenWithPortRetry will retry the next port
114
+ return;
115
+ }
116
+ logger.error("❌ [WebSocket Server] Error", { error: err });
117
+ });
118
+
119
+ // Auth is required when either: an adapter is present (secure by default),
120
+ // OR the config has a jwtSecret and requireAuth !== false.
121
+ const requireAuth = authAdapter
122
+ ? true
123
+ : (authConfig?.requireAuth !== false && !!authConfig?.jwtSecret);
124
+
125
+ wss.on("connection", (ws) => {
126
+ const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
127
+ wsDebug(`WebSocket client connected: ${clientId}`);
128
+
129
+ // Initialize client session
130
+ clientSessions.set(clientId, { ws,
131
+ authenticated: !requireAuth,
132
+ messageCount: 0,
133
+ messageWindowStart: Date.now() });
134
+ realtimeService.addClient(clientId, ws);
135
+
136
+ ws.on("close", () => {
137
+ wsDebug(`WebSocket client disconnected: ${clientId}`);
138
+ clientSessions.delete(clientId);
139
+ });
140
+
141
+ // Route all messages through RealtimeService for unified handling
142
+ ws.on("message", async (message) => {
143
+ let requestId: string | undefined;
144
+ try {
145
+ const {
146
+ type,
147
+ payload,
148
+ requestId: reqId
149
+ } = JSON.parse(message.toString());
150
+ requestId = reqId; // Capture requestId for use in catch block
151
+
152
+ wsDebug(`[WS] ${clientId} → ${type}`, requestId ? `(${requestId})` : "");
153
+
154
+ // Handle authentication first
155
+ // Helper: send a canonical error frame
156
+ const sendError = (errType: "ERROR" | "AUTH_ERROR", code: string, msg: string) => {
157
+ ws.send(JSON.stringify({
158
+ type: errType,
159
+ requestId,
160
+ payload: { error: { message: msg,
161
+ code } }
162
+ }));
163
+ };
164
+
165
+ if (type === "AUTHENTICATE") {
166
+ const { token } = payload || {};
167
+ if (!token) {
168
+ sendError("AUTH_ERROR", "INVALID_INPUT", "Token is required");
169
+ return;
170
+ }
171
+
172
+ // Use the auth adapter when available (custom auth, Clerk, etc.)
173
+ // Fall back to JWT extraction otherwise.
174
+ let verifiedUser: WsUserIdentity | null = null;
175
+
176
+ if (authAdapter) {
177
+ try {
178
+ const adapterUser = authAdapter.verifyToken
179
+ ? await authAdapter.verifyToken(token)
180
+ : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", {
181
+ headers: { Authorization: `Bearer ${token}` }
182
+ }));
183
+
184
+ if (adapterUser) {
185
+ verifiedUser = {
186
+ userId: adapterUser.uid,
187
+ roles: adapterUser.roles,
188
+ isAdmin: adapterUser.isAdmin
189
+ };
190
+ }
191
+ } catch {
192
+ // Adapter threw — treat as invalid token
193
+ }
194
+ } else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) {
195
+ // Service key: a static secret, not a JWT. Checked
196
+ // before verification, mirroring the HTTP middleware —
197
+ // verifying it as a JWT can only ever fail.
198
+ verifiedUser = { userId: "service", roles: ["admin"], isAdmin: true };
199
+ } else {
200
+ // Standard JWT path
201
+ const jwtPayload = extractUserFromToken(token);
202
+ if (jwtPayload) {
203
+ verifiedUser = {
204
+ userId: jwtPayload.userId,
205
+ roles: jwtPayload.roles ?? [],
206
+ isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === "admin")
207
+ };
208
+ }
209
+ }
210
+
211
+ if (verifiedUser) {
212
+ const session = clientSessions.get(clientId);
213
+ if (session) {
214
+ session.user = verifiedUser;
215
+ session.authenticated = true;
216
+ }
217
+ wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);
218
+ ws.send(JSON.stringify({
219
+ type: "AUTH_SUCCESS",
220
+ requestId,
221
+ payload: { userId: verifiedUser.userId,
222
+ roles: verifiedUser.roles }
223
+ }));
224
+ wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.userId}`);
225
+ } else {
226
+ wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
227
+ sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
228
+ }
229
+ return;
230
+ }
231
+
232
+ // Check authentication for protected operations
233
+ if (requireAuth) {
234
+ const session = clientSessions.get(clientId);
235
+ if (!session?.authenticated) {
236
+ sendError("ERROR", "UNAUTHORIZED", "Authentication required");
237
+ return;
238
+ }
239
+ }
240
+
241
+ // Rate limiting: reject if client exceeds message limit
242
+ {
243
+ const session = clientSessions.get(clientId);
244
+ if (session) {
245
+ const now = Date.now();
246
+ if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {
247
+ session.messageCount = 0;
248
+ session.messageWindowStart = now;
249
+ }
250
+ session.messageCount++;
251
+ if (session.messageCount > WS_RATE_LIMIT) {
252
+ sendError("ERROR", "RATE_LIMITED", "Too many requests. Please slow down.");
253
+ return;
254
+ }
255
+ }
256
+ }
257
+
258
+ // Admin-only operations require admin role
259
+ if (ADMIN_ONLY_TYPES.has(type)) {
260
+ const session = clientSessions.get(clientId);
261
+ if (!isAdminSession(session)) {
262
+ sendError("ERROR", "FORBIDDEN", "Admin access required for this operation");
263
+ return;
264
+ }
265
+ }
266
+
267
+ // Helper to get correctly scoped delegate for the current request
268
+ const getScopedDelegate = async (): Promise<DataDriver> => {
269
+ const session = clientSessions.get(clientId);
270
+ // Check if the driver supports RLS-scoped delegates
271
+ if (typeof driver.withAuth === "function") {
272
+ try {
273
+ const userForAuth: User = session?.user
274
+ ? {
275
+ uid: session.user.userId,
276
+ displayName: null,
277
+ email: null,
278
+ photoURL: null,
279
+ providerId: "websocket",
280
+ isAnonymous: false,
281
+ roles: session.user.roles ?? []
282
+ }
283
+ : {
284
+ uid: "anon",
285
+ displayName: null,
286
+ email: null,
287
+ photoURL: null,
288
+ providerId: "websocket",
289
+ isAnonymous: true,
290
+ roles: ["anon"]
291
+ };
292
+ return await driver.withAuth(userForAuth);
293
+ } catch (e) {
294
+ logger.error("Failed to create RLS scoped delegate for WS request", { error: e });
295
+ throw new Error("Internal authentication error");
296
+ }
297
+ }
298
+ return driver;
299
+ };
300
+
301
+ switch (type) {
302
+ case "FETCH_COLLECTION": {
303
+ wsDebug("📋 [WebSocket Server] Processing FETCH_COLLECTION request");
304
+ const request: FetchCollectionProps = payload;
305
+ const delegate = await getScopedDelegate();
306
+ const rows = await delegate.fetchCollection(request);
307
+ wsDebug("📋 [WebSocket Server] FETCH_COLLECTION result - rows count:", rows.length);
308
+ const response = {
309
+ type: "FETCH_COLLECTION_SUCCESS",
310
+ payload: { rows },
311
+ requestId
312
+ };
313
+ wsDebug("📋 [WebSocket Server] Sending FETCH_COLLECTION_SUCCESS response");
314
+ ws.send(JSON.stringify(response));
315
+ }
316
+ break;
317
+
318
+ case "FETCH_ONE": {
319
+ wsDebug("📄 [WebSocket Server] Processing FETCH_ENTITY request");
320
+ const request: FetchOneProps = payload;
321
+ const delegate = await getScopedDelegate();
322
+ const row = await delegate.fetchOne(request);
323
+ wsDebug("📄 [WebSocket Server] FETCH_ENTITY result:", row);
324
+ const response = {
325
+ type: "FETCH_ONE_SUCCESS",
326
+ payload: { row: row ?? null },
327
+ requestId
328
+ };
329
+ wsDebug("📄 [WebSocket Server] Sending FETCH_ENTITY_SUCCESS response");
330
+ ws.send(JSON.stringify(response));
331
+ }
332
+ break;
333
+
334
+ case "SAVE": {
335
+ wsDebug("💾 [WebSocket Server] Processing SAVE_ENTITY request");
336
+ const request: SaveProps = payload;
337
+ wsDebug("💾 [WebSocket Server] Saving row with request:", inspect(request, { depth: null,
338
+ colors: true }));
339
+ const delegate = await getScopedDelegate();
340
+ const row = await delegate.save(request);
341
+ wsDebug("💾 [WebSocket Server] SAVE_ENTITY result:", inspect(row, { depth: null,
342
+ colors: true }));
343
+ const response = {
344
+ type: "SAVE_SUCCESS",
345
+ payload: { row },
346
+ requestId
347
+ };
348
+ wsDebug("💾 [WebSocket Server] Sending SAVE_ENTITY_SUCCESS response");
349
+ ws.send(JSON.stringify(response));
350
+ }
351
+ break;
352
+
353
+ case "DELETE": {
354
+ wsDebug("🗑️ [WebSocket Server] Processing DELETE_ENTITY request");
355
+ const request: DeleteProps = payload;
356
+ wsDebug("🗑️ [WebSocket Server] Deleting row:", request.row);
357
+ const delegate = await getScopedDelegate();
358
+ await delegate.delete(request);
359
+ wsDebug("🗑️ [WebSocket Server] DELETE_ENTITY completed successfully");
360
+ const response = {
361
+ type: "DELETE_SUCCESS",
362
+ payload: { success: true },
363
+ requestId
364
+ };
365
+ wsDebug("🗑️ [WebSocket Server] Sending DELETE_ENTITY_SUCCESS response");
366
+ ws.send(JSON.stringify(response));
367
+ }
368
+ break;
369
+
370
+ case "CHECK_UNIQUE_FIELD": {
371
+ wsDebug("🔍 [WebSocket Server] Processing CHECK_UNIQUE_FIELD request");
372
+ const {
373
+ path,
374
+ name,
375
+ value,
376
+ id,
377
+ collection
378
+ } = payload;
379
+ const delegate = await getScopedDelegate();
380
+ const isUnique = await delegate.checkUniqueField(path, name, value, id, collection);
381
+ wsDebug("🔍 [WebSocket Server] CHECK_UNIQUE_FIELD result:", isUnique);
382
+ const response = {
383
+ type: "CHECK_UNIQUE_FIELD_SUCCESS",
384
+ payload: { isUnique },
385
+ requestId
386
+ };
387
+ wsDebug("🔍 [WebSocket Server] Sending CHECK_UNIQUE_FIELD_SUCCESS response");
388
+ ws.send(JSON.stringify(response));
389
+ }
390
+ break;
391
+
392
+
393
+ case "COUNT": {
394
+ const request: FetchCollectionProps = payload;
395
+ const delegate = await getScopedDelegate();
396
+ const count = await delegate.count!(request);
397
+ const response = {
398
+ type: "COUNT_SUCCESS",
399
+ payload: { count },
400
+ requestId
401
+ };
402
+ ws.send(JSON.stringify(response));
403
+ }
404
+ break;
405
+
406
+ case "EXECUTE_SQL": {
407
+ const { sql, options } = payload;
408
+ try {
409
+ const delegate = await getScopedDelegate();
410
+ const admin = delegate.admin;
411
+ if (!isSQLAdmin(admin)) {
412
+ sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
413
+ break;
414
+ }
415
+ const result = await admin.executeSql(sql, options);
416
+ if (process.env.NODE_ENV !== "production") {
417
+ wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
418
+ }
419
+ const auditSession = clientSessions.get(clientId);
420
+ console.log("[SQL Audit] WebSocket SQL execution", JSON.stringify({
421
+ sql: typeof sql === "string" ? sql.substring(0, 500) : sql,
422
+ options,
423
+ resultRows: Array.isArray(result) ? result.length : "unknown",
424
+ userId: auditSession?.user?.userId ?? "unknown",
425
+ roles: auditSession?.user?.roles ?? [],
426
+ isAdmin: auditSession?.user?.isAdmin ?? false,
427
+ }));
428
+ const response = {
429
+ type: "EXECUTE_SQL_SUCCESS",
430
+ payload: { result },
431
+ requestId
432
+ };
433
+ ws.send(JSON.stringify(response));
434
+ } catch (sqlError: unknown) {
435
+ // This is a query execution error (e.g., syntax error, permission denied).
436
+ // We return it cleanly to the client without logging a server stack trace.
437
+ const errMsg = extractErrorMessage(sqlError);
438
+ sendError("ERROR", "SQL_ERROR", errMsg);
439
+ }
440
+ }
441
+ break;
442
+
443
+ case "FETCH_DATABASES": {
444
+ wsDebug("📚 [WebSocket Server] Processing FETCH_DATABASES request");
445
+ const delegate = await getScopedDelegate();
446
+ const admin = delegate.admin;
447
+ let databases: string[] = [];
448
+ if (isSQLAdmin(admin) && admin.fetchAvailableDatabases) {
449
+ databases = await admin.fetchAvailableDatabases();
450
+ }
451
+ wsDebug(`📚 [WebSocket Server] Fetched ${databases.length} databases.`);
452
+ const response = {
453
+ type: "FETCH_DATABASES_SUCCESS",
454
+ payload: { databases },
455
+ requestId
456
+ };
457
+ ws.send(JSON.stringify(response));
458
+ }
459
+ break;
460
+
461
+ case "FETCH_ROLES": {
462
+ wsDebug("👤 [WebSocket Server] Processing FETCH_ROLES request");
463
+ const delegate = await getScopedDelegate();
464
+ const admin = delegate.admin;
465
+ let roles: string[] = [];
466
+ if (isSQLAdmin(admin) && admin.fetchAvailableRoles) {
467
+ roles = await admin.fetchAvailableRoles();
468
+ }
469
+ wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} roles.`);
470
+ const response = {
471
+ type: "FETCH_ROLES_SUCCESS",
472
+ payload: { roles },
473
+ requestId
474
+ };
475
+ ws.send(JSON.stringify(response));
476
+ }
477
+ break;
478
+
479
+ case "FETCH_CURRENT_DATABASE": {
480
+ wsDebug("📚 [WebSocket Server] Processing FETCH_CURRENT_DATABASE request");
481
+ const delegate = await getScopedDelegate();
482
+ const admin = delegate.admin;
483
+ let database: string | undefined = undefined;
484
+ if (isSQLAdmin(admin) && admin.fetchCurrentDatabase) {
485
+ database = await admin.fetchCurrentDatabase();
486
+ }
487
+ const response = {
488
+ type: "FETCH_CURRENT_DATABASE_SUCCESS",
489
+ payload: { database },
490
+ requestId
491
+ };
492
+ ws.send(JSON.stringify(response));
493
+ }
494
+ break;
495
+
496
+ case "FETCH_UNMAPPED_TABLES": {
497
+ wsDebug("📋 [WebSocket Server] Processing FETCH_UNMAPPED_TABLES request");
498
+ const delegate = await getScopedDelegate();
499
+ const admin = delegate.admin;
500
+ let tables: string[] = [];
501
+ if (isSchemaAdmin(admin) && admin.fetchUnmappedTables) {
502
+ tables = await admin.fetchUnmappedTables(payload?.mappedPaths);
503
+ }
504
+ wsDebug(`📋 [WebSocket Server] Fetched ${tables.length} unmapped tables.`);
505
+ const response = {
506
+ type: "FETCH_UNMAPPED_TABLES_SUCCESS",
507
+ payload: { tables },
508
+ requestId
509
+ };
510
+ ws.send(JSON.stringify(response));
511
+ }
512
+ break;
513
+
514
+ case "FETCH_TABLE_METADATA": {
515
+ wsDebug("📋 [WebSocket Server] Processing FETCH_TABLE_METADATA request");
516
+ const { tableName } = payload;
517
+ const delegate = await getScopedDelegate();
518
+ const admin = delegate.admin;
519
+ let metadata: TableMetadata | undefined;
520
+ if (isSchemaAdmin(admin) && admin.fetchTableMetadata) {
521
+ metadata = await admin.fetchTableMetadata(tableName) as TableMetadata;
522
+ }
523
+ wsDebug(`📋 [WebSocket Server] Fetched metadata for table '${tableName}'. (${metadata?.columns?.length ?? 0} columns)`);
524
+ const response = {
525
+ type: "FETCH_TABLE_METADATA_SUCCESS",
526
+ payload: { metadata },
527
+ requestId
528
+ };
529
+ ws.send(JSON.stringify(response));
530
+ }
531
+ break;
532
+
533
+ case "CREATE_BRANCH": {
534
+ wsDebug("🌿 [WebSocket Server] Processing CREATE_BRANCH request");
535
+ const { name, options } = payload;
536
+ const delegate = await getScopedDelegate();
537
+ if (!delegate.admin?.createBranch) {
538
+ sendError("ERROR", "NOT_SUPPORTED", "Database branching is not available. Configure adminConnectionString.");
539
+ break;
540
+ }
541
+ const branch: BranchInfo = await delegate.admin.createBranch(name, options);
542
+ wsDebug(`🌿 [WebSocket Server] Branch created: ${branch.name}`);
543
+ const response = {
544
+ type: "CREATE_BRANCH_SUCCESS",
545
+ payload: { branch },
546
+ requestId
547
+ };
548
+ ws.send(JSON.stringify(response));
549
+ }
550
+ break;
551
+
552
+ case "DELETE_BRANCH": {
553
+ wsDebug("🗑️ [WebSocket Server] Processing DELETE_BRANCH request");
554
+ const { name: branchName } = payload;
555
+ const delegate = await getScopedDelegate();
556
+ if (!delegate.admin?.deleteBranch) {
557
+ sendError("ERROR", "NOT_SUPPORTED", "Database branching is not available.");
558
+ break;
559
+ }
560
+ await delegate.admin.deleteBranch(branchName);
561
+ wsDebug(`🗑️ [WebSocket Server] Branch deleted: ${branchName}`);
562
+ const response = {
563
+ type: "DELETE_BRANCH_SUCCESS",
564
+ payload: { success: true },
565
+ requestId
566
+ };
567
+ ws.send(JSON.stringify(response));
568
+ }
569
+ break;
570
+
571
+ case "LIST_BRANCHES": {
572
+ wsDebug("🌿 [WebSocket Server] Processing LIST_BRANCHES request");
573
+ const delegate = await getScopedDelegate();
574
+ let branches: BranchInfo[] = [];
575
+ if (delegate.admin?.listBranches) {
576
+ branches = await delegate.admin.listBranches();
577
+ }
578
+ wsDebug(`🌿 [WebSocket Server] Listed ${branches.length} branches.`);
579
+ const response = {
580
+ type: "LIST_BRANCHES_SUCCESS",
581
+ payload: { branches },
582
+ requestId
583
+ };
584
+ ws.send(JSON.stringify(response));
585
+ }
586
+ break;
587
+
588
+ // Route subscription messages, broadcast channels, and presence to RealtimeService
589
+ case "subscribe_collection":
590
+ case "subscribe_one":
591
+ case "unsubscribe":
592
+ case "join_channel":
593
+ case "leave_channel":
594
+ case "broadcast":
595
+ case "presence_track":
596
+ case "presence_untrack":
597
+ case "presence_state": {
598
+ wsDebug("🔄 [WebSocket Server] Routing realtime message to RealtimeService:", type);
599
+ // Attach auth context from the WS session so RLS-aware refetches work
600
+ const session = clientSessions.get(clientId);
601
+ const authContext = session?.user
602
+ ? { userId: session.user.userId,
603
+ roles: session.user.roles ?? [] }
604
+ : { userId: "anon",
605
+ roles: ["anon"] };
606
+ // Let RealtimeService handle these messages
607
+ await realtimeService.handleClientMessage(clientId, {
608
+ type,
609
+ payload,
610
+ subscriptionId: payload?.subscriptionId
611
+ }, authContext);
612
+ break;
613
+ }
614
+
615
+ default:
616
+ logger.error("❌ [WebSocket Server] Unknown message type", { detail: type });
617
+ }
618
+ } catch (error: unknown) {
619
+ logger.error("💥 [WebSocket Server] Error handling message", { error: error });
620
+ if (error instanceof Error) {
621
+ logger.error("Stack trace", { detail: error.stack });
622
+ }
623
+ const errorMessage = process.env.NODE_ENV === "production"
624
+ ? "An unexpected error occurred"
625
+ : (error instanceof Error ? error.message : "An unexpected error occurred");
626
+ const errorResponse = {
627
+ type: "ERROR",
628
+ requestId,
629
+ payload: {
630
+ error: {
631
+ message: errorMessage,
632
+ code: "INTERNAL_ERROR"
633
+ }
634
+ }
635
+ };
636
+ ws.send(JSON.stringify(errorResponse));
637
+ }
638
+ });
639
+ });
640
+ }