turbine-orm 0.77.1 → 0.78.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 (93) hide show
  1. package/README.md +13 -9
  2. package/dist/cjs/cli/config.d.ts +7 -1
  3. package/dist/cjs/cli/config.js +11 -2
  4. package/dist/cjs/cli/destructive.d.ts +1 -1
  5. package/dist/cjs/cli/destructive.js +307 -9
  6. package/dist/cjs/cli/index.js +252 -42
  7. package/dist/cjs/cli/mcp.d.ts +23 -0
  8. package/dist/cjs/cli/mcp.js +190 -152
  9. package/dist/cjs/cli/migrate.d.ts +243 -3
  10. package/dist/cjs/cli/migrate.js +432 -43
  11. package/dist/cjs/cli/sql-statements.js +27 -0
  12. package/dist/cjs/cli/studio.d.ts +0 -1
  13. package/dist/cjs/cli/studio.js +9 -7
  14. package/dist/cjs/client.d.ts +8 -1
  15. package/dist/cjs/client.js +7 -0
  16. package/dist/cjs/connection-url.d.ts +82 -0
  17. package/dist/cjs/connection-url.js +187 -1
  18. package/dist/cjs/errors.d.ts +112 -12
  19. package/dist/cjs/errors.js +558 -114
  20. package/dist/cjs/generate.js +47 -15
  21. package/dist/cjs/index.d.ts +1 -1
  22. package/dist/cjs/introspect.d.ts +33 -0
  23. package/dist/cjs/introspect.js +54 -1
  24. package/dist/cjs/mssql.js +21 -1
  25. package/dist/cjs/nested-write.js +85 -14
  26. package/dist/cjs/pipeline-submittable.d.ts +2 -0
  27. package/dist/cjs/pipeline-submittable.js +88 -3
  28. package/dist/cjs/pipeline.js +13 -1
  29. package/dist/cjs/powdb-introspect.d.ts +5 -1
  30. package/dist/cjs/powdb-introspect.js +5 -1
  31. package/dist/cjs/powql.d.ts +14 -0
  32. package/dist/cjs/powql.js +44 -4
  33. package/dist/cjs/prisma-compat.js +95 -8
  34. package/dist/cjs/query/aggregates.js +56 -6
  35. package/dist/cjs/query/builder.d.ts +76 -13
  36. package/dist/cjs/query/builder.js +188 -58
  37. package/dist/cjs/query/compound-unique.d.ts +76 -0
  38. package/dist/cjs/query/compound-unique.js +129 -0
  39. package/dist/cjs/query/index.d.ts +1 -1
  40. package/dist/cjs/query/types.d.ts +65 -11
  41. package/dist/cjs/query/where.d.ts +85 -19
  42. package/dist/cjs/query/where.js +262 -47
  43. package/dist/cjs/query/writes.d.ts +11 -2
  44. package/dist/cjs/query/writes.js +116 -21
  45. package/dist/cjs/seed.d.ts +16 -0
  46. package/dist/cjs/seed.js +16 -0
  47. package/dist/cli/config.d.ts +7 -1
  48. package/dist/cli/config.js +11 -2
  49. package/dist/cli/destructive.d.ts +1 -1
  50. package/dist/cli/destructive.js +307 -9
  51. package/dist/cli/index.js +254 -44
  52. package/dist/cli/mcp.d.ts +23 -0
  53. package/dist/cli/mcp.js +187 -150
  54. package/dist/cli/migrate.d.ts +243 -3
  55. package/dist/cli/migrate.js +423 -45
  56. package/dist/cli/sql-statements.js +27 -0
  57. package/dist/cli/studio.d.ts +0 -1
  58. package/dist/cli/studio.js +10 -7
  59. package/dist/client.d.ts +8 -1
  60. package/dist/client.js +7 -0
  61. package/dist/connection-url.d.ts +82 -0
  62. package/dist/connection-url.js +183 -0
  63. package/dist/errors.d.ts +112 -12
  64. package/dist/errors.js +558 -114
  65. package/dist/generate.js +47 -15
  66. package/dist/index.d.ts +1 -1
  67. package/dist/introspect.d.ts +33 -0
  68. package/dist/introspect.js +53 -1
  69. package/dist/mssql.js +21 -1
  70. package/dist/nested-write.js +85 -14
  71. package/dist/pipeline-submittable.d.ts +2 -0
  72. package/dist/pipeline-submittable.js +87 -3
  73. package/dist/pipeline.js +14 -2
  74. package/dist/powdb-introspect.d.ts +5 -1
  75. package/dist/powdb-introspect.js +5 -1
  76. package/dist/powql.d.ts +14 -0
  77. package/dist/powql.js +45 -5
  78. package/dist/prisma-compat.js +96 -9
  79. package/dist/query/aggregates.js +56 -6
  80. package/dist/query/builder.d.ts +76 -13
  81. package/dist/query/builder.js +188 -58
  82. package/dist/query/compound-unique.d.ts +76 -0
  83. package/dist/query/compound-unique.js +126 -1
  84. package/dist/query/index.d.ts +1 -1
  85. package/dist/query/types.d.ts +65 -11
  86. package/dist/query/where.d.ts +85 -19
  87. package/dist/query/where.js +260 -47
  88. package/dist/query/writes.d.ts +11 -2
  89. package/dist/query/writes.js +117 -22
  90. package/dist/seed.d.ts +16 -0
  91. package/dist/seed.js +16 -0
  92. package/package.json +3 -3
  93. package/skills/turbine-orm/SKILL.md +37 -10
package/dist/errors.js CHANGED
@@ -59,8 +59,38 @@ function formatErrorMessage(code, message) {
59
59
  const body = message.startsWith(tag) ? message : message ? `${tag} ${message}` : tag;
60
60
  return `${body}${link}`;
61
61
  }
62
+ /**
63
+ * The cross-copy identity brand every Turbine error carries as an own,
64
+ * non-enumerable symbol property.
65
+ *
66
+ * The package ships an ESM build and a CJS build, and one process can hold
67
+ * both: a generated client compiled as CommonJS `require`s `dist/cjs` while an
68
+ * `.mts` entry file `import`s `dist`. The error is then constructed by one
69
+ * copy's class and tested against the other copy's, and prototype-chain
70
+ * `instanceof` says false, so every documented
71
+ * `if (err instanceof UniqueConstraintError)` branch is skipped and the error
72
+ * propagates as unhandled, with nothing logged. `Symbol.for` gives both copies
73
+ * the same key, so {@link TurbineError[Symbol.hasInstance]} can recognise the
74
+ * other copy's instances by brand plus code instead of by class identity. Same
75
+ * pattern as {@link VALUE_BEARING_MESSAGE} below.
76
+ */
77
+ const TURBINE_ERROR_BRAND = Symbol.for('turbine-orm.error');
62
78
  /** Base error class for all Turbine errors */
