@rebasepro/server-postgres 0.17.3-canary.gdd23447 → 0.18.0

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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
package/src/websocket.ts DELETED
@@ -1,855 +0,0 @@
1
- import { RealtimeService } from "./services/realtimeService";
2
- import { PostgresBackendDriver, effectiveSqlRole } from "./PostgresBackendDriver";
3
- import type { DataDriver, DeleteProps, FetchCollectionProps, FetchOneProps, SaveProps, TableMetadata, BranchInfo, AuthAdapter } from "@rebasepro/types";
4
- import { ANONYMOUS_USER_ID, isSQLAdmin, isSchemaAdmin, resolveClientListLimit, ListLimitError } 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, resolveRequireAuth, assertWriteRequestValid, ApiError } 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
- uid: 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
- /** The same window, counted separately for channel frames. */
43
- channelMessageCount: number;
44
- channelWindowStart: number;
45
- }
46
-
47
-
48
- /** Maximum messages per client per window */
49
- const WS_RATE_LIMIT = 2000;
50
- /** Rate limit window in milliseconds (60 seconds) */
51
- const WS_RATE_WINDOW_MS = 60_000;
52
-
53
- /**
54
- * Channel frames get their own budget, because they are a different workload.
55
- *
56
- * 2000/minute is 33/second, which is generous for queries and subscriptions and
57
- * an order of magnitude below what the documented channel idiom asks for: the
58
- * capacity note in `docs/backend/realtime.md` uses 60 fps cursor movement as
59
- * its worked example, and the presence idiom re-`track()`s on every move, so
60
- * one client sustaining that sends ~120 frames/second — 7200 a minute. Sharing
61
- * one counter meant the cursor stream ate the query budget and then froze for
62
- * the rest of the window.
63
- *
64
- * The number is sized to that documented workload and nothing more; it is not
65
- * a considered product limit (see `docs/channel-authorization.md`).
66
- */
67
- const WS_CHANNEL_RATE_LIMIT = 7200;
68
-
69
- /** Frames counted against the channel budget rather than the general one. */
70
- const CHANNEL_MESSAGE_TYPES = new Set([
71
- "join_channel",
72
- "leave_channel",
73
- "broadcast",
74
- "presence_track",
75
- "presence_untrack",
76
- "presence_state",
77
- "channel_history"
78
- ]);
79
-
80
- /**
81
- * WebSocket message types that require an admin session.
82
- *
83
- * Exported so the test can READ it. It used to be private, and the test that
84
- * exists to make "a privileged verb added without a role check" impossible held
85
- * a hand-typed copy of the same nine strings — so it agreed with itself, and
86
- * `FETCH_APPLICATION_ROLES` was added to neither. That verb runs
87
- * `SELECT DISTINCT unnest(roles)` over the users table through `executeSql`,
88
- * which is the owner connection and not subject to RLS, so any authenticated
89
- * non-admin could enumerate every role in the project — and any anonymous
90
- * socket could, on a deployment with `requireAuth: false`.
91
- *
92
- * The list is no longer the whole guarantee. `PUBLIC_TYPES` below is its
93
- * counterpart, and a test asserts that every `case` this file handles appears
94
- * in exactly one of the two — so a verb added to neither fails rather than
95
- * defaulting to reachable.
96
- */
97
- export const ADMIN_ONLY_TYPES = new Set([
98
- "EXECUTE_SQL",
99
- "FETCH_DATABASES",
100
- "FETCH_ROLES",
101
- "FETCH_APPLICATION_ROLES",
102
- "FETCH_UNMAPPED_TABLES",
103
- "FETCH_TABLE_METADATA",
104
- "FETCH_CURRENT_DATABASE",
105
- "CREATE_BRANCH",
106
- "DELETE_BRANCH",
107
- "LIST_BRANCHES"
108
- ]);
109
-
110
- /**
111
- * Message types deliberately reachable by a non-admin session.
112
- *
113
- * Data operations, gated per row by RLS and per request by the same write
114
- * checks the REST layer applies — not by this list. It exists so that "which
115
- * bucket is this verb in" is a question with an answer for every verb, and
116
- * adding one without answering it is a test failure.
117
- */
118
- export const PUBLIC_TYPES = new Set([
119
- "FETCH_COLLECTION",
120
- "FETCH_ONE",
121
- "COUNT",
122
- "SAVE",
123
- "DELETE",
124
- "CHECK_UNIQUE_FIELD"
125
- ]);
126
-
127
- /**
128
- * Recursively extract the deepest error message from an error's cause chain (e.g., Drizzle wrapping a PG error).
129
- */
130
- function extractErrorMessage(error: unknown): string {
131
- if (!error) return "Unknown error";
132
- if (error instanceof Error) {
133
- if ("cause" in error && error.cause) {
134
- return extractErrorMessage(error.cause);
135
- }
136
- return error.message;
137
- }
138
- if (typeof error === "object" && "message" in error && typeof (error as { message: unknown }).message === "string") {
139
- return (error as { message: string }).message;
140
- }
141
- return String(error);
142
- }
143
-
144
- /**
145
- * Check if the current session belongs to an admin user.
146
- */
147
- function isAdminSession(session: ClientSession | undefined): boolean {
148
- if (!session?.user) return false;
149
- // Fast path: new adapter-aware sessions set isAdmin directly
150
- if (session.user.isAdmin) return true;
151
- if (!session.user.roles) return false;
152
- return session.user.roles.some((r) => r === "admin");
153
- }
154
-
155
- export function createPostgresWebSocket(
156
- server: Server,
157
- realtimeService: RealtimeService,
158
- driver: PostgresBackendDriver,
159
- authConfig?: WsAuthConfig,
160
- authAdapter?: AuthAdapter
161
- ) {
162
- // Session map scoped to this factory invocation — prevents stale sessions
163
- // leaking across hot reloads or multiple factory calls.
164
- const clientSessions = new Map<string, ClientSession>();
165
-
166
- const isProduction = process.env.NODE_ENV === "production";
167
- /** Debug logger that is suppressed in production to prevent PII/data leaks */
168
- const wsDebug = (...args: unknown[]) => { if (!isProduction) console.debug(...args); };
169
- const wss = new WebSocketServer({ server });
170
-
171
- // Handle errors on the WSS so that EADDRINUSE from the underlying HTTP
172
- // server doesn't surface as an unhandled 'error' event and crash the
173
- // process. The dev-mode `listenWithPortRetry` utility handles retry
174
- // logic on the HTTP server side — we just need the WSS not to throw.
175
- wss.on("error", (err: NodeJS.ErrnoException) => {
176
- if (err.code === "EADDRINUSE") {
177
- // Silently absorbed — listenWithPortRetry will retry the next port
178
- return;
179
- }
180
- logger.error("❌ [WebSocket Server] Error", { error: err });
181
- });
182
-
183
- // The same predicate the HTTP data routes use, from the same function —
184
- // this socket is the other enforcement point for one product decision, and
185
- // while it computed the answer itself it computed a different one. See
186
- // `resolveRequireAuth` for what its local copy got wrong and why a `false`
187
- // here grants access rather than skipping a check.
188
- const requireAuth = !!authAdapter || resolveRequireAuth(authConfig as never);
189
-
190
- if (requireAuth && !authAdapter && !authConfig?.jwtSecret && !authConfig?.serviceKey) {
191
- logger.warn(
192
- "🔐 [WebSocket Server] Authentication is required but no adapter, jwtSecret or " +
193
- "serviceKey is configured — no client can complete AUTH, so every realtime " +
194
- "message will be refused with UNAUTHORIZED."
195
- );
196
- }
197
-
198
- wss.on("connection", (ws) => {
199
- const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
200
- wsDebug(`WebSocket client connected: ${clientId}`);
201
-
202
- // Initialize client session
203
- clientSessions.set(clientId, { ws,
204
- authenticated: !requireAuth,
205
- messageCount: 0,
206
- messageWindowStart: Date.now(),
207
- channelMessageCount: 0,
208
- channelWindowStart: Date.now() });
209
- realtimeService.addClient(clientId, ws);
210
-
211
- ws.on("close", () => {
212
- wsDebug(`WebSocket client disconnected: ${clientId}`);
213
- clientSessions.delete(clientId);
214
- });
215
-
216
- // Route all messages through RealtimeService for unified handling
217
- ws.on("message", async (message) => {
218
- let requestId: string | undefined;
219
- try {
220
- const {
221
- type,
222
- payload,
223
- requestId: reqId
224
- } = JSON.parse(message.toString());
225
- requestId = reqId; // Capture requestId for use in catch block
226
-
227
- wsDebug(`[WS] ${clientId} → ${type}`, requestId ? `(${requestId})` : "");
228
-
229
- // Handle authentication first
230
- // Helper: send a canonical error frame
231
- const sendError = (errType: "ERROR" | "AUTH_ERROR", code: string, msg: string) => {
232
- ws.send(JSON.stringify({
233
- type: errType,
234
- requestId,
235
- payload: { error: { message: msg,
236
- code } }
237
- }));
238
- };
239
-
240
- if (type === "AUTHENTICATE") {
241
- const { token } = payload || {};
242
- if (!token) {
243
- sendError("AUTH_ERROR", "INVALID_INPUT", "Token is required");
244
- return;
245
- }
246
-
247
- // Use the auth adapter when available (custom auth, Clerk, etc.)
248
- // Fall back to JWT extraction otherwise.
249
- let verifiedUser: WsUserIdentity | null = null;
250
-
251
- if (authAdapter) {
252
- try {
253
- const adapterUser = authAdapter.verifyToken
254
- ? await authAdapter.verifyToken(token)
255
- : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", {
256
- headers: { Authorization: `Bearer ${token}` }
257
- }));
258
-
259
- if (adapterUser) {
260
- verifiedUser = {
261
- uid: adapterUser.uid,
262
- roles: adapterUser.roles,
263
- isAdmin: adapterUser.isAdmin
264
- };
265
- }
266
- } catch {
267
- // Adapter threw — treat as invalid token
268
- }
269
- } else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) {
270
- // Service key: a static secret, not a JWT. Checked
271
- // before verification, mirroring the HTTP middleware —
272
- // verifying it as a JWT can only ever fail.
273
- verifiedUser = { uid: "service", roles: ["admin"], isAdmin: true };
274
- } else {
275
- // Standard JWT path
276
- const jwtPayload = await extractUserFromToken(token);
277
- if (jwtPayload) {
278
- verifiedUser = {
279
- uid: jwtPayload.uid,
280
- roles: jwtPayload.roles ?? [],
281
- isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === "admin")
282
- };
283
- }
284
- }
285
-
286
- if (verifiedUser) {
287
- const session = clientSessions.get(clientId);
288
- if (session) {
289
- session.user = verifiedUser;
290
- session.authenticated = true;
291
- }
292
- wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);
293
- ws.send(JSON.stringify({
294
- type: "AUTH_SUCCESS",
295
- requestId,
296
- payload: { uid: verifiedUser.uid,
297
- roles: verifiedUser.roles }
298
- }));
299
- wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.uid}`);
300
- } else {
301
- wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
302
- sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
303
- }
304
- return;
305
- }
306
-
307
- // Check authentication for protected operations
308
- if (requireAuth) {
309
- const session = clientSessions.get(clientId);
310
- if (!session?.authenticated) {
311
- sendError("ERROR", "UNAUTHORIZED", "Authentication required");
312
- return;
313
- }
314
- }
315
-
316
- // Rate limiting: reject if client exceeds message limit.
317
- // Channel frames are counted against their own budget — see
318
- // WS_CHANNEL_RATE_LIMIT for why one shared counter starved them.
319
- {
320
- const session = clientSessions.get(clientId);
321
- if (session) {
322
- const now = Date.now();
323
- const isChannelFrame = CHANNEL_MESSAGE_TYPES.has(type);
324
- if (isChannelFrame) {
325
- if (now - session.channelWindowStart > WS_RATE_WINDOW_MS) {
326
- session.channelMessageCount = 0;
327
- session.channelWindowStart = now;
328
- }
329
- session.channelMessageCount++;
330
- if (session.channelMessageCount > WS_CHANNEL_RATE_LIMIT) {
331
- sendError("ERROR", "RATE_LIMITED", "Too many channel messages. Please slow down.");
332
- return;
333
- }
334
- } else {
335
- if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {
336
- session.messageCount = 0;
337
- session.messageWindowStart = now;
338
- }
339
- session.messageCount++;
340
- if (session.messageCount > WS_RATE_LIMIT) {
341
- sendError("ERROR", "RATE_LIMITED", "Too many requests. Please slow down.");
342
- return;
343
- }
344
- }
345
- }
346
- }
347
-
348
- // Admin-only operations require admin role
349
- if (ADMIN_ONLY_TYPES.has(type)) {
350
- const session = clientSessions.get(clientId);
351
- if (!isAdminSession(session)) {
352
- sendError("ERROR", "FORBIDDEN", "Admin access required for this operation");
353
- return;
354
- }
355
- }
356
-
357
- /**
358
- * Apply the REST layer's write checks to a socket payload.
359
- *
360
- * Silent when the path names no registered collection: the
361
- * driver decides what a path means, and refusing here would
362
- * turn "unknown collection" into a validation error.
363
- */
364
- const assertWriteRequest = (path: string | undefined, values: unknown): void => {
365
- if (!path || !values || typeof values !== "object") return;
366
- const collection = driver.registry?.getCollectionByPath(path);
367
- if (!collection) return;
368
- assertWriteRequestValid(values as Record<string, unknown>, collection);
369
- };
370
-
371
- // Helper to get correctly scoped delegate for the current request
372
- const getScopedDelegate = async (): Promise<DataDriver> => {
373
- const session = clientSessions.get(clientId);
374
- // Check if the driver supports RLS-scoped delegates
375
- if (typeof driver.withAuth === "function") {
376
- try {
377
- const userForAuth: User = session?.user
378
- ? {
379
- uid: session.user.uid,
380
- displayName: null,
381
- email: null,
382
- photoURL: null,
383
- providerId: "websocket",
384
- isAnonymous: false,
385
- roles: session.user.roles ?? []
386
- }
387
- : {
388
- uid: ANONYMOUS_USER_ID,
389
- displayName: null,
390
- email: null,
391
- photoURL: null,
392
- providerId: "websocket",
393
- isAnonymous: true,
394
- roles: ["anon"]
395
- };
396
- return await driver.withAuth(userForAuth);
397
- } catch (e) {
398
- logger.error("Failed to create RLS scoped delegate for WS request", { error: e });
399
- throw new Error("Internal authentication error");
400
- }
401
- }
402
- return driver;
403
- };
404
-
405
- switch (type) {
406
- case "FETCH_COLLECTION": {
407
- wsDebug("📋 [WebSocket Server] Processing FETCH_COLLECTION request");
408
- const request: FetchCollectionProps = payload;
409
- const delegate = await getScopedDelegate();
410
- // Bound the client-supplied limit with the SAME guarantee
411
- // the REST ingress and `subscribe_collection` apply
412
- // (`resolveClientListLimit`). Without it an absent limit
413
- // reached the driver as `undefined`, which emits no LIMIT
414
- // clause — one socket frame streamed the whole table, on
415
- // the one transport that skipped the ceiling every other
416
- // read path enforces.
417
- const rows = await delegate.fetchCollection({
418
- ...request,
419
- limit: resolveClientListLimit(request.limit, {
420
- vectorSearch: !!request.vectorSearch
421
- })
422
- });
423
- wsDebug("📋 [WebSocket Server] FETCH_COLLECTION result - rows count:", rows.length);
424
- const response = {
425
- type: "FETCH_COLLECTION_SUCCESS",
426
- payload: { rows },
427
- requestId
428
- };
429
- wsDebug("📋 [WebSocket Server] Sending FETCH_COLLECTION_SUCCESS response");
430
- ws.send(JSON.stringify(response));
431
- }
432
- break;
433
-
434
- case "FETCH_ONE": {
435
- wsDebug("📄 [WebSocket Server] Processing FETCH_ENTITY request");
436
- const request: FetchOneProps = payload;
437
- const delegate = await getScopedDelegate();
438
- const row = await delegate.fetchOne(request);
439
- wsDebug("📄 [WebSocket Server] FETCH_ENTITY result:", row);
440
- const response = {
441
- type: "FETCH_ONE_SUCCESS",
442
- payload: { row: row ?? null },
443
- requestId
444
- };
445
- wsDebug("📄 [WebSocket Server] Sending FETCH_ENTITY_SUCCESS response");
446
- ws.send(JSON.stringify(response));
447
- }
448
- break;
449
-
450
- case "SAVE": {
451
- wsDebug("💾 [WebSocket Server] Processing SAVE_ENTITY request");
452
- const request: SaveProps = payload;
453
- wsDebug("💾 [WebSocket Server] Saving row with request:", inspect(request, { depth: null,
454
- colors: true }));
455
- // The same two checks the REST write routes run, on the
456
- // same input, at the same point. This socket is the
457
- // other request boundary — the comment on `requireAuth`
458
- // above says so — and it used to hand the client's
459
- // payload straight to the driver, so a value the HTTP
460
- // API answers 400 for was written when it arrived here.
461
- //
462
- // The collection comes from the registry by path, never
463
- // from `request.collection`: that field is client-
464
- // supplied, and reading the rules out of it would let
465
- // the caller choose which rules to be checked against.
466
- assertWriteRequest(request.path, request.values as Record<string, unknown>);
467
- const delegate = await getScopedDelegate();
468
- const row = await delegate.save(request);
469
- wsDebug("💾 [WebSocket Server] SAVE_ENTITY result:", inspect(row, { depth: null,
470
- colors: true }));
471
- const response = {
472
- type: "SAVE_SUCCESS",
473
- payload: { row },
474
- requestId
475
- };
476
- wsDebug("💾 [WebSocket Server] Sending SAVE_ENTITY_SUCCESS response");
477
- ws.send(JSON.stringify(response));
478
- }
479
- break;
480
-
481
- case "DELETE": {
482
- wsDebug("🗑️ [WebSocket Server] Processing DELETE_ENTITY request");
483
- const request: DeleteProps = payload;
484
- wsDebug("🗑️ [WebSocket Server] Deleting row:", request.row);
485
- const delegate = await getScopedDelegate();
486
- await delegate.delete(request);
487
- wsDebug("🗑️ [WebSocket Server] DELETE_ENTITY completed successfully");
488
- const response = {
489
- type: "DELETE_SUCCESS",
490
- payload: { success: true },
491
- requestId
492
- };
493
- wsDebug("🗑️ [WebSocket Server] Sending DELETE_ENTITY_SUCCESS response");
494
- ws.send(JSON.stringify(response));
495
- }
496
- break;
497
-
498
- case "CHECK_UNIQUE_FIELD": {
499
- wsDebug("🔍 [WebSocket Server] Processing CHECK_UNIQUE_FIELD request");
500
- const {
501
- path,
502
- name,
503
- value,
504
- id,
505
- collection
506
- } = payload;
507
- const delegate = await getScopedDelegate();
508
- const isUnique = await delegate.checkUniqueField(path, name, value, id, collection);
509
- wsDebug("🔍 [WebSocket Server] CHECK_UNIQUE_FIELD result:", isUnique);
510
- const response = {
511
- type: "CHECK_UNIQUE_FIELD_SUCCESS",
512
- payload: { isUnique },
513
- requestId
514
- };
515
- wsDebug("🔍 [WebSocket Server] Sending CHECK_UNIQUE_FIELD_SUCCESS response");
516
- ws.send(JSON.stringify(response));
517
- }
518
- break;
519
-
520
-
521
- case "COUNT": {
522
- // Deliberately NOT routed through `resolveClientListLimit`:
523
- // this answers with a scalar, and the driver drops `limit`
524
- // on the way to `SELECT count(*)`. Clamping here could only
525
- // ever make `total` describe fewer rows than the collection
526
- // holds — the page size is the caller's business, the total
527
- // is not.
528
- const request: FetchCollectionProps = payload;
529
- const delegate = await getScopedDelegate();
530
- const count = await delegate.count!(request);
531
- const response = {
532
- type: "COUNT_SUCCESS",
533
- payload: { count },
534
- requestId
535
- };
536
- ws.send(JSON.stringify(response));
537
- }
538
- break;
539
-
540
- case "EXECUTE_SQL": {
541
- const { sql, options } = payload;
542
- try {
543
- const delegate = await getScopedDelegate();
544
- const admin = delegate.admin;
545
- if (!isSQLAdmin(admin)) {
546
- sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
547
- break;
548
- }
549
- const result = await admin.executeSql(sql, options);
550
- if (process.env.NODE_ENV !== "production") {
551
- wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
552
- }
553
- const auditSession = clientSessions.get(clientId);
554
- // Through `logger`, not `console.log`: this line is
555
- // emitted in production, and a bare console call
556
- // has no severity, no timestamp, no JSON envelope
557
- // and no LOG_LEVEL gate, so it lands in Cloud
558
- // Logging as unstructured text the queries written
559
- // for every other line cannot match.
560
- //
561
- // The bound values are counted, never written — the
562
- // statement is the audit signal, the parameters are
563
- // whatever row the operator was touching. (stdout is
564
- // not an audit sink either; a real trail belongs in
565
- // a table with an actor and a retention policy.)
566
- logger.info("[SQL Audit] WebSocket SQL execution", {
567
- sql: typeof sql === "string" ? sql.substring(0, 500) : String(sql),
568
- database: options?.database,
569
- role: options?.role,
570
- // The role the statement asked for is above;
571
- // this is the one it ran as. They used to be
572
- // assumed identical, so an execution that fell
573
- // back to the owner was audited under the role
574
- // it had failed to assume. That fallback is now
575
- // an error everywhere except the documented
576
- // `DISABLE_DB_ROLE_SWITCHING` opt-out — which is
577
- // precisely the case this line still has to
578
- // report honestly.
579
- effectiveRole: effectiveSqlRole(options?.role),
580
- paramCount: Array.isArray(options?.params) ? options.params.length : 0,
581
- resultRows: Array.isArray(result) ? result.length : "unknown",
582
- uid: auditSession?.user?.uid ?? "unknown",
583
- roles: auditSession?.user?.roles ?? [],
584
- isAdmin: auditSession?.user?.isAdmin ?? false,
585
- requestId
586
- });
587
- const response = {
588
- type: "EXECUTE_SQL_SUCCESS",
589
- payload: { result },
590
- requestId
591
- };
592
- ws.send(JSON.stringify(response));
593
- } catch (sqlError: unknown) {
594
- // This is a query execution error (e.g., syntax error, permission denied).
595
- // We return it cleanly to the client without logging a server stack trace.
596
- const errMsg = extractErrorMessage(sqlError);
597
- sendError("ERROR", "SQL_ERROR", errMsg);
598
- }
599
- }
600
- break;
601
-
602
- case "FETCH_DATABASES": {
603
- wsDebug("📚 [WebSocket Server] Processing FETCH_DATABASES request");
604
- const delegate = await getScopedDelegate();
605
- const admin = delegate.admin;
606
- let databases: string[] = [];
607
- if (isSQLAdmin(admin) && admin.fetchAvailableDatabases) {
608
- databases = await admin.fetchAvailableDatabases();
609
- }
610
- wsDebug(`📚 [WebSocket Server] Fetched ${databases.length} databases.`);
611
- const response = {
612
- type: "FETCH_DATABASES_SUCCESS",
613
- payload: { databases },
614
- requestId
615
- };
616
- ws.send(JSON.stringify(response));
617
- }
618
- break;
619
-
620
- case "FETCH_ROLES": {
621
- wsDebug("👤 [WebSocket Server] Processing FETCH_ROLES request");
622
- const delegate = await getScopedDelegate();
623
- const admin = delegate.admin;
624
- let roles: string[] = [];
625
- if (isSQLAdmin(admin) && admin.fetchAvailableRoles) {
626
- roles = await admin.fetchAvailableRoles();
627
- }
628
- wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} roles.`);
629
- const response = {
630
- type: "FETCH_ROLES_SUCCESS",
631
- payload: { roles },
632
- requestId
633
- };
634
- ws.send(JSON.stringify(response));
635
- }
636
- break;
637
-
638
- case "FETCH_APPLICATION_ROLES": {
639
- wsDebug("👤 [WebSocket Server] Processing FETCH_APPLICATION_ROLES request");
640
- const delegate = await getScopedDelegate();
641
- const admin = delegate.admin;
642
- let roles: string[] = [];
643
- if (isSQLAdmin(admin) && admin.fetchApplicationRoles) {
644
- roles = await admin.fetchApplicationRoles();
645
- }
646
- wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} application roles.`);
647
- const response = {
648
- type: "FETCH_APPLICATION_ROLES_SUCCESS",
649
- payload: { roles },
650
- requestId
651
- };
652
- ws.send(JSON.stringify(response));
653
- }
654
- break;
655
-
656
- case "FETCH_CURRENT_DATABASE": {
657
- wsDebug("📚 [WebSocket Server] Processing FETCH_CURRENT_DATABASE request");
658
- const delegate = await getScopedDelegate();
659
- const admin = delegate.admin;
660
- let database: string | undefined = undefined;
661
- if (isSQLAdmin(admin) && admin.fetchCurrentDatabase) {
662
- database = await admin.fetchCurrentDatabase();
663
- }
664
- const response = {
665
- type: "FETCH_CURRENT_DATABASE_SUCCESS",
666
- payload: { database },
667
- requestId
668
- };
669
- ws.send(JSON.stringify(response));
670
- }
671
- break;
672
-
673
- case "FETCH_UNMAPPED_TABLES": {
674
- wsDebug("📋 [WebSocket Server] Processing FETCH_UNMAPPED_TABLES request");
675
- const delegate = await getScopedDelegate();
676
- const admin = delegate.admin;
677
- let tables: string[] = [];
678
- if (isSchemaAdmin(admin) && admin.fetchUnmappedTables) {
679
- tables = await admin.fetchUnmappedTables(payload?.mappedPaths);
680
- }
681
- wsDebug(`📋 [WebSocket Server] Fetched ${tables.length} unmapped tables.`);
682
- const response = {
683
- type: "FETCH_UNMAPPED_TABLES_SUCCESS",
684
- payload: { tables },
685
- requestId
686
- };
687
- ws.send(JSON.stringify(response));
688
- }
689
- break;
690
-
691
- case "FETCH_TABLE_METADATA": {
692
- wsDebug("📋 [WebSocket Server] Processing FETCH_TABLE_METADATA request");
693
- const { tableName } = payload;
694
- const delegate = await getScopedDelegate();
695
- const admin = delegate.admin;
696
- let metadata: TableMetadata | undefined;
697
- if (isSchemaAdmin(admin) && admin.fetchTableMetadata) {
698
- metadata = await admin.fetchTableMetadata(tableName) as TableMetadata;
699
- }
700
- wsDebug(`📋 [WebSocket Server] Fetched metadata for table '${tableName}'. (${metadata?.columns?.length ?? 0} columns)`);
701
- const response = {
702
- type: "FETCH_TABLE_METADATA_SUCCESS",
703
- payload: { metadata },
704
- requestId
705
- };
706
- ws.send(JSON.stringify(response));
707
- }
708
- break;
709
-
710
- case "CREATE_BRANCH": {
711
- wsDebug("🌿 [WebSocket Server] Processing CREATE_BRANCH request");
712
- const { name, options } = payload;
713
- const delegate = await getScopedDelegate();
714
- if (!delegate.admin?.createBranch) {
715
- sendError("ERROR", "NOT_SUPPORTED", "Database branching is not available. Configure adminConnectionString.");
716
- break;
717
- }
718
- const branch: BranchInfo = await delegate.admin.createBranch(name, options);
719
- wsDebug(`🌿 [WebSocket Server] Branch created: ${branch.name}`);
720
- const response = {
721
- type: "CREATE_BRANCH_SUCCESS",
722
- payload: { branch },
723
- requestId
724
- };
725
- ws.send(JSON.stringify(response));
726
- }
727
- break;
728
-
729
- case "DELETE_BRANCH": {
730
- wsDebug("🗑️ [WebSocket Server] Processing DELETE_BRANCH request");
731
- const { name: branchName } = payload;
732
- const delegate = await getScopedDelegate();
733
- if (!delegate.admin?.deleteBranch) {
734
- sendError("ERROR", "NOT_SUPPORTED", "Database branching is not available.");
735
- break;
736
- }
737
- await delegate.admin.deleteBranch(branchName);
738
- wsDebug(`🗑️ [WebSocket Server] Branch deleted: ${branchName}`);
739
- const response = {
740
- type: "DELETE_BRANCH_SUCCESS",
741
- payload: { success: true },
742
- requestId
743
- };
744
- ws.send(JSON.stringify(response));
745
- }
746
- break;
747
-
748
- case "LIST_BRANCHES": {
749
- wsDebug("🌿 [WebSocket Server] Processing LIST_BRANCHES request");
750
- const delegate = await getScopedDelegate();
751
- let branches: BranchInfo[] = [];
752
- if (delegate.admin?.listBranches) {
753
- branches = await delegate.admin.listBranches();
754
- }
755
- wsDebug(`🌿 [WebSocket Server] Listed ${branches.length} branches.`);
756
- const response = {
757
- type: "LIST_BRANCHES_SUCCESS",
758
- payload: { branches },
759
- requestId
760
- };
761
- ws.send(JSON.stringify(response));
762
- }
763
- break;
764
-
765
- // Route subscription messages, broadcast channels, and presence to RealtimeService
766
- case "subscribe_collection":
767
- case "subscribe_one":
768
- case "unsubscribe":
769
- case "join_channel":
770
- case "leave_channel":
771
- case "broadcast":
772
- case "presence_track":
773
- case "presence_untrack":
774
- case "presence_state":
775
- case "channel_history": {
776
- wsDebug("🔄 [WebSocket Server] Routing realtime message to RealtimeService:", type);
777
- // Attach auth context from the WS session so RLS-aware refetches work
778
- const session = clientSessions.get(clientId);
779
- const authContext = session?.user
780
- ? { uid: session.user.uid,
781
- roles: session.user.roles ?? [] }
782
- : { uid: ANONYMOUS_USER_ID,
783
- roles: ["anon"] };
784
- // Let RealtimeService handle these messages
785
- await realtimeService.handleClientMessage(clientId, {
786
- type,
787
- payload,
788
- subscriptionId: payload?.subscriptionId
789
- }, authContext);
790
- break;
791
- }
792
-
793
- default:
794
- logger.error("❌ [WebSocket Server] Unknown message type", { detail: type });
795
- }
796
- } catch (error: unknown) {
797
- // A refused `limit` is the caller's mistake, not a server fault.
798
- // Left to the generic branch below it answers INTERNAL_ERROR
799
- // with the message suppressed in production — so the one thing
800
- // that would tell the caller what to send instead is exactly
801
- // what gets dropped. Answered here the way
802
- // `subscribe_collection` already answers it: INVALID_LIMIT,
803
- // message intact. The text names the ceiling and nothing else.
804
- if (error instanceof ListLimitError) {
805
- logger.warn(`[WebSocket Server] Refused a list read: ${error.message}`);
806
- ws.send(JSON.stringify({
807
- type: "ERROR",
808
- requestId,
809
- payload: { error: { message: error.message,
810
- code: "INVALID_LIMIT" } }
811
- }));
812
- return;
813
- }
814
- // A refused write is the caller's mistake, and its message is
815
- // the only thing that says what to send instead — the same
816
- // reasoning as `ListLimitError` above. Left to the generic
817
- // branch it becomes INTERNAL_ERROR with the text dropped in
818
- // production, so the socket would refuse the write and decline
819
- // to say why.
820
- if (error instanceof ApiError || (error as Error)?.name === "ApiError") {
821
- const apiError = error as ApiError;
822
- logger.warn(`[WebSocket Server] Refused a write: ${apiError.message}`);
823
- ws.send(JSON.stringify({
824
- type: "ERROR",
825
- requestId,
826
- payload: { error: { message: apiError.message,
827
- code: apiError.code } }
828
- }));
829
- return;
830
- }
831
- logger.error("💥 [WebSocket Server] Error handling message", { error: error });
832
- if (error instanceof Error) {
833
- logger.error("Stack trace", { detail: error.stack });
834
- }
835
- // Unwrap the cause chain: a Drizzle failure reports itself as
836
- // "Failed query: <sql> params:", which tells the user nothing and
837
- // echoes the statement back at them. The reason is in the cause.
838
- const errorMessage = process.env.NODE_ENV === "production"
839
- ? "An unexpected error occurred"
840
- : extractErrorMessage(error);
841
- const errorResponse = {
842
- type: "ERROR",
843
- requestId,
844
- payload: {
845
- error: {
846
- message: errorMessage,
847
- code: "INTERNAL_ERROR"
848
- }
849
- }
850
- };
851
- ws.send(JSON.stringify(errorResponse));
852
- }
853
- });
854
- });
855
- }