@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
@@ -1,42 +0,0 @@
1
- import { getTableColumns } from "drizzle-orm";
2
- import { PgArray, PgTable } from "drizzle-orm/pg-core";
3
- import { logger } from "@rebasepro/server";
4
-
5
- /**
6
- * Patches all PgArray columns on the given tables to handle NULL values safely.
7
- *
8
- * Drizzle ORM's `PgArray.mapFromDriverValue` calls `value.map(...)` without
9
- * guarding against `null`. When a PostgreSQL native array column (`text[]`,
10
- * `integer[]`, etc.) contains NULL, the pg driver returns `null` in JavaScript,
11
- * and `null.map(...)` throws `TypeError: value.map is not a function`.
12
- *
13
- * This function walks every column of every registered table and, for any
14
- * `PgArray` column, wraps its `mapFromDriverValue` to return `null` when the
15
- * database value is nullish.
16
- *
17
- * This is a workaround for a known Drizzle ORM issue. Should be removed once
18
- * Drizzle handles nullable arrays natively.
19
- */
20
- export function patchPgArrayNullSafety(tables: Record<string, unknown>): void {
21
- let patchedCount = 0;
22
-
23
- for (const tableOrRelation of Object.values(tables)) {
24
- if (!(tableOrRelation instanceof PgTable)) continue;
25
-
26
- const columns = getTableColumns(tableOrRelation);
27
- for (const column of Object.values(columns)) {
28
- if (column instanceof PgArray) {
29
- const original = column.mapFromDriverValue.bind(column);
30
- column.mapFromDriverValue = function (value: unknown) {
31
- if (value == null) return null;
32
- return original(value as string | unknown[]);
33
- };
34
- patchedCount++;
35
- }
36
- }
37
- }
38
-
39
- if (patchedCount > 0) {
40
- logger.debug(`[PgArray] Patched ${patchedCount} array column(s) for null-safety`);
41
- }
42
- }
@@ -1,391 +0,0 @@
1
- /**
2
- * Shared PostgreSQL error extraction and user-friendly message formatting.
3
- *
4
- * Drizzle wraps native PG errors in a `.cause` chain. These utilities
5
- * unwrap that chain to get the real PostgreSQL error (identified by a
6
- * 5-character alphanumeric `code` such as `42P01`) and translate it into
7
- * a message that is safe and helpful to show to end-users.
8
- */
9
-
10
- import { logger } from "@rebasepro/server";
11
-
12
- /**
13
- * Shape of a deliberate client-facing error — `ApiError` from
14
- * `@rebasepro/server`, or anything else carrying a 4xx `statusCode`.
15
- *
16
- * Matched structurally rather than with `instanceof`: `@rebasepro/server` can
17
- * be loaded twice (published dist vs. workspace source), which breaks class
18
- * identity — `PersistService` hedges against the same thing by also accepting
19
- * `name === "ApiError"`.
20
- */
21
- interface ClientFacingError extends Error {
22
- statusCode?: number;
23
- code?: string;
24
- /** See `ApiError.expected` — routine outcomes log at debug, not warn. */
25
- expected?: boolean;
26
- }
27
-
28
- /**
29
- * Return the error when it is a deliberate 4xx, otherwise null.
30
- *
31
- * A thrown `ApiError` is a decision the server made about the request, not a
32
- * database failure: its message and code are already written for the client.
33
- */
34
- function asClientFacingError(error: unknown): ClientFacingError | null {
35
- if (!(error instanceof Error)) return null;
36
- const e = error as ClientFacingError;
37
- if (typeof e.statusCode !== "number" || e.statusCode < 400 || e.statusCode >= 500) return null;
38
- return e;
39
- }
40
-
41
- /** Shape of PostgreSQL errors with diagnostic metadata. */
42
- export interface PostgresError extends Error {
43
- code?: string;
44
- detail?: string;
45
- hint?: string;
46
- constraint?: string;
47
- column?: string;
48
- table?: string;
49
- dataType?: string;
50
- cause?: unknown;
51
- }
52
-
53
- /**
54
- * Extract the underlying PostgreSQL error from a Drizzle wrapper.
55
- * Drizzle wraps PG errors in a `cause` property — this function
56
- * recursively walks the chain until it finds an object with a PG
57
- * error code (5-char alphanumeric, e.g. `42P01`).
58
- */
59
- export function extractPgError(error: unknown): PostgresError | null {
60
- if (!error || typeof error !== "object") return null;
61
- if (!(error instanceof Error)) {
62
- // Check non-Error objects for a cause chain (Drizzle sometimes wraps oddly)
63
- if ("cause" in error && (error as Record<string, unknown>).cause && typeof (error as Record<string, unknown>).cause === "object") {
64
- return extractPgError((error as Record<string, unknown>).cause);
65
- }
66
- return null;
67
- }
68
-
69
- // Check if the error itself has a PG error code
70
- if ("code" in error && typeof (error as PostgresError).code === "string" && /^[0-9A-Z]{5}$/.test((error as PostgresError).code!)) {
71
- return error as PostgresError;
72
- }
73
-
74
- // Check the cause chain (Drizzle wraps PG errors)
75
- if (error.cause && typeof error.cause === "object") {
76
- return extractPgError(error.cause);
77
- }
78
-
79
- return null;
80
- }
81
-
82
- /**
83
- * Whether the failure came back from Postgres rather than from building the
84
- * query — which decides whether a fallback query is worth issuing.
85
- *
86
- * Reads here run inside a transaction (that is where `SET LOCAL ROLE` binds
87
- * RLS). Once a statement raises, that transaction is aborted, and every later
88
- * statement on it returns `25P02` — "current transaction is aborted, commands
89
- * ignored until end of transaction block". So a retry after a database error
90
- * cannot succeed, and it replaces a precise diagnosis ("invalid input syntax
91
- * for type uuid") with a generic one. Rethrow instead.
92
- *
93
- * A query the driver could not even build — a missing reciprocal relation, say
94
- * — never reached Postgres, leaves the transaction usable, and is exactly what
95
- * the fallback paths exist for.
96
- */
97
- export function reachedDatabase(error: unknown): boolean {
98
- return extractPgError(error) !== null;
99
- }
100
-
101
- /**
102
- * Walk the error cause chain and return the deepest meaningful message.
103
- */
104
- export function extractCauseMessage(error: unknown): string | null {
105
- if (!error || typeof error !== "object") return null;
106
- if (!(error instanceof Error)) return null;
107
-
108
- if (error.cause && typeof error.cause === "object") {
109
- const deeper = extractCauseMessage(error.cause);
110
- if (deeper) return deeper;
111
- // The cause itself has a message
112
- if (error.cause instanceof Error && error.cause.message) {
113
- return error.cause.message;
114
- }
115
- }
116
- return null;
117
- }
118
-
119
- /**
120
- * Codes that mean "this connection will never work as configured".
121
- *
122
- * A wrong password or a database that does not exist is a settled fact about
123
- * the connection string, not a transient fault — retrying produces the same
124
- * answer forever.
125
- */
126
- const UNRECOVERABLE_CONNECT_CODES = new Set([
127
- "28P01", // invalid_password
128
- "28000", // invalid_authorization_specification
129
- "3D000", // invalid_catalog_name — the database does not exist
130
- "42501" // insufficient_privilege
131
- ]);
132
-
133
- export interface ConnectFailure {
134
- /** True when retrying cannot help: the connection string itself is wrong. */
135
- fatal: boolean;
136
- /** The deepest message available — the Postgres one where there is one. */
137
- reason: string;
138
- /** The `SQLSTATE`, when the failure came from Postgres rather than the socket. */
139
- code?: string;
140
- }
141
-
142
- /**
143
- * Describe a failed connection attempt in terms a developer can act on.
144
- *
145
- * The error a caller catches is Drizzle's wrapper: its message is
146
- * `Failed query: SELECT 1` and its stack runs through drizzle internals, while
147
- * the sentence that says what is actually wrong — "password authentication
148
- * failed for user …", "database … does not exist" — sits in `.cause`. Logging
149
- * the wrapper, as the bootstrapper used to, tells a developer with a typo in
150
- * their `DATABASE_URL` nothing at all.
151
- */
152
- export function classifyConnectFailure(error: unknown): ConnectFailure {
153
- const pgError = extractPgError(error);
154
- const reason =
155
- pgError?.message ??
156
- extractCauseMessage(error) ??
157
- (error instanceof Error ? error.message : String(error));
158
- return {
159
- fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),
160
- reason,
161
- code: pgError?.code
162
- };
163
- }
164
-
165
- /**
166
- * Detect whether an error is specifically a role-switching permission failure
167
- * (e.g. "permission denied to set role" or "must be member of role"),
168
- * as opposed to a table-level permission denial.
169
- *
170
- * This is used by the backend driver to auto-disable role switching when the
171
- * connection user lacks SET ROLE privileges, rather than surfacing a confusing
172
- * error to the Studio SQL Editor user.
173
- */
174
- export function isRoleSwitchingPermissionError(error: unknown): boolean {
175
- const pgError = extractPgError(error);
176
- if (!pgError || pgError.code !== "42501") return false;
177
- const msg = pgError.message.toLowerCase();
178
- return msg.includes("set role") || msg.includes("member of role");
179
- }
180
-
181
- /**
182
- * Was this `42501` the *caller* being refused by a policy, rather than the
183
- * server lacking a privilege?
184
- *
185
- * Both arrive as `insufficient_privilege`, and they are opposite kinds of
186
- * problem. A row-level-security refusal is a working access-control system
187
- * doing its job: the caller asked for something their policies do not permit,
188
- * which is a 403 and nobody's bug. A missing `GRANT` is the deployment being
189
- * wrong — the connection role cannot touch the table at all, no policy is
190
- * involved, and nothing the caller changes about the request will help.
191
- *
192
- * Postgres distinguishes them in the message, so this does too:
193
- *
194
- * new row violates row-level security policy for table "notes" → the caller
195
- * permission denied for table notes → the server
196
- *
197
- * Only writes reach this. A read that RLS excludes is not an error — the rows
198
- * are filtered and the caller gets an empty page — so the erroring case is
199
- * specifically an `INSERT`/`UPDATE` whose row fails a policy's `WITH CHECK`.
200
- *
201
- * Matched on the message because that is the only thing carrying the
202
- * distinction; the SQLSTATE is identical either way. Narrow by design: anything
203
- * not naming row-level security stays the server's problem, since reporting a
204
- * genuine privilege misconfiguration as "forbidden" would send an operator
205
- * hunting for a policy bug that does not exist.
206
- */
207
- export function isRowLevelSecurityDenial(error: unknown): boolean {
208
- const pgError = extractPgError(error);
209
- if (!pgError || pgError.code !== "42501") return false;
210
- return pgError.message.toLowerCase().includes("row-level security policy");
211
- }
212
-
213
- /**
214
- * Translate a raw PostgreSQL error into a user-friendly message.
215
- *
216
- * @param pgError - The extracted PostgreSQL error (from {@link extractPgError})
217
- * @param context - A human-readable context string (e.g. collection slug or path)
218
- * @returns An object with a `message` safe for the client and the PG `code`.
219
- */
220
- export function pgErrorToFriendlyMessage(pgError: PostgresError, context: string): { message: string; code: string } {
221
- const detail = pgError.detail as string | undefined;
222
- const hint = pgError.hint as string | undefined;
223
- const constraint = pgError.constraint as string | undefined;
224
- const column = pgError.column as string | undefined;
225
- const table = pgError.table as string | undefined;
226
- const dataType = pgError.dataType as string | undefined;
227
- const pgMessage = pgError.message || "Unknown database error";
228
- const code = pgError.code || "UNKNOWN";
229
-
230
- const suffix = hint ? ` Hint: ${hint}` : "";
231
- const tableRef = table ?? context;
232
-
233
- switch (pgError.code) {
234
- case "23503": // foreign_key_violation
235
- return {
236
- message: detail
237
- ? `Foreign key constraint violated: ${detail}${suffix}`
238
- : `Cannot complete operation: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
239
- code
240
- };
241
- case "23505": // unique_violation
242
- return {
243
- message: detail
244
- ? `Duplicate value: ${detail}${suffix}`
245
- : `Cannot complete operation: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
246
- code
247
- };
248
- case "23502": // not_null_violation
249
- return {
250
- message: `Missing required field: "${column ?? "unknown"}" in "${tableRef}" cannot be empty.${suffix}`,
251
- code
252
- };
253
- case "23514": // check_violation
254
- return {
255
- message: `Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
256
- code
257
- };
258
- case "22P02": // invalid_text_representation (e.g. invalid UUID, wrong enum value)
259
- return {
260
- message: `Invalid data format in "${context}": ${pgMessage}${suffix}`,
261
- code
262
- };
263
- case "22001": // string_data_right_truncation (value too long)
264
- return {
265
- message: `Value too long for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`,
266
- code
267
- };
268
- case "22003": // numeric_value_out_of_range
269
- return {
270
- message: `Numeric value out of range for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`,
271
- code
272
- };
273
- case "42703": // undefined_column
274
- return {
275
- message: `Unknown column in "${tableRef}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`,
276
- code
277
- };
278
- case "42P01": // undefined_table
279
- return {
280
- message: `Table not found for "${context}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`,
281
- code
282
- };
283
- case "42501": // insufficient_privilege
284
- // Two unrelated failures share this SQLSTATE, and the old message
285
- // named both causes because it could not tell them apart — which
286
- // meant it was half wrong whichever one had happened, and sent the
287
- // reader to check the other. Postgres says which in its own message.
288
- return pgMessage.toLowerCase().includes("row-level security policy")
289
- ? {
290
- // The caller. Their policies do not permit this row; the
291
- // deployment is working exactly as configured.
292
- message: `Not permitted to write this row in "${tableRef}": it does not satisfy the row-level security policy.${suffix}`,
293
- code
294
- }
295
- : {
296
- // The deployment. No policy is involved — the connecting
297
- // role cannot touch the table at all.
298
- message: `Permission denied on "${tableRef}": the database role this server connects as lacks privileges on it.${suffix}`,
299
- code
300
- };
301
- case "28000": // invalid_authorization_specification
302
- return {
303
- message: `Authorization failed for "${context}". Check your database credentials.${suffix}`,
304
- code
305
- };
306
- default: {
307
- // Unhandled PG code — still surface the actual database message
308
- const parts = [`Database error in "${context}" [${code}]: ${pgMessage}`];
309
- if (detail) parts.push(`Detail: ${detail}`);
310
- if (column) parts.push(`Column: ${column}`);
311
- if (dataType) parts.push(`Data type: ${dataType}`);
312
- if (constraint) parts.push(`Constraint: ${constraint}`);
313
- if (hint) parts.push(`Hint: ${hint}`);
314
- return { message: parts.join(". "), code };
315
- }
316
- }
317
- }
318
-
319
- /**
320
- * Sanitize any error into a message safe and helpful for the client.
321
- *
322
- * A deliberate 4xx (`ApiError`) passes through untouched — the server already
323
- * decided what the client should read. Otherwise the PG error is extracted
324
- * from the Drizzle cause chain, falling back to a generic message that
325
- * doesn't leak SQL.
326
- *
327
- * @param error - The raw caught error
328
- * @param context - A human-readable context string (e.g. collection path)
329
- * @returns An object with `message` (user-friendly) and optional `code`
330
- * (the `ApiError` code, or the PG SQLSTATE).
331
- */
332
- export function sanitizeErrorForClient(error: unknown, context: string): { message: string; code?: string } {
333
- // ── A deliberate 4xx is not a database failure ──────────────────
334
- // Its message and code are written for the client; replacing them with
335
- // "Check server logs" would discard the only diagnosis the caller gets
336
- // (e.g. the offending filter field and the collection's valid ones).
337
- // Log level follows the same convention as the HTTP error handler in
338
- // @rebasepro/server: routine outcomes at debug, everything else at warn.
339
- const clientError = asClientFacingError(error);
340
- if (clientError) {
341
- const line = `[API ${clientError.statusCode} ${clientError.code ?? "BAD_REQUEST"}] in "${context}": ${clientError.message}`;
342
- if (clientError.expected) {
343
- logger.debug(line);
344
- } else {
345
- logger.warn(`⚠️ ${line}`);
346
- }
347
- return { message: clientError.message, ...(clientError.code && { code: clientError.code }) };
348
- }
349
-
350
- // ── Always log the full, unsanitized error server-side ──────────
351
- const pgError = extractPgError(error);
352
-
353
- if (pgError) {
354
- logger.error(`[PG ${pgError.code}] Error in "${context}"`, {
355
- code: pgError.code,
356
- message: pgError.message,
357
- detail: pgError.detail,
358
- hint: pgError.hint,
359
- column: pgError.column,
360
- table: pgError.table,
361
- constraint: pgError.constraint,
362
- dataType: pgError.dataType
363
- // The outer Drizzle wrapper message used to be logged here "for
364
- // full context": it is `Failed query: <sql>\nparams: <values>`, so
365
- // it published the statement and every bound value (an email, a
366
- // password hash) on every realtime data failure. The SQLSTATE,
367
- // detail, table, column and constraint above are the diagnostic
368
- // value; the wrapper added only the leak. `logger` strips the
369
- // wrapper as well, but the field itself carried nothing else.
370
- });
371
- return pgErrorToFriendlyMessage(pgError, context);
372
- }
373
-
374
- // No PG error found — log the raw error as-is
375
- logger.error(`Database error in "${context}" (no PG error extracted)`, {
376
- error: error instanceof Error ? error.message : String(error),
377
- stack: error instanceof Error ? error.stack : undefined,
378
- cause: error instanceof Error && error.cause
379
- ? (error.cause instanceof Error ? error.cause.message : String(error.cause))
380
- : undefined
381
- });
382
-
383
- // Try to get the deepest cause message
384
- const causeMessage = extractCauseMessage(error);
385
- if (causeMessage) {
386
- return { message: `Database error in "${context}": ${causeMessage}` };
387
- }
388
-
389
- // Last resort — generic message, never leak raw SQL
390
- return { message: `Could not load data for "${context}". Check server logs for details.` };
391
- }