63
79
  export class TurbineError extends Error {
80
+ /**
81
+ * The code every instance of this class passes to `super()`, declared once
82
+ * per subclass on the line above the constructor that names it, so the two
83
+ * are read together. It exists for {@link TurbineError[Symbol.hasInstance]}:
84
+ * a subclass recognises a branded error from another copy of this module
85
+ * when the codes agree. `undefined` here on the base class, which therefore
86
+ * recognises any branded error.
87
+ *
88
+ * A static per class rather than a class-to-code map because the code is
89
+ * already written once per class; this puts the second mention next to the
90
+ * first instead of in a table at the bottom of the file, and a unit test
91
+ * asserts the two agree for every exported class.
92
+ */
93
+ static CODE = undefined;
64
94
  code;
65
95
  /** Docs page for this code, e.g. `https://turbineorm.dev/errors#e003`. */
66
96
  docsUrl;
@@ -75,6 +105,45 @@ export class TurbineError extends Error {
75
105
  this.name = 'TurbineError';
76
106
  this.code = code;
77
107
  this.docsUrl = docsUrlForCode(code);
108
+ // Non-enumerable, like the message flag, so the brand never appears in a
109
+ // serialized error; non-writable so nothing downstream can unbrand it.
110
+ Object.defineProperty(this, TURBINE_ERROR_BRAND, {
111
+ value: true,
112
+ enumerable: false,
113
+ writable: false,
114
+ configurable: false,
115
+ });
116
+ }
117
+ /**
118
+ * `instanceof` that survives two copies of this module in one process.
119
+ *
120
+ * The ordinary prototype-chain answer is taken first, so a single-copy
121
+ * process behaves exactly as before, user subclasses included. Only when that
122
+ * says no is the brand consulted: a branded object is an instance of the base
123
+ * class outright, and of a coded subclass when its `code` equals that class's
124
+ * {@link CODE}. The `hasOwn` guard restricts the code match to classes that
125
+ * declare their own CODE, i.e. the ones in this module: a user subclass such
126
+ * as `class MyError extends ValidationError {}` inherits E003 without
127
+ * declaring it, and every ValidationError must not become an instance of the
128
+ * user's narrower class.
129
+ */
130
+ static [Symbol.hasInstance](value) {
131
+ // `this` is the class `instanceof` was invoked on, which for an inherited
132
+ // static is the SUBCLASS (NotFoundError, not TurbineError); naming the base
133
+ // class here, as the lint rule wants, would make every subclass answer for
134
+ // the base. Named once so the reason is stated once.
135
+ // biome-ignore lint/complexity/noThisInStatic: the receiver is the subclass being tested against, see above
136
+ // biome-ignore lint/complexity/noUselessThisAlias: one alias carries one suppression instead of three
137
+ const cls = this;
138
+ if (Function.prototype[Symbol.hasInstance].call(cls, value))
139
+ return true;
140
+ if (value === null || typeof value !== 'object')
141
+ return false;
142
+ if (value[TURBINE_ERROR_BRAND] !== true)
143
+ return false;
144
+ if (!Object.hasOwn(cls, 'CODE'))
145
+ return false;
146
+ return cls.CODE === undefined || value.code === cls.CODE;
78
147
  }
79
148
  }
80
149
  let errorMessageMode = 'safe';
@@ -158,17 +227,28 @@ export function runWithErrorMessageMode(mode, fn) {
158
227
  * clause (e.g. `where: {"id":1,"email":"alice@x.com"}`).
159
228
  *
160
229
  * SCOPE, stated precisely because the useful version of this contract is the
161
- * one that is true. 'safe' mode redacts row values from the surfaces Turbine
162
- * OWNS: its own error messages, and the `detail` field of a driver error it
163
- * wraps and attaches as `.cause` (see redactCauseForMode).
164
- *
165
- * It is NOT a blanket guarantee that no row value can be reached from a thrown
166
- * error. A driver error whose SQLSTATE {@link wrapPgError} does not classify is
167
- * returned UNCHANGED, and some of those carry a value in the `message` field
168
- * itself, where nothing can be removed without destroying the diagnosis:
169
- * `22P02 invalid input syntax for type integer: "alice@example.com"` is the
170
- * common one. Treat 'safe' mode as removing Turbine's own contribution to the
171
- * leak, not as a log-scrubbing boundary.
230
+ * one that is true. 'safe' mode redacts row and parameter values from every
231
+ * error Turbine raises or wraps:
232
+ * - its own messages;
233
+ * - the `detail`, `hint`, `where` (the CONTEXT field, which carries the bound
234
+ * PARAMETERS when `log_parameter_max_length_on_error` is non-zero) and
235
+ * `internalQuery` fields of a driver error it attaches as `.cause`, and of
236
+ * the error it hands back. See {@link VALUE_BEARING_FIELDS} for what puts a
237
+ * value in each of them;
238
+ * - the driver MESSAGE where that is what carries the value, so `22P02
239
+ * invalid input syntax for type integer: "alice@example.com"` becomes a
240
+ * ValidationError whose safe-mode text names the column and the SQLSTATE
241
+ * and nothing else (see {@link wrapPgError});
242
+ * - the message of a PostgreSQL server error whose SQLSTATE wrapPgError has
243
+ * no class for AND whose SQLSTATE class can hold a value in its message
244
+ * text (`P0001`, say, whose text the function author wrote), replaced by a
245
+ * sentence naming the SQLSTATE and saying the text was withheld. A class
246
+ * whose grammar is written over schema object names keeps its text, because
247
+ * withholding what cannot leak protects nothing: see
248
+ * {@link messageTextIsValueFree}.
249
+ * It is not a log-scrubbing boundary for errors that never pass through
250
+ * Turbine, and an unclassified NATIVE error code from one of the non-Postgres
251
+ * engines is left to that engine's own classifier.
172
252
  */
173
253
  export function setErrorMessageMode(mode) {
174
254
  errorMessageMode = mode;
@@ -205,11 +285,14 @@ export const REDACTED_DETAIL = '[redacted by turbine errorMessages:"safe"]';
205
285
  * every log line, Sentry event and uncaught-rejection dump that renders the
206
286
  * cause chain, in the mode whose entire job is to prevent exactly that.
207
287
  *
208
- * A flag rather than a code list, and set by the ENGINE that knows its own
209
- * message grammar, for two reasons. It keeps Postgres byte-identical (pg never
210
- * sets it, so its cause is returned exactly as before), and it puts "this
211
- * engine's message embeds values" next to the code that reads that engine's
212
- * messages instead of in a table here that would silently rot.
288
+ * A flag rather than a code list, and set by the code that knows the message
289
+ * grammar in question, for two reasons. It keeps every constraint-class
290
+ * Postgres cause byte-identical (nothing sets it for those, so they are
291
+ * returned exactly as before; `wrapPgError` sets it only for the class-22 and
292
+ * `to_tsquery` shapes whose value sits in `message`, and for a server error it
293
+ * cannot classify at all), and it puts "this engine's message embeds values"
294
+ * next to the code that reads that engine's messages instead of in a table
295
+ * here that would silently rot.
213
296
  *
214
297
  * `Symbol.for` so the ESM and CJS copies of this module agree on the key: a
215
298
  * dual-package consumer can hand an error marked by one build to the other.
@@ -251,84 +334,125 @@ export function markValueBearingMessage(err) {
251
334
  */
252
335
  function redactedDriverMessage(code) {
253
336
  const codePart = typeof code === 'string' && code.length > 0 ? ` (driver code ${code})` : '';
254
- return `${REDACTED_DETAIL}${codePart}: this engine reports the conflicting row values in the message text, so errorMessages:"safe" withholds it. Use errorMessages:"verbose" to see it.`;
337
+ return `${REDACTED_DETAIL}${codePart}: the driver puts a row or parameter value in this message text, so errorMessages:"safe" withholds it. Use errorMessages:"verbose" to see it.`;
255
338
  }
256
339
  /**
257
- * Postgres puts the CONFLICTING ROW VALUES in the `detail` field of a
258
- * constraint error, and nowhere else: `Key (email)=(alice@example.com) already
259
- * exists.` for 23505, `Failing row contains (7, alice@example.com, …)` for
260
- * 23502. The `message` field carries only relation/constraint/column NAMES.
340
+ * The PostgreSQL error fields that can carry a ROW or PARAMETER value, and are
341
+ * therefore replaced by {@link REDACTED_DETAIL} in 'safe' mode wherever this
342
+ * module clones a driver error.
343
+ *
344
+ * `detail` was the whole list until it turned out not to be. All four ride the
345
+ * same ErrorResponse wire message, node-postgres copies all four verbatim onto
346
+ * the error object as own ENUMERABLE properties, and `util.inspect` (which is
347
+ * what `console.error`, an uncaught rejection and every structured log
348
+ * serializer effectively run) prints all four:
261
349
  *
262
- * 'safe' mode keeps those values out of the Turbine error's own message, but
263
- * the raw driver error used to be attached verbatim as `.cause`, so the values
264
- * still reached every place an error object gets rendered whole:
350
+ * - `detail` (D) is where a constraint error puts the conflicting values:
351
+ * `Key (email)=(alice@example.com) already exists.` for 23505, `Failing row
352
+ * contains (7, alice@example.com, ...)` for 23502. The `message` of those
353
+ * carries only relation / constraint / column NAMES, which is why redacting
354
+ * `detail` alone looked like the whole job.
355
+ * - `hint` (H): server-generated hints name catalog objects, but
356
+ * `RAISE ... USING HINT = 'the email was ' || v` inside a plpgsql function
357
+ * puts a row value there. It needs no non-default server setting and no
358
+ * cooperation from the application.
359
+ * - `where` (W), the CONTEXT field: with `log_parameter_max_length_on_error`
360
+ * set to anything but 0 the server appends `unnamed portal parameter $1 =
361
+ * '...'`, i.e. THE BOUND PARAMETERS, to the context of any error raised
362
+ * while executing a bound statement.
363
+ * - `internalQuery` (q): the SQL text of a statement executed INSIDE a
364
+ * function, so an `EXECUTE format('SELECT %L::int', v)` hands the
365
+ * interpolated value straight back as SQL text.
366
+ *
367
+ * Redacted on EVERY SQLSTATE, deliberately, including the classes whose
368
+ * `message` is kept (see {@link messageTextIsValueFree}). None of these three
369
+ * is tied to the SQLSTATE the way a message grammar is: parameter context can
370
+ * attach to an error of any class, `format()` can build any internal query, and
371
+ * `RAISE ... USING ERRCODE` lets author-written hint text ride whatever code the
372
+ * author picked. That allowlist is a claim about one field, `message`, and
373
+ * stretching it over these three would be a claim nothing supports.
374
+ *
375
+ * NOT substituted out of the rendered `stack`, unlike a withheld message. V8
376
+ * renders a stack as `<name>: <message>` plus frames, so none of these fields is
377
+ * ever in it, and a global substitution of arbitrary short server text across
378
+ * the frames would risk mangling them to remove something that is not there.
379
+ */
380
+ const VALUE_BEARING_FIELDS = ['detail', 'hint', 'where', 'internalQuery'];
381
+ /**
382
+ * The property overrides that redact every {@link VALUE_BEARING_FIELDS} entry
383
+ * `err` actually carries; empty when it carries none, which is the signal both
384
+ * callers use to skip cloning altogether.
385
+ *
386
+ * Replaces the DESCRIPTOR rather than assigning after the clone exists: a
387
+ * non-writable field would make the assignment throw in strict mode (every
388
+ * module here is ESM, so it always would), and losing the error is worse than
389
+ * paying for one descriptor literal.
390
+ */
391
+ function redactValueBearingFields(err) {
392
+ const overrides = {};
393
+ for (const key of VALUE_BEARING_FIELDS) {
394
+ const current = err[key];
395
+ if (typeof current !== 'string' || current.length === 0)
396
+ continue;
397
+ overrides[key] = {
398
+ value: REDACTED_DETAIL,
399
+ writable: true,
400
+ // Keep whichever visibility the driver chose so the clone serializes with
401
+ // the same key set as the original.
402
+ enumerable: Object.getOwnPropertyDescriptor(err, key)?.enumerable ?? true,
403
+ configurable: true,
404
+ };
405
+ }
406
+ return overrides;
407
+ }
408
+ /**
409
+ * 'safe' mode keeps a driver error's row values out of the Turbine error's own
410
+ * message, but the raw driver error used to be attached verbatim as `.cause`,
411
+ * so the values still reached every place an error object gets rendered whole:
265
412
  * - `console.error(err)` / an uncaught rejection: Node's error printer walks
266
- * the cause chain and prints `[cause]: detail: 'Key (email)=()'`. Note
267
- * that `cause` is ALREADY non-enumerable (the Error constructor defines it
268
- * that way) and Node prints it anyway, so hiding the property is not a fix;
413
+ * the cause chain and prints `[cause]: ... detail: 'Key (email)=(...)'`.
414
+ * Note that `cause` is ALREADY non-enumerable (the Error constructor defines
415
+ * it that way) and Node prints it anyway, so hiding the property is not a
416
+ * fix;
269
417
  * - Sentry and similar sinks link `cause` chains by default and serialize
270
418
  * each link's own properties.
271
419
  *
272
- * So in 'safe' mode the cause is replaced by a shallow clone with `detail`
273
- * swapped for {@link REDACTED_DETAIL}. Cloning rather than mutating leaves the
274
- * driver's own object untouched (a caller holding it from their own catch sees
275
- * what the driver produced).
420
+ * So in 'safe' mode the cause is replaced by a shallow clone with every
421
+ * {@link VALUE_BEARING_FIELDS} entry swapped for {@link REDACTED_DETAIL}.
422
+ * Cloning rather than mutating leaves the driver's own object untouched (a
423
+ * caller holding it from their own catch sees what the driver produced).
276
424
  *
277
- * The clone must remain a REAL error, which is the part that is easy to get
278
- * wrong. `Object.create(proto, descriptors)` looks equivalent and is not: V8
279
- * installs `stack` as an own ACCESSOR whose backing store is the internal
280
- * [[ErrorData]] slot, and that slot is not a property, so it is not copied. The
281
- * result reads `cause.stack === undefined`, `util.types.isNativeError(cause) ===
282
- * false` and `Object.prototype.toString.call(cause) === '[object Object]'`, i.e.
283
- * every log serializer that does `err.cause.stack.split('\n')` throws a
284
- * TypeError and Sentry/pino drop the cause's frames. So the clone starts life
285
- * as `new Error()` (which HAS the slot), is re-prototyped to the driver error's
286
- * own prototype, and takes the original's stack as a plain string. That keeps
287
- * `cause instanceof pg.DatabaseError`, `cause.code === '23505'`, the native
288
- * brand, and the frames.
425
+ * The clone must remain a REAL error (string stack, native brand,
426
+ * `instanceof pg.DatabaseError`); see {@link cloneErrorWithOverrides} for why
427
+ * `Object.create` is not enough.
289
428
  *
290
429
  * In 'verbose' mode the cause passes through untouched: that mode's documented
291
430
  * job is full-fidelity debugging.
292
431
  *
293
432
  * ENGINES WHOSE MESSAGE CARRIES THE VALUE. MySQL and SQL Server do not have a
294
433
  * `detail` field at all and put the conflicting value in `message`, so the
295
- * early return below used to hand the raw driver error straight back (see
296
- * {@link markValueBearingMessage}). When the engine set that flag the clone
297
- * also withholds `message`, `sqlMessage` (mysql2's copy, which is the field
298
- * mysql2 formats `message` FROM, so leaving it would put the value straight
299
- * back) and the message text embedded in the rendered `stack` string. The stack
300
- * substitution is an exact replacement of the known message string, never a
301
- * grammar guess, so the frames survive intact.
434
+ * "nothing to remove" early return below used to hand the raw driver error
435
+ * straight back (see {@link markValueBearingMessage}). When the engine set that
436
+ * flag the clone also withholds `message`, `sqlMessage` (mysql2's copy, which is
437
+ * the field mysql2 formats `message` FROM, so leaving it would put the value
438
+ * straight back) and the message text embedded in the rendered `stack` string.
439
+ * The stack substitution is an exact replacement of the known message string,
440
+ * never a grammar guess, so the frames survive intact.
302
441
  */
303
442
  function redactCauseForMode(cause) {
304
443
  if (currentErrorMessageMode() === 'verbose')
305
444
  return cause;
306
445
  if (!cause || typeof cause !== 'object')
307
446
  return cause;
308
- const detail = cause.detail;
309
- const hasDetail = typeof detail === 'string' && detail.length > 0;
310
447
  const valueBearingMessage = cause[VALUE_BEARING_MESSAGE] === true;
311
- // Nothing value-bearing to remove: return the original object so the common
312
- // case (a non-pg cause, or a pg error without a detail) allocates nothing and
313
- // keeps object identity with what the driver threw.
314
- if (!hasDetail && !valueBearingMessage)
315
- return cause;
316
448
  try {
317
- const descriptors = Object.getOwnPropertyDescriptors(cause);
318
- // Replace the descriptor rather than assigning after the clone exists: a
319
- // non-writable `detail` would make the assignment throw in strict mode
320
- // (every module here is ESM, so it always would), and losing the cause is
321
- // worse than paying for one descriptor literal.
322
- if (hasDetail) {
323
- descriptors.detail = {
324
- value: REDACTED_DETAIL,
325
- writable: true,
326
- enumerable: descriptors.detail?.enumerable ?? true,
327
- configurable: true,
328
- };
329
- }
330
- // The exact message strings to scrub out of the rendered stack, collected
331
- // BEFORE the descriptors are overwritten.
449
+ const overrides = redactValueBearingFields(cause);
450
+ // Nothing value-bearing to remove: return the original object so the common
451
+ // case (a non-pg cause, or a pg error carrying none of these fields)
452
+ // allocates nothing and keeps object identity with what the driver threw.
453
+ if (!valueBearingMessage && Object.keys(overrides).length === 0)
454
+ return cause;
455
+ // The exact message strings to scrub out of the rendered stack.
332
456
  const withheldTexts = [];
333
457
  if (valueBearingMessage) {
334
458
  const code = cause.code;
@@ -338,59 +462,83 @@ function redactCauseForMode(cause) {
338
462
  if (typeof current !== 'string' || current.length === 0)
339
463
  continue;
340
464
  withheldTexts.push(current);
341
- descriptors[key] = {
465
+ overrides[key] = {
342
466
  value: replacement,
343
467
  writable: true,
344
468
  // `message` is non-enumerable on a native Error and mysql2's
345
469
  // `sqlMessage` is enumerable; keep whichever the driver chose so the
346
470
  // clone serializes with the same key set as the original.
347
- enumerable: descriptors[key]?.enumerable ?? false,
471
+ enumerable: Object.getOwnPropertyDescriptor(cause, key)?.enumerable ?? false,
348
472
  configurable: true,
349
473
  };
350
474
  }
351
475
  }
352
- // Brand check rather than `instanceof Error`, so a driver error thrown from
353
- // another realm (a worker, a bundled duplicate of pg) is still recognized.
354
- const isError = Object.prototype.toString.call(cause) === '[object Error]';
355
- if (!isError)
356
- return Object.create(Object.getPrototypeOf(cause), descriptors);
357
- // `new Error()` is the only way to obtain the [[ErrorData]] slot; the
358
- // prototype is then pointed at the driver error's, so `instanceof` and
359
- // `.name` behave exactly as before.
360
- const clone = new Error();
361
- Object.setPrototypeOf(clone, Object.getPrototypeOf(cause));
362
- // The clone's own fresh `stack` accessor would otherwise describe THIS
363
- // function's frames, and the original's accessor cannot be transplanted
364
- // (it reads the receiver's slot). Copy the rendered string instead, and
365
- // only when it is one: a driver that stashed a non-string there keeps its
366
- // own descriptor rather than having a lie written over it.
367
- const originalStack = cause.stack;
368
- if (typeof originalStack === 'string') {
369
- // V8 renders the stack as `<name>: <message>\n at …`, so a withheld
370
- // message is still sitting in it. Substitute the exact strings that were
371
- // withheld (split/join, so a message repeated in a nested frame goes
372
- // too); everything else, the frames included, is untouched.
373
- let stackText = originalStack;
374
- for (const text of withheldTexts)
375
- stackText = stackText.split(text).join(REDACTED_DETAIL);
376
- descriptors.stack = { value: stackText, writable: true, enumerable: false, configurable: true };
377
- }
378
- else if (descriptors.stack && typeof descriptors.stack.get === 'function') {
379
- // An own accessor bound to the ORIGINAL receiver would return undefined
380
- // here; drop it and let the clone keep its own working one.
381
- delete descriptors.stack;
382
- }
383
- Object.defineProperties(clone, descriptors);
384
- return clone;
476
+ return cloneErrorWithOverrides(cause, overrides, withheldTexts);
385
477
  }
386
478
  catch {
387
- // A cause whose descriptors cannot be replayed (an exotic proxy, a frozen
388
- // prototype chain) must not turn a database error into a TypeError thrown
389
- // from an error constructor. Dropping the cause entirely is the safe
390
- // direction here: 'safe' mode's contract is that no row value escapes.
479
+ // A cause whose descriptors cannot be replayed, or whose properties cannot
480
+ // even be read (an exotic proxy, a throwing getter, a frozen prototype
481
+ // chain), must not turn a database error into a TypeError thrown from an
482
+ // error constructor. Dropping the cause entirely is the safe direction
483
+ // here: 'safe' mode's contract is that no row value escapes.
391
484
  return undefined;
392
485
  }
393
486
  }
487
+ /**
488
+ * A copy of `original` with some own properties replaced, that is still a REAL
489
+ * error. Shared by {@link redactCauseForMode} and the unclassified-SQLSTATE
490
+ * scrub in {@link wrapPgError}, so the two cannot drift in how they clone.
491
+ *
492
+ * This is the part that is easy to get wrong. `Object.create(proto,
493
+ * descriptors)` looks equivalent and is not: V8 installs `stack` as an own
494
+ * ACCESSOR whose backing store is the internal [[ErrorData]] slot, and that
495
+ * slot is not a property, so it is not copied. The result reads `stack ===
496
+ * undefined`, `util.types.isNativeError() === false` and
497
+ * `Object.prototype.toString.call() === '[object Object]'`, i.e. every log
498
+ * serializer that does `err.stack.split('\n')` throws a TypeError and
499
+ * Sentry/pino drop the frames. So the clone starts life as `new Error()`
500
+ * (which HAS the slot), is re-prototyped to the original's own prototype, and
501
+ * takes the original's stack as a plain string. That keeps `instanceof
502
+ * pg.DatabaseError`, `.code`, the native brand, and the frames.
503
+ *
504
+ * `withheldTexts` are the exact message strings that were replaced: V8 renders
505
+ * the stack as `<name>: <message>\n at …`, so a withheld message is still
506
+ * sitting in it and is substituted out (split/join, so a message repeated in a
507
+ * nested frame goes too). Everything else, the frames included, is untouched.
508
+ * `omit` names own keys the copy must NOT carry over.
509
+ */
510
+ function cloneErrorWithOverrides(original, overrides, withheldTexts, omit = []) {
511
+ const descriptors = Object.getOwnPropertyDescriptors(original);
512
+ for (const key of omit)
513
+ delete descriptors[key];
514
+ Object.assign(descriptors, overrides);
515
+ // Brand check rather than `instanceof Error`, so a driver error thrown from
516
+ // another realm (a worker, a bundled duplicate of pg) is still recognized.
517
+ const isError = Object.prototype.toString.call(original) === '[object Error]';
518
+ if (!isError)
519
+ return Object.create(Object.getPrototypeOf(original), descriptors);
520
+ const clone = new Error();
521
+ Object.setPrototypeOf(clone, Object.getPrototypeOf(original));
522
+ // The clone's own fresh `stack` accessor would otherwise describe THIS
523
+ // function's frames, and the original's accessor cannot be transplanted (it
524
+ // reads the receiver's slot). Copy the rendered string instead, and only when
525
+ // it is one: a driver that stashed a non-string there keeps its own
526
+ // descriptor rather than having a lie written over it.
527
+ const originalStack = original.stack;
528
+ if (typeof originalStack === 'string') {
529
+ let stackText = originalStack;
530
+ for (const text of withheldTexts)
531
+ stackText = stackText.split(text).join(REDACTED_DETAIL);
532
+ descriptors.stack = { value: stackText, writable: true, enumerable: false, configurable: true };
533
+ }
534
+ else if (descriptors.stack && typeof descriptors.stack.get === 'function') {
535
+ // An own accessor bound to the ORIGINAL receiver would return undefined
536
+ // here; drop it and let the clone keep its own working one.
537
+ delete descriptors.stack;
538
+ }
539
+ Object.defineProperties(clone, descriptors);
540
+ return clone;
541
+ }
394
542
  /**
395
543
  * Render a user-supplied `where` / `connect` target for a "no row found" error
396
544
  * message, honoring the global {@link ErrorMessageMode}. In 'safe' mode (the
@@ -462,6 +610,7 @@ function renderWhereForMessage(where, mode) {
462
610
  * structured properties on the error instance regardless of mode.
463
611
  */
464
612
  export class NotFoundError extends TurbineError {
613
+ static CODE = TurbineErrorCode.NOT_FOUND;
465
614
  table;
466
615
  where;
467
616
  operation;
@@ -496,6 +645,7 @@ export class NotFoundError extends TurbineError {
496
645
  }
497
646
  /** Thrown when a query or transaction exceeds the configured timeout */
498
647
  export class TimeoutError extends TurbineError {
648
+ static CODE = TurbineErrorCode.TIMEOUT;
499
649
  timeoutMs;
500
650
  /**
501
651
  * @param timeoutMs the client-side timeout budget in ms. Pass `0` when the
@@ -513,6 +663,23 @@ export class TimeoutError extends TurbineError {
513
663
  }
514
664
  /** Thrown when query arguments fail validation (unknown column, invalid operator, etc.) */
515
665
  export class ValidationError extends TurbineError {
666
+ static CODE = TurbineErrorCode.VALIDATION;
667
+ /**
668
+ * For an E003 that wraps a PostgreSQL data exception (SQLSTATE class 22) or a
669
+ * `search` operand `to_tsquery` rejected (42601): the column the server named,
670
+ * when it named one, and the SQLSTATE that classified the error. Undefined
671
+ * for validation Turbine performed itself, which never reaches the server.
672
+ */
673
+ column;
674
+ sqlstate;
675
+ /**
676
+ * The driver's own text for a wrapped data exception. PostgreSQL puts the
677
+ * offending VALUE in that text (`invalid input syntax for type integer:
678
+ * "alice@example.com"`), so under `errorMessages: 'safe'` this is
679
+ * {@link REDACTED_DETAIL} and under `'verbose'` the driver text in full, the
680
+ * same split the constraint classes apply to `.cause.detail`.
681
+ */
682
+ detail;
516
683
  /**
517
684
  * `options.cause` is for the engines, not for the query builder. Turbine's own
518
685
  * E003s are raised from validation it performed itself, so there is nothing
@@ -525,10 +692,14 @@ export class ValidationError extends TurbineError {
525
692
  constructor(message, options) {
526
693
  super(TurbineErrorCode.VALIDATION, message, options);
527
694
  this.name = 'ValidationError';
695
+ this.column = options?.column;
696
+ this.sqlstate = options?.sqlstate;
697
+ this.detail = options?.detail;
528
698
  }
529
699
  }
530
700
  /** Thrown when a database connection fails */
531
701
  export class ConnectionError extends TurbineError {
702
+ static CODE = TurbineErrorCode.CONNECTION;
532
703
  /**
533
704
  * The driver code that produced this error: a Postgres SQLSTATE (`28P01`
534
705
  * wrong password, `3D000` no such database, `08006` connection failure, ...)
@@ -582,6 +753,7 @@ export function malformedConnectionStringMessage(engine, example) {
582
753
  }
583
754
  /** Thrown when a relation reference is invalid */
584
755
  export class RelationError extends TurbineError {
756
+ static CODE = TurbineErrorCode.RELATION;
585
757
  constructor(message) {
586
758
  super(TurbineErrorCode.RELATION, message);
587
759
  this.name = 'RelationError';
@@ -589,6 +761,7 @@ export class RelationError extends TurbineError {
589
761
  }
590
762
  /** Thrown when a migration operation fails */
591
763
  export class MigrationError extends TurbineError {
764
+ static CODE = TurbineErrorCode.MIGRATION;
592
765
  constructor(message) {
593
766
  super(TurbineErrorCode.MIGRATION, message);
594
767
  this.name = 'MigrationError';
@@ -596,6 +769,7 @@ export class MigrationError extends TurbineError {
596
769
  }
597
770
  /** Thrown when circular relation nesting is detected */
598
771
  export class CircularRelationError extends TurbineError {
772
+ static CODE = TurbineErrorCode.CIRCULAR_RELATION;
599
773
  path;
600
774
  constructor(path) {
601
775
  super(TurbineErrorCode.CIRCULAR_RELATION, `Circular or too-deep relation nesting detected: ${path.join(' → ')}. Maximum nesting depth is 10.`);
@@ -618,6 +792,7 @@ function detailFromCause(cause) {
618
792
  }
619
793
  /** Thrown when a UNIQUE constraint is violated (pg code 23505) */
620
794
  export class UniqueConstraintError extends TurbineError {
795
+ static CODE = TurbineErrorCode.UNIQUE_VIOLATION;
621
796
  constraint;
622
797
  columns;
623
798
  table;
@@ -650,6 +825,7 @@ export class UniqueConstraintError extends TurbineError {
650
825
  }
651
826
  /** Thrown when a FOREIGN KEY constraint is violated (pg code 23503) */
652
827
  export class ForeignKeyError extends TurbineError {
828
+ static CODE = TurbineErrorCode.FOREIGN_KEY_VIOLATION;
653
829
  constraint;
654
830
  table;
655
831
  constructor(opts = {}) {
@@ -679,6 +855,7 @@ export class ForeignKeyError extends TurbineError {
679
855
  }
680
856
  /** Thrown when a NOT NULL constraint is violated (pg code 23502) */
681
857
  export class NotNullViolationError extends TurbineError {
858
+ static CODE = TurbineErrorCode.NOT_NULL_VIOLATION;
682
859
  column;
683
860
  table;
684
861
  constructor(opts = {}) {
@@ -723,6 +900,7 @@ export class NotNullViolationError extends TurbineError {
723
900
  * ```
724
901
  */
725
902
  export class DeadlockError extends TurbineError {
903
+ static CODE = TurbineErrorCode.DEADLOCK_DETECTED;
726
904
  /** Marks this error as safe to retry */
727
905
  isRetryable = true;
728
906
  constraint;
@@ -756,6 +934,7 @@ export class DeadlockError extends TurbineError {
756
934
  * ```
757
935
  */
758
936
  export class SerializationFailureError extends TurbineError {
937
+ static CODE = TurbineErrorCode.SERIALIZATION_FAILURE;
759
938
  /** Marks this error as safe to retry */
760
939
  isRetryable = true;
761
940
  constructor(opts = {}) {
@@ -771,6 +950,7 @@ export class SerializationFailureError extends TurbineError {
771
950
  }
772
951
  /** Thrown when a CHECK constraint is violated (pg code 23514) */
773
952
  export class CheckConstraintError extends TurbineError {
953
+ static CODE = TurbineErrorCode.CHECK_VIOLATION;
774
954
  constraint;
775
955
  table;
776
956
  constructor(opts = {}) {
@@ -799,6 +979,7 @@ export class CheckConstraintError extends TurbineError {
799
979
  }
800
980
  }
801
981
  export class ExclusionConstraintError extends TurbineError {
982
+ static CODE = TurbineErrorCode.EXCLUSION_VIOLATION;
802
983
  constraint;
803
984
  table;
804
985
  constructor(opts = {}) {
@@ -847,6 +1028,7 @@ export class ExclusionConstraintError extends TurbineError {
847
1028
  * ```
848
1029
  */
849
1030
  export class PipelineError extends TurbineError {
1031
+ static CODE = TurbineErrorCode.PIPELINE;
850
1032
  /** Per-query results: each slot is either `{status:'ok', value}` or `{status:'error', error}` */
851
1033
  results;
852
1034
  /** Zero-based index of the first query that failed */
@@ -867,16 +1049,30 @@ export class PipelineError extends TurbineError {
867
1049
  }
868
1050
  }
869
1051
  export class OptimisticLockError extends TurbineError {
1052
+ static CODE = TurbineErrorCode.OPTIMISTIC_LOCK;
870
1053
  table;
871
1054
  versionField;
872
1055
  expectedVersion;
1056
+ /**
1057
+ * The comparison that failed, value included: `expected version = 3`.
1058
+ * Populated in both modes, like `expectedVersion` itself; only the MESSAGE
1059
+ * withholds the value under `errorMessages: 'safe'`. `optimisticLock` accepts
1060
+ * any column as the version field (an `updated_at` timestamp, an etag
1061
+ * string), so the value is a stored cell like any other, and the message
1062
+ * treats it the way the constraint classes treat `detail`.
1063
+ */
1064
+ detail;
873
1065
  constructor(opts) {
874
- super(TurbineErrorCode.OPTIMISTIC_LOCK, `Optimistic lock failed on "${opts.table}", ` +
875
- `expected ${opts.versionField} = ${opts.expectedVersion} but row was modified by another transaction`);
1066
+ const detail = `expected ${opts.versionField} = ${String(opts.expectedVersion)}`;
1067
+ const account = currentErrorMessageMode() === 'verbose'
1068
+ ? `${detail} but row was modified by another transaction`
1069
+ : `the ${opts.versionField} value did not match, the row was modified by another transaction`;
1070
+ super(TurbineErrorCode.OPTIMISTIC_LOCK, `Optimistic lock failed on "${opts.table}", ${account}`);
876
1071
  this.name = 'OptimisticLockError';
877
1072
  this.table = opts.table;
878
1073
  this.versionField = opts.versionField;
879
1074
  this.expectedVersion = opts.expectedVersion;
1075
+ this.detail = detail;
880
1076
  }
881
1077
  }
882
1078
  /**
@@ -886,6 +1082,7 @@ export class OptimisticLockError extends TurbineError {
886
1082
  * clear `unsupported on <engine>` message instead of generating broken SQL.
887
1083
  */
888
1084
  export class UnsupportedFeatureError extends TurbineError {
1085
+ static CODE = TurbineErrorCode.UNSUPPORTED_FEATURE;
889
1086
  feature;
890
1087
  dialect;
891
1088
  constructor(feature, dialect, hint) {
@@ -910,6 +1107,7 @@ export class UnsupportedFeatureError extends TurbineError {
910
1107
  * identically; route it to a writable primary instead.
911
1108
  */
912
1109
  export class ReadOnlyError extends TurbineError {
1110
+ static CODE = TurbineErrorCode.READ_ONLY;
913
1111
  /**
914
1112
  * Why the write was refused. `'snapshot'`: the database itself is read-only
915
1113
  * (snapshot serving, an embedded `readonly: true` open, or the client-level
@@ -1026,9 +1224,218 @@ const CONNECTION_ERROR_HINTS = {
1026
1224
  CERT_HAS_EXPIRED: "The server's TLS certificate has expired. Renew it, or supply the correct CA via `ssl: { ca }`.",
1027
1225
  ERR_TLS_CERT_ALTNAME_INVALID: "The server's TLS certificate does not cover the host you connected to. Check the host name in the connection string.",
1028
1226
  };
1227
+ /** The SQLSTATE shape: five characters from [0-9A-Z]. */
1228
+ const SQLSTATE_RE = /^[0-9A-Z]{5}$/;
1229
+ /**
1230
+ * Whether `e` is a PostgreSQL SERVER error, as opposed to anything else that
1231
+ * reaches {@link wrapPgError} carrying a `.code`. The distinction matters for
1232
+ * the safe-mode scrub of an unclassified code, which rewrites the MESSAGE: a
1233
+ * user's own `fs` error thrown inside a `$transaction` callback travels the
1234
+ * same path with `code: 'EPERM'`, five uppercase characters like a SQLSTATE,
1235
+ * and rewriting that would turn "permission denied, open /etc/x" into
1236
+ * "Database error EPERM". node-postgres stamps `severity` (`ERROR`, `FATAL`,
1237
+ * `PANIC`) on every ErrorResponse it parses and on nothing else, so its
1238
+ * presence is the positive signal. The engines that reuse this function
1239
+ * (SQLite, MySQL, SQL Server) rewrite `.code` to a mapped SQLSTATE for the
1240
+ * shapes they classify and leave their native codes on the rest, so their
1241
+ * unclassified errors pass through exactly as before.
1242
+ */
1243
+ function isPgServerError(e) {
1244
+ return typeof e.code === 'string' && SQLSTATE_RE.test(e.code) && typeof e.severity === 'string';
1245
+ }
1246
+ /**
1247
+ * Value-free accounts of the common data exceptions, so the safe-mode message
1248
+ * still says WHAT kind of bad input it was. PostgreSQL's own text is
1249
+ * `<fixed phrase>: "<the value>"`; the fixed half is what these paraphrase.
1250
+ * Anything else in class 22 gets the class-wide fallback. Never derived from
1251
+ * the driver text.
1252
+ */
1253
+ const DATA_EXCEPTION_PHRASES = {
1254
+ '22P02': 'the value could not be parsed as the column type',
1255
+ '22003': 'the number is out of range for the column type',
1256
+ '22001': 'the string is too long for the column type',
1257
+ '22007': 'the value is not a valid date or time',
1258
+ '22008': 'a date or time field is out of range',
1259
+ '22012': 'division by zero',
1260
+ };
1261
+ const DATA_EXCEPTION_FALLBACK = "invalid input for the column's type";
1262
+ /**
1263
+ * Wrap a driver error whose MESSAGE embeds the offending value into a
1264
+ * ValidationError (E003): SQLSTATE class 22 (data exception) and the
1265
+ * `to_tsquery` parse failure. `describe` is the value-free account of what went
1266
+ * wrong; in 'verbose' mode the driver text replaces it, because that text IS
1267
+ * the precise account and 'verbose' exists to show it.
1268
+ *
1269
+ * The driver text also goes on `.detail` (redacted in 'safe' mode, full in
1270
+ * 'verbose'), and the driver error is marked value-bearing before it becomes
1271
+ * `.cause`, so the base constructor's cause redaction withholds its message in
1272
+ * 'safe' mode exactly as it does for the MySQL and SQL Server augmenters'
1273
+ * errors. There is no `detail` field to redact for these codes: the value sits
1274
+ * in `message`, which is why the constraint-class treatment (redact `detail`,
1275
+ * keep `message`) was not enough.
1276
+ */
1277
+ function wrapValueBearingPgError(err, e, subject, describe, hint = '') {
1278
+ const driverText = typeof e.message === 'string' && e.message.length > 0 ? e.message : undefined;
1279
+ const verbose = currentErrorMessageMode() === 'verbose';
1280
+ const account = verbose && driverText ? driverText : describe;
1281
+ markValueBearingMessage(err);
1282
+ return new ValidationError(`${subject}: ${account} (SQLSTATE ${e.code}).${hint}`, {
1283
+ cause: err,
1284
+ column: e.column,
1285
+ sqlstate: e.code,
1286
+ detail: verbose ? driverText : REDACTED_DETAIL,
1287
+ });
1288
+ }
1289
+ /**
1290
+ * SQLSTATE CLASSES whose MESSAGE text is structurally value-free, so 'safe'
1291
+ * mode keeps it instead of scrubbing it.
1292
+ *
1293
+ * WHY AN ALLOWLIST AT ALL. Scrubbing every unclassified SQLSTATE deletes the
1294
+ * single most useful sentence a first-run or mistyped query produces, and buys
1295
+ * no privacy doing it. `relation "orders" does not exist` (42P01: you forgot to
1296
+ * migrate) and `column "emial" of relation "users" does not exist` (42703: you
1297
+ * misspelled a field) name SCHEMA OBJECTS. There is no row value in either, so
1298
+ * withholding them protects nothing and costs the reader the entire diagnosis,
1299
+ * in the mode that is on by default. A redaction that removes text which cannot
1300
+ * leak is not a safety measure, it is a cost with nothing on the other side.
1301
+ *
1302
+ * THE RULE. A class is listed when PostgreSQL's message grammar for the codes
1303
+ * in it is written over schema object NAMES, over the STRUCTURE of the
1304
+ * statement, or over SERVER STATE, and never over row data:
1305
+ * - `42` syntax error or access rule violation: undefined table / column /
1306
+ * function / object, duplicate object, insufficient privilege, datatype
1307
+ * mismatch. Names and types, and the whole developer-typo population.
1308
+ * - `3D` invalid catalog name and `3F` invalid schema name: a database or a
1309
+ * schema name. (3D000 is claimed by CONNECTION_ERROR_CODES before it can
1310
+ * reach here; the list states the rule for the class, not which branch
1311
+ * happens to answer first.)
1312
+ * - `08` connection exception, `53` insufficient resources, `57` operator
1313
+ * intervention, `58` system error: transport, resource and server state. No
1314
+ * statement data at all.
1315
+ *
1316
+ * WHAT IS DELIBERATELY NOT LISTED, because the argument for it fails:
1317
+ * - `P0001` raise_exception and class `55` object_not_in_prerequisite_state
1318
+ * carry text written by the FUNCTION AUTHOR, and `RAISE EXCEPTION 'order %
1319
+ * is already shipped', order_id` is the idiomatic way to write a business
1320
+ * rule. What is in that text is not knowable from here, and this mode's job
1321
+ * is to be right about the case it cannot see. So they scrub, and the
1322
+ * replacement message says the text was withheld and how to see it.
1323
+ * - Every class nobody has argued about yet. The safe direction for an
1324
+ * unknown grammar is to withhold; adding a class later is cheap and needs
1325
+ * exactly the argument above.
1326
+ *
1327
+ * ONLY THE MESSAGE. `detail`, `hint`, `where` and `internalQuery` are redacted
1328
+ * on a listed class exactly as on any other, for the reasons in
1329
+ * {@link VALUE_BEARING_FIELDS}.
1330
+ */
1331
+ const VALUE_FREE_MESSAGE_CLASSES = new Set(['08', '3D', '3F', '42', '53', '57', '58']);
1332
+ /**
1333
+ * The one carve-out from {@link VALUE_FREE_MESSAGE_CLASSES}. `42601`
1334
+ * syntax_error is the single class-42 code whose message is defined over the
1335
+ * statement TEXT rather than over names: PostgreSQL quotes the offending token,
1336
+ * and a token can be a string literal, so `syntax error at or near
1337
+ * "'alice@example.com'"` is a reachable message. Turbine binds every value it
1338
+ * emits, so its own SQL text never holds one; SQL a caller assembled by
1339
+ * concatenation does, and that is precisely the code path a syntax error comes
1340
+ * from. Keeping the rule ("scrub where a value can actually ride") true is
1341
+ * worth losing the text on a raw-SQL syntax error, which the replacement
1342
+ * message says how to get back.
1343
+ *
1344
+ * (The `to_tsquery` 42601 never reaches here; {@link wrapPgError} classifies it
1345
+ * as an E003 further up.)
1346
+ */
1347
+ const VALUE_BEARING_CODES_IN_VALUE_FREE_CLASSES = new Set(['42601']);
1348
+ /** Whether 'safe' mode may keep the driver's message text for this SQLSTATE. */
1349
+ function messageTextIsValueFree(code) {
1350
+ return VALUE_FREE_MESSAGE_CLASSES.has(code.slice(0, 2)) && !VALUE_BEARING_CODES_IN_VALUE_FREE_CLASSES.has(code);
1351
+ }
1352
+ /**
1353
+ * The replacement for a withheld server message. It SAYS the text was withheld
1354
+ * and how to see it: `Database error P0001` on its own reads like the whole of
1355
+ * what the server said, so a reader has no reason to look further. Same
1356
+ * reasoning as {@link REDACTED_DETAIL}, which marks the field rather than
1357
+ * deleting it.
1358
+ */
1359
+ function withheldServerMessage(code) {
1360
+ return `Database error ${code} (driver text withheld by errorMessages: 'safe'; set errorMessages: 'verbose' to see it)`;
1361
+ }
1362
+ /**
1363
+ * The 'safe'-mode treatment of a PostgreSQL server error {@link wrapPgError}
1364
+ * has no class for. 'verbose' mode never reaches this function: the raw error
1365
+ * passes through, as it always did.
1366
+ *
1367
+ * WHAT COMES BACK: a clone of the driver error, same prototype, same frames,
1368
+ * same `.code` (the SQLSTATE), plus `.sqlstate` carrying that same SQLSTATE
1369
+ * under the name every typed Turbine error uses for it
1370
+ * (`ValidationError.sqlstate`, `ConnectionError.sqlstate`), so one `catch`
1371
+ * branch reads one field whichever of the two an operation produced. Every
1372
+ * {@link VALUE_BEARING_FIELDS} entry is redacted. The MESSAGE survives when
1373
+ * {@link messageTextIsValueFree} says this SQLSTATE's grammar cannot hold a row
1374
+ * value, and is replaced by {@link withheldServerMessage} (and substituted out
1375
+ * of the rendered stack) when it can.
1376
+ *
1377
+ * The original is on `.cause` under the same rules, and that matters more than
1378
+ * it looks: `.cause` is the documented escape hatch, so a scrub that empties it
1379
+ * too leaves no way to debug at all. Its message is withheld only where the
1380
+ * returned error's is.
1381
+ *
1382
+ * NOT A TurbineError, deliberately. The obvious complaint about this branch is
1383
+ * that `err instanceof TurbineError` is false, so a caller's
1384
+ * `catch (e) { if (e instanceof TurbineError) ... }` misses it. Making it one is
1385
+ * still wrong, because 'verbose' mode returns the raw driver error UNCHANGED
1386
+ * here and always has: the same database failure would then be a TurbineError
1387
+ * under one setting and a `pg.DatabaseError` under the other, i.e. a
1388
+ * LOG-REDACTION setting would decide which `catch` branch runs. An error whose
1389
+ * TYPE depends on a logging option is a worse bug than the one that would fix.
1390
+ * The frozen code set (STABILITY.md) points the same way: there is no
1391
+ * TURBINE_E0NN for "a SQLSTATE we have no opinion about", minting one is out of
1392
+ * scope, and a Turbine class whose `.code` held a raw SQLSTATE instead would
1393
+ * break both the `TurbineErrorCode` type and the brand matching in
1394
+ * {@link TurbineError[Symbol.hasInstance]}. So `.code` stays the raw SQLSTATE it
1395
+ * has always been, `.sqlstate` is added beside it, and there is no `.docsUrl`:
1396
+ * that field is the anchor for a Turbine CODE, and pointing it at a docs section
1397
+ * that does not exist would be worse than its absence.
1398
+ */
1399
+ function scrubUnclassifiedServerError(err, code) {
1400
+ const keepMessage = messageTextIsValueFree(code);
1401
+ try {
1402
+ const overrides = redactValueBearingFields(err);
1403
+ // The exact message string to substitute out of the rendered stack.
1404
+ const withheld = [];
1405
+ if (!keepMessage) {
1406
+ const original = err.message;
1407
+ overrides.message = {
1408
+ value: withheldServerMessage(code),
1409
+ writable: true,
1410
+ enumerable: false,
1411
+ configurable: true,
1412
+ };
1413
+ if (typeof original === 'string' && original.length > 0)
1414
+ withheld.push(original);
1415
+ }
1416
+ overrides.sqlstate = { value: code, writable: true, enumerable: true, configurable: true };
1417
+ // The value-bearing-message flag is set on `err` itself, so the omit list
1418
+ // strips it from the clone: the clone's own message is either the driver's
1419
+ // value-free text or the withheld sentence, and neither needs the flag.
1420
+ overrides.cause = {
1421
+ value: redactCauseForMode(keepMessage ? err : markValueBearingMessage(err)),
1422
+ writable: true,
1423
+ enumerable: false,
1424
+ configurable: true,
1425
+ };
1426
+ return cloneErrorWithOverrides(err, overrides, withheld, [VALUE_BEARING_MESSAGE]);
1427
+ }
1428
+ catch {
1429
+ // Same posture as redactCauseForMode: a driver error whose descriptors
1430
+ // cannot be replayed must not become a TypeError, and must not leak either.
1431
+ // The fallback withholds the text whatever the allowlist said, because
1432
+ // reading it off an object whose own descriptors just failed to replay is
1433
+ // the one thing this branch cannot safely do.
1434
+ return Object.assign(new Error(withheldServerMessage(code)), { code, sqlstate: code });
1435
+ }
1436
+ }
1029
1437
  /**
1030
1438
  * Translate a pg driver error into a typed Turbine error.
1031
- * If the error doesn't match a known constraint code, returns it unchanged.
1032
1439
  *
1033
1440
  * Maps:
1034
1441
  * 23505 (unique_violation) -> UniqueConstraintError
@@ -1042,6 +1449,22 @@ const CONNECTION_ERROR_HINTS = {
1042
1449
  * 28P01 / 28000 (auth refused) -> ConnectionError, with a remediation hint
1043
1450
  * 3D000 (no such database) -> ConnectionError, with a remediation hint
1044
1451
  * connection-class codes -> ConnectionError (see CONNECTION_ERROR_CODES)
1452
+ * 22xxx (data exception) -> ValidationError naming the column (when the
1453
+ * server did) and the SQLSTATE; driver text
1454
+ * on `.detail`, redacted in 'safe' mode
1455
+ * 42601 from to_tsquery -> ValidationError with a `search` hint
1456
+ * any other server SQLSTATE -> 'safe' mode: a clone keeping the SQLSTATE
1457
+ * on `.code` and repeating it on `.sqlstate`,
1458
+ * with the value-bearing fields redacted, the
1459
+ * driver message kept when the SQLSTATE
1460
+ * class's grammar cannot hold a row value and
1461
+ * withheld when it can, and the original on
1462
+ * `.cause` under the same rules (see
1463
+ * scrubUnclassifiedServerError);
1464
+ * 'verbose' mode: returned unchanged
1465
+ *
1466
+ * Anything that is not a PostgreSQL server error (no SQLSTATE-shaped code, or
1467
+ * no `severity`) is returned unchanged whatever the mode: see isPgServerError.
1045
1468
  *
1046
1469
  * The original pg error is preserved as `.cause` on the wrapped error.
1047
1470
  */
@@ -1112,6 +1535,27 @@ export function wrapPgError(err) {
1112
1535
  const head = pgMessage ? `Database connection error: ${pgMessage}` : `Database connection error (${e.code})`;
1113
1536
  return new ConnectionError(hint ? `${head} (${e.code}) ${hint}` : head, { cause: err, sqlstate: e.code });
1114
1537
  }
1538
+ // Class 22, data exception: a bound value did not fit the column type
1539
+ // (`where: { id: req.params.id }` with a non-numeric id is the common
1540
+ // one). Specific enough on its own that no severity check is needed:
1541
+ // nothing but a SQL engine produces a `22xxx` code.
1542
+ if (e.code.startsWith('22') && SQLSTATE_RE.test(e.code)) {
1543
+ const subject = e.column ? `Invalid input for column "${e.column}"` : 'Invalid input';
1544
+ // Own-property lookup, same reason as CONNECTION_ERROR_HINTS above.
1545
+ const phrase = Object.hasOwn(DATA_EXCEPTION_PHRASES, e.code)
1546
+ ? DATA_EXCEPTION_PHRASES[e.code]
1547
+ : DATA_EXCEPTION_FALLBACK;
1548
+ return wrapValueBearingPgError(err, e, subject, phrase);
1549
+ }
1550
+ // 42601 is any syntax error. Only the to_tsquery parse failure is a
1551
+ // caller-input problem Turbine can name (the `search` operator), so the
1552
+ // message text is the discriminator; every other 42601 takes the generic
1553
+ // path below.
1554
+ if (e.code === '42601' && typeof e.message === 'string' && /tsquery/i.test(e.message)) {
1555
+ return wrapValueBearingPgError(err, e, 'Invalid full-text search operand', 'PostgreSQL could not parse the `search` value as a tsquery', ' `search` compiles to to_tsquery(), so `&`, `|`, `!`, `<->` and parentheses in the value are operators rather than text; quote such terms in single quotes, or strip them before searching.');
1556
+ }
1557
+ if (currentErrorMessageMode() === 'safe' && isPgServerError(e))
1558
+ return scrubUnclassifiedServerError(err, e.code);
1115
1559
  return err;
1116
1560
  }
1117
1561
  }