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
@@ -71,8 +71,38 @@ function formatErrorMessage(code, message) {
71
71
  const body = message.startsWith(tag) ? message : message ? `${tag} ${message}` : tag;
72
72
  return `${body}${link}`;
73
73
  }
74
+ /**
75
+ * The cross-copy identity brand every Turbine error carries as an own,
76
+ * non-enumerable symbol property.
77
+ *
78
+ * The package ships an ESM build and a CJS build, and one process can hold
79
+ * both: a generated client compiled as CommonJS `require`s `dist/cjs` while an
80
+ * `.mts` entry file `import`s `dist`. The error is then constructed by one
81
+ * copy's class and tested against the other copy's, and prototype-chain
82
+ * `instanceof` says false, so every documented
83
+ * `if (err instanceof UniqueConstraintError)` branch is skipped and the error
84
+ * propagates as unhandled, with nothing logged. `Symbol.for` gives both copies
85
+ * the same key, so {@link TurbineError[Symbol.hasInstance]} can recognise the
86
+ * other copy's instances by brand plus code instead of by class identity. Same
87
+ * pattern as {@link VALUE_BEARING_MESSAGE} below.
88
+ */
89
+ const TURBINE_ERROR_BRAND = Symbol.for('turbine-orm.error');
74
90
  /** Base error class for all Turbine errors */
75
91
  class TurbineError extends Error {
92
+ /**
93
+ * The code every instance of this class passes to `super()`, declared once
94
+ * per subclass on the line above the constructor that names it, so the two
95
+ * are read together. It exists for {@link TurbineError[Symbol.hasInstance]}:
96
+ * a subclass recognises a branded error from another copy of this module
97
+ * when the codes agree. `undefined` here on the base class, which therefore
98
+ * recognises any branded error.
99
+ *
100
+ * A static per class rather than a class-to-code map because the code is
101
+ * already written once per class; this puts the second mention next to the
102
+ * first instead of in a table at the bottom of the file, and a unit test
103
+ * asserts the two agree for every exported class.
104
+ */
105
+ static CODE = undefined;
76
106
  code;
77
107
  /** Docs page for this code, e.g. `https://turbineorm.dev/errors#e003`. */
78
108
  docsUrl;
@@ -87,6 +117,45 @@ class TurbineError extends Error {
87
117
  this.name = 'TurbineError';
88
118
  this.code = code;
89
119
  this.docsUrl = docsUrlForCode(code);
120
+ // Non-enumerable, like the message flag, so the brand never appears in a
121
+ // serialized error; non-writable so nothing downstream can unbrand it.
122
+ Object.defineProperty(this, TURBINE_ERROR_BRAND, {
123
+ value: true,
124
+ enumerable: false,
125
+ writable: false,
126
+ configurable: false,
127
+ });
128
+ }
129
+ /**
130
+ * `instanceof` that survives two copies of this module in one process.
131
+ *
132
+ * The ordinary prototype-chain answer is taken first, so a single-copy
133
+ * process behaves exactly as before, user subclasses included. Only when that
134
+ * says no is the brand consulted: a branded object is an instance of the base
135
+ * class outright, and of a coded subclass when its `code` equals that class's
136
+ * {@link CODE}. The `hasOwn` guard restricts the code match to classes that
137
+ * declare their own CODE, i.e. the ones in this module: a user subclass such
138
+ * as `class MyError extends ValidationError {}` inherits E003 without
139
+ * declaring it, and every ValidationError must not become an instance of the
140
+ * user's narrower class.
141
+ */
142
+ static [Symbol.hasInstance](value) {
143
+ // `this` is the class `instanceof` was invoked on, which for an inherited
144
+ // static is the SUBCLASS (NotFoundError, not TurbineError); naming the base
145
+ // class here, as the lint rule wants, would make every subclass answer for
146
+ // the base. Named once so the reason is stated once.
147
+ // biome-ignore lint/complexity/noThisInStatic: the receiver is the subclass being tested against, see above
148
+ // biome-ignore lint/complexity/noUselessThisAlias: one alias carries one suppression instead of three
149
+ const cls = this;
150
+ if (Function.prototype[Symbol.hasInstance].call(cls, value))
151
+ return true;
152
+ if (value === null || typeof value !== 'object')
153
+ return false;
154
+ if (value[TURBINE_ERROR_BRAND] !== true)
155
+ return false;
156
+ if (!Object.hasOwn(cls, 'CODE'))
157
+ return false;
158
+ return cls.CODE === undefined || value.code === cls.CODE;
90
159
  }
91
160
  }
92
161
  exports.TurbineError = TurbineError;
@@ -171,17 +240,28 @@ function runWithErrorMessageMode(mode, fn) {
171
240
  * clause (e.g. `where: {"id":1,"email":"alice@x.com"}`).
172
241
  *
173
242
  * SCOPE, stated precisely because the useful version of this contract is the
174
- * one that is true. 'safe' mode redacts row values from the surfaces Turbine
175
- * OWNS: its own error messages, and the `detail` field of a driver error it
176
- * wraps and attaches as `.cause` (see redactCauseForMode).
177
- *
178
- * It is NOT a blanket guarantee that no row value can be reached from a thrown
179
- * error. A driver error whose SQLSTATE {@link wrapPgError} does not classify is
180
- * returned UNCHANGED, and some of those carry a value in the `message` field
181
- * itself, where nothing can be removed without destroying the diagnosis:
182
- * `22P02 invalid input syntax for type integer: "alice@example.com"` is the
183
- * common one. Treat 'safe' mode as removing Turbine's own contribution to the
184
- * leak, not as a log-scrubbing boundary.
243
+ * one that is true. 'safe' mode redacts row and parameter values from every
244
+ * error Turbine raises or wraps:
245
+ * - its own messages;
246
+ * - the `detail`, `hint`, `where` (the CONTEXT field, which carries the bound
247
+ * PARAMETERS when `log_parameter_max_length_on_error` is non-zero) and
248
+ * `internalQuery` fields of a driver error it attaches as `.cause`, and of
249
+ * the error it hands back. See {@link VALUE_BEARING_FIELDS} for what puts a
250
+ * value in each of them;
251
+ * - the driver MESSAGE where that is what carries the value, so `22P02
252
+ * invalid input syntax for type integer: "alice@example.com"` becomes a
253
+ * ValidationError whose safe-mode text names the column and the SQLSTATE
254
+ * and nothing else (see {@link wrapPgError});
255
+ * - the message of a PostgreSQL server error whose SQLSTATE wrapPgError has
256
+ * no class for AND whose SQLSTATE class can hold a value in its message
257
+ * text (`P0001`, say, whose text the function author wrote), replaced by a
258
+ * sentence naming the SQLSTATE and saying the text was withheld. A class
259
+ * whose grammar is written over schema object names keeps its text, because
260
+ * withholding what cannot leak protects nothing: see
261
+ * {@link messageTextIsValueFree}.
262
+ * It is not a log-scrubbing boundary for errors that never pass through
263
+ * Turbine, and an unclassified NATIVE error code from one of the non-Postgres
264
+ * engines is left to that engine's own classifier.
185
265
  */
186
266
  function setErrorMessageMode(mode) {
187
267
  errorMessageMode = mode;
@@ -218,11 +298,14 @@ exports.REDACTED_DETAIL = '[redacted by turbine errorMessages:"safe"]';
218
298
  * every log line, Sentry event and uncaught-rejection dump that renders the
219
299
  * cause chain, in the mode whose entire job is to prevent exactly that.
220
300
  *
221
- * A flag rather than a code list, and set by the ENGINE that knows its own
222
- * message grammar, for two reasons. It keeps Postgres byte-identical (pg never
223
- * sets it, so its cause is returned exactly as before), and it puts "this
224
- * engine's message embeds values" next to the code that reads that engine's
225
- * messages instead of in a table here that would silently rot.
301
+ * A flag rather than a code list, and set by the code that knows the message
302
+ * grammar in question, for two reasons. It keeps every constraint-class
303
+ * Postgres cause byte-identical (nothing sets it for those, so they are
304
+ * returned exactly as before; `wrapPgError` sets it only for the class-22 and
305
+ * `to_tsquery` shapes whose value sits in `message`, and for a server error it
306
+ * cannot classify at all), and it puts "this engine's message embeds values"
307
+ * next to the code that reads that engine's messages instead of in a table
308
+ * here that would silently rot.
226
309
  *
227
310
  * `Symbol.for` so the ESM and CJS copies of this module agree on the key: a
228
311
  * dual-package consumer can hand an error marked by one build to the other.
@@ -264,84 +347,125 @@ function markValueBearingMessage(err) {
264
347
  */
265
348
  function redactedDriverMessage(code) {
266
349
  const codePart = typeof code === 'string' && code.length > 0 ? ` (driver code ${code})` : '';
267
- return `${exports.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.`;
350
+ return `${exports.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.`;
268
351
  }
269
352
  /**
270
- * Postgres puts the CONFLICTING ROW VALUES in the `detail` field of a
271
- * constraint error, and nowhere else: `Key (email)=(alice@example.com) already
272
- * exists.` for 23505, `Failing row contains (7, alice@example.com, …)` for
273
- * 23502. The `message` field carries only relation/constraint/column NAMES.
353
+ * The PostgreSQL error fields that can carry a ROW or PARAMETER value, and are
354
+ * therefore replaced by {@link REDACTED_DETAIL} in 'safe' mode wherever this
355
+ * module clones a driver error.
356
+ *
357
+ * `detail` was the whole list until it turned out not to be. All four ride the
358
+ * same ErrorResponse wire message, node-postgres copies all four verbatim onto
359
+ * the error object as own ENUMERABLE properties, and `util.inspect` (which is
360
+ * what `console.error`, an uncaught rejection and every structured log
361
+ * serializer effectively run) prints all four:
274
362
  *
275
- * 'safe' mode keeps those values out of the Turbine error's own message, but
276
- * the raw driver error used to be attached verbatim as `.cause`, so the values
277
- * still reached every place an error object gets rendered whole:
363
+ * - `detail` (D) is where a constraint error puts the conflicting values:
364
+ * `Key (email)=(alice@example.com) already exists.` for 23505, `Failing row
365
+ * contains (7, alice@example.com, ...)` for 23502. The `message` of those
366
+ * carries only relation / constraint / column NAMES, which is why redacting
367
+ * `detail` alone looked like the whole job.
368
+ * - `hint` (H): server-generated hints name catalog objects, but
369
+ * `RAISE ... USING HINT = 'the email was ' || v` inside a plpgsql function
370
+ * puts a row value there. It needs no non-default server setting and no
371
+ * cooperation from the application.
372
+ * - `where` (W), the CONTEXT field: with `log_parameter_max_length_on_error`
373
+ * set to anything but 0 the server appends `unnamed portal parameter $1 =
374
+ * '...'`, i.e. THE BOUND PARAMETERS, to the context of any error raised
375
+ * while executing a bound statement.
376
+ * - `internalQuery` (q): the SQL text of a statement executed INSIDE a
377
+ * function, so an `EXECUTE format('SELECT %L::int', v)` hands the
378
+ * interpolated value straight back as SQL text.
379
+ *
380
+ * Redacted on EVERY SQLSTATE, deliberately, including the classes whose
381
+ * `message` is kept (see {@link messageTextIsValueFree}). None of these three
382
+ * is tied to the SQLSTATE the way a message grammar is: parameter context can
383
+ * attach to an error of any class, `format()` can build any internal query, and
384
+ * `RAISE ... USING ERRCODE` lets author-written hint text ride whatever code the
385
+ * author picked. That allowlist is a claim about one field, `message`, and
386
+ * stretching it over these three would be a claim nothing supports.
387
+ *
388
+ * NOT substituted out of the rendered `stack`, unlike a withheld message. V8
389
+ * renders a stack as `<name>: <message>` plus frames, so none of these fields is
390
+ * ever in it, and a global substitution of arbitrary short server text across
391
+ * the frames would risk mangling them to remove something that is not there.
392
+ */
393
+ const VALUE_BEARING_FIELDS = ['detail', 'hint', 'where', 'internalQuery'];
394
+ /**
395
+ * The property overrides that redact every {@link VALUE_BEARING_FIELDS} entry
396
+ * `err` actually carries; empty when it carries none, which is the signal both
397
+ * callers use to skip cloning altogether.
398
+ *
399
+ * Replaces the DESCRIPTOR rather than assigning after the clone exists: a
400
+ * non-writable field would make the assignment throw in strict mode (every
401
+ * module here is ESM, so it always would), and losing the error is worse than
402
+ * paying for one descriptor literal.
403
+ */
404
+ function redactValueBearingFields(err) {
405
+ const overrides = {};
406
+ for (const key of VALUE_BEARING_FIELDS) {
407
+ const current = err[key];
408
+ if (typeof current !== 'string' || current.length === 0)
409
+ continue;
410
+ overrides[key] = {
411
+ value: exports.REDACTED_DETAIL,
412
+ writable: true,
413
+ // Keep whichever visibility the driver chose so the clone serializes with
414
+ // the same key set as the original.
415
+ enumerable: Object.getOwnPropertyDescriptor(err, key)?.enumerable ?? true,
416
+ configurable: true,
417
+ };
418
+ }
419
+ return overrides;
420
+ }
421
+ /**
422
+ * 'safe' mode keeps a driver error's row values out of the Turbine error's own
423
+ * message, but the raw driver error used to be attached verbatim as `.cause`,
424
+ * so the values still reached every place an error object gets rendered whole:
278
425
  * - `console.error(err)` / an uncaught rejection: Node's error printer walks
279
- * the cause chain and prints `[cause]: detail: 'Key (email)=()'`. Note
280
- * that `cause` is ALREADY non-enumerable (the Error constructor defines it
281
- * that way) and Node prints it anyway, so hiding the property is not a fix;
426
+ * the cause chain and prints `[cause]: ... detail: 'Key (email)=(...)'`.
427
+ * Note that `cause` is ALREADY non-enumerable (the Error constructor defines
428
+ * it that way) and Node prints it anyway, so hiding the property is not a
429
+ * fix;
282
430
  * - Sentry and similar sinks link `cause` chains by default and serialize
283
431
  * each link's own properties.
284
432
  *
285
- * So in 'safe' mode the cause is replaced by a shallow clone with `detail`
286
- * swapped for {@link REDACTED_DETAIL}. Cloning rather than mutating leaves the
287
- * driver's own object untouched (a caller holding it from their own catch sees
288
- * what the driver produced).
433
+ * So in 'safe' mode the cause is replaced by a shallow clone with every
434
+ * {@link VALUE_BEARING_FIELDS} entry swapped for {@link REDACTED_DETAIL}.
435
+ * Cloning rather than mutating leaves the driver's own object untouched (a
436
+ * caller holding it from their own catch sees what the driver produced).
289
437
  *
290
- * The clone must remain a REAL error, which is the part that is easy to get
291
- * wrong. `Object.create(proto, descriptors)` looks equivalent and is not: V8
292
- * installs `stack` as an own ACCESSOR whose backing store is the internal
293
- * [[ErrorData]] slot, and that slot is not a property, so it is not copied. The
294
- * result reads `cause.stack === undefined`, `util.types.isNativeError(cause) ===
295
- * false` and `Object.prototype.toString.call(cause) === '[object Object]'`, i.e.
296
- * every log serializer that does `err.cause.stack.split('\n')` throws a
297
- * TypeError and Sentry/pino drop the cause's frames. So the clone starts life
298
- * as `new Error()` (which HAS the slot), is re-prototyped to the driver error's
299
- * own prototype, and takes the original's stack as a plain string. That keeps
300
- * `cause instanceof pg.DatabaseError`, `cause.code === '23505'`, the native
301
- * brand, and the frames.
438
+ * The clone must remain a REAL error (string stack, native brand,
439
+ * `instanceof pg.DatabaseError`); see {@link cloneErrorWithOverrides} for why
440
+ * `Object.create` is not enough.
302
441
  *
303
442
  * In 'verbose' mode the cause passes through untouched: that mode's documented
304
443
  * job is full-fidelity debugging.
305
444
  *
306
445
  * ENGINES WHOSE MESSAGE CARRIES THE VALUE. MySQL and SQL Server do not have a
307
446
  * `detail` field at all and put the conflicting value in `message`, so the
308
- * early return below used to hand the raw driver error straight back (see
309
- * {@link markValueBearingMessage}). When the engine set that flag the clone
310
- * also withholds `message`, `sqlMessage` (mysql2's copy, which is the field
311
- * mysql2 formats `message` FROM, so leaving it would put the value straight
312
- * back) and the message text embedded in the rendered `stack` string. The stack
313
- * substitution is an exact replacement of the known message string, never a
314
- * grammar guess, so the frames survive intact.
447
+ * "nothing to remove" early return below used to hand the raw driver error
448
+ * straight back (see {@link markValueBearingMessage}). When the engine set that
449
+ * flag the clone also withholds `message`, `sqlMessage` (mysql2's copy, which is
450
+ * the field mysql2 formats `message` FROM, so leaving it would put the value
451
+ * straight back) and the message text embedded in the rendered `stack` string.
452
+ * The stack substitution is an exact replacement of the known message string,
453
+ * never a grammar guess, so the frames survive intact.
315
454
  */
316
455
  function redactCauseForMode(cause) {
317
456
  if (currentErrorMessageMode() === 'verbose')
318
457
  return cause;
319
458
  if (!cause || typeof cause !== 'object')
320
459
  return cause;
321
- const detail = cause.detail;
322
- const hasDetail = typeof detail === 'string' && detail.length > 0;
323
460
  const valueBearingMessage = cause[VALUE_BEARING_MESSAGE] === true;
324
- // Nothing value-bearing to remove: return the original object so the common
325
- // case (a non-pg cause, or a pg error without a detail) allocates nothing and
326
- // keeps object identity with what the driver threw.
327
- if (!hasDetail && !valueBearingMessage)
328
- return cause;
329
461
  try {
330
- const descriptors = Object.getOwnPropertyDescriptors(cause);
331
- // Replace the descriptor rather than assigning after the clone exists: a
332
- // non-writable `detail` would make the assignment throw in strict mode
333
- // (every module here is ESM, so it always would), and losing the cause is
334
- // worse than paying for one descriptor literal.
335
- if (hasDetail) {
336
- descriptors.detail = {
337
- value: exports.REDACTED_DETAIL,
338
- writable: true,
339
- enumerable: descriptors.detail?.enumerable ?? true,
340
- configurable: true,
341
- };
342
- }
343
- // The exact message strings to scrub out of the rendered stack, collected
344
- // BEFORE the descriptors are overwritten.
462
+ const overrides = redactValueBearingFields(cause);
463
+ // Nothing value-bearing to remove: return the original object so the common
464
+ // case (a non-pg cause, or a pg error carrying none of these fields)
465
+ // allocates nothing and keeps object identity with what the driver threw.
466
+ if (!valueBearingMessage && Object.keys(overrides).length === 0)
467
+ return cause;
468
+ // The exact message strings to scrub out of the rendered stack.
345
469
  const withheldTexts = [];
346
470
  if (valueBearingMessage) {
347
471
  const code = cause.code;
@@ -351,59 +475,83 @@ function redactCauseForMode(cause) {
351
475
  if (typeof current !== 'string' || current.length === 0)
352
476
  continue;
353
477
  withheldTexts.push(current);
354
- descriptors[key] = {
478
+ overrides[key] = {
355
479
  value: replacement,
356
480
  writable: true,
357
481
  // `message` is non-enumerable on a native Error and mysql2's
358
482
  // `sqlMessage` is enumerable; keep whichever the driver chose so the
359
483
  // clone serializes with the same key set as the original.
360
- enumerable: descriptors[key]?.enumerable ?? false,
484
+ enumerable: Object.getOwnPropertyDescriptor(cause, key)?.enumerable ?? false,
361
485
  configurable: true,
362
486
  };
363
487
  }
364
488
  }
365
- // Brand check rather than `instanceof Error`, so a driver error thrown from
366
- // another realm (a worker, a bundled duplicate of pg) is still recognized.
367
- const isError = Object.prototype.toString.call(cause) === '[object Error]';
368
- if (!isError)
369
- return Object.create(Object.getPrototypeOf(cause), descriptors);
370
- // `new Error()` is the only way to obtain the [[ErrorData]] slot; the
371
- // prototype is then pointed at the driver error's, so `instanceof` and
372
- // `.name` behave exactly as before.
373
- const clone = new Error();
374
- Object.setPrototypeOf(clone, Object.getPrototypeOf(cause));
375
- // The clone's own fresh `stack` accessor would otherwise describe THIS
376
- // function's frames, and the original's accessor cannot be transplanted
377
- // (it reads the receiver's slot). Copy the rendered string instead, and
378
- // only when it is one: a driver that stashed a non-string there keeps its
379
- // own descriptor rather than having a lie written over it.
380
- const originalStack = cause.stack;
381
- if (typeof originalStack === 'string') {
382
- // V8 renders the stack as `<name>: <message>\n at …`, so a withheld
383
- // message is still sitting in it. Substitute the exact strings that were
384
- // withheld (split/join, so a message repeated in a nested frame goes
385
- // too); everything else, the frames included, is untouched.
386
- let stackText = originalStack;
387
- for (const text of withheldTexts)
388
- stackText = stackText.split(text).join(exports.REDACTED_DETAIL);
389
- descriptors.stack = { value: stackText, writable: true, enumerable: false, configurable: true };
390
- }
391
- else if (descriptors.stack && typeof descriptors.stack.get === 'function') {
392
- // An own accessor bound to the ORIGINAL receiver would return undefined
393
- // here; drop it and let the clone keep its own working one.
394
- delete descriptors.stack;
395
- }
396
- Object.defineProperties(clone, descriptors);
397
- return clone;
489
+ return cloneErrorWithOverrides(cause, overrides, withheldTexts);
398
490
  }
399
491
  catch {
400
- // A cause whose descriptors cannot be replayed (an exotic proxy, a frozen
401
- // prototype chain) must not turn a database error into a TypeError thrown
402
- // from an error constructor. Dropping the cause entirely is the safe
403
- // direction here: 'safe' mode's contract is that no row value escapes.
492
+ // A cause whose descriptors cannot be replayed, or whose properties cannot
493
+ // even be read (an exotic proxy, a throwing getter, a frozen prototype
494
+ // chain), must not turn a database error into a TypeError thrown from an
495
+ // error constructor. Dropping the cause entirely is the safe direction
496
+ // here: 'safe' mode's contract is that no row value escapes.
404
497
  return undefined;
405
498
  }
406
499
  }
500
+ /**
501
+ * A copy of `original` with some own properties replaced, that is still a REAL
502
+ * error. Shared by {@link redactCauseForMode} and the unclassified-SQLSTATE
503
+ * scrub in {@link wrapPgError}, so the two cannot drift in how they clone.
504
+ *
505
+ * This is the part that is easy to get wrong. `Object.create(proto,
506
+ * descriptors)` looks equivalent and is not: V8 installs `stack` as an own
507
+ * ACCESSOR whose backing store is the internal [[ErrorData]] slot, and that
508
+ * slot is not a property, so it is not copied. The result reads `stack ===
509
+ * undefined`, `util.types.isNativeError() === false` and
510
+ * `Object.prototype.toString.call() === '[object Object]'`, i.e. every log
511
+ * serializer that does `err.stack.split('\n')` throws a TypeError and
512
+ * Sentry/pino drop the frames. So the clone starts life as `new Error()`
513
+ * (which HAS the slot), is re-prototyped to the original's own prototype, and
514
+ * takes the original's stack as a plain string. That keeps `instanceof
515
+ * pg.DatabaseError`, `.code`, the native brand, and the frames.
516
+ *
517
+ * `withheldTexts` are the exact message strings that were replaced: V8 renders
518
+ * the stack as `<name>: <message>\n at …`, so a withheld message is still
519
+ * sitting in it and is substituted out (split/join, so a message repeated in a
520
+ * nested frame goes too). Everything else, the frames included, is untouched.
521
+ * `omit` names own keys the copy must NOT carry over.
522
+ */
523
+ function cloneErrorWithOverrides(original, overrides, withheldTexts, omit = []) {
524
+ const descriptors = Object.getOwnPropertyDescriptors(original);
525
+ for (const key of omit)
526
+ delete descriptors[key];
527
+ Object.assign(descriptors, overrides);
528
+ // Brand check rather than `instanceof Error`, so a driver error thrown from
529
+ // another realm (a worker, a bundled duplicate of pg) is still recognized.
530
+ const isError = Object.prototype.toString.call(original) === '[object Error]';
531
+ if (!isError)
532
+ return Object.create(Object.getPrototypeOf(original), descriptors);
533
+ const clone = new Error();
534
+ Object.setPrototypeOf(clone, Object.getPrototypeOf(original));
535
+ // The clone's own fresh `stack` accessor would otherwise describe THIS
536
+ // function's frames, and the original's accessor cannot be transplanted (it
537
+ // reads the receiver's slot). Copy the rendered string instead, and only when
538
+ // it is one: a driver that stashed a non-string there keeps its own
539
+ // descriptor rather than having a lie written over it.
540
+ const originalStack = original.stack;
541
+ if (typeof originalStack === 'string') {
542
+ let stackText = originalStack;
543
+ for (const text of withheldTexts)
544
+ stackText = stackText.split(text).join(exports.REDACTED_DETAIL);
545
+ descriptors.stack = { value: stackText, writable: true, enumerable: false, configurable: true };
546
+ }
547
+ else if (descriptors.stack && typeof descriptors.stack.get === 'function') {
548
+ // An own accessor bound to the ORIGINAL receiver would return undefined
549
+ // here; drop it and let the clone keep its own working one.
550
+ delete descriptors.stack;
551
+ }
552
+ Object.defineProperties(clone, descriptors);
553
+ return clone;
554
+ }
407
555
  /**
408
556
  * Render a user-supplied `where` / `connect` target for a "no row found" error
409
557
  * message, honoring the global {@link ErrorMessageMode}. In 'safe' mode (the
@@ -475,6 +623,7 @@ function renderWhereForMessage(where, mode) {
475
623
  * structured properties on the error instance regardless of mode.
476
624
  */
477
625
  class NotFoundError extends TurbineError {
626
+ static CODE = exports.TurbineErrorCode.NOT_FOUND;
478
627
  table;
479
628
  where;
480
629
  operation;
@@ -510,6 +659,7 @@ class NotFoundError extends TurbineError {
510
659
  exports.NotFoundError = NotFoundError;
511
660
  /** Thrown when a query or transaction exceeds the configured timeout */
512
661
  class TimeoutError extends TurbineError {
662
+ static CODE = exports.TurbineErrorCode.TIMEOUT;
513
663
  timeoutMs;
514
664
  /**
515
665
  * @param timeoutMs the client-side timeout budget in ms. Pass `0` when the
@@ -528,6 +678,23 @@ class TimeoutError extends TurbineError {
528
678
  exports.TimeoutError = TimeoutError;
529
679
  /** Thrown when query arguments fail validation (unknown column, invalid operator, etc.) */
530
680
  class ValidationError extends TurbineError {
681
+ static CODE = exports.TurbineErrorCode.VALIDATION;
682
+ /**
683
+ * For an E003 that wraps a PostgreSQL data exception (SQLSTATE class 22) or a
684
+ * `search` operand `to_tsquery` rejected (42601): the column the server named,
685
+ * when it named one, and the SQLSTATE that classified the error. Undefined
686
+ * for validation Turbine performed itself, which never reaches the server.
687
+ */
688
+ column;
689
+ sqlstate;
690
+ /**
691
+ * The driver's own text for a wrapped data exception. PostgreSQL puts the
692
+ * offending VALUE in that text (`invalid input syntax for type integer:
693
+ * "alice@example.com"`), so under `errorMessages: 'safe'` this is
694
+ * {@link REDACTED_DETAIL} and under `'verbose'` the driver text in full, the
695
+ * same split the constraint classes apply to `.cause.detail`.
696
+ */
697
+ detail;
531
698
  /**
532
699
  * `options.cause` is for the engines, not for the query builder. Turbine's own
533
700
  * E003s are raised from validation it performed itself, so there is nothing
@@ -540,11 +707,15 @@ class ValidationError extends TurbineError {
540
707
  constructor(message, options) {
541
708
  super(exports.TurbineErrorCode.VALIDATION, message, options);
542
709
  this.name = 'ValidationError';
710
+ this.column = options?.column;
711
+ this.sqlstate = options?.sqlstate;
712
+ this.detail = options?.detail;
543
713
  }
544
714
  }
545
715
  exports.ValidationError = ValidationError;
546
716
  /** Thrown when a database connection fails */
547
717
  class ConnectionError extends TurbineError {
718
+ static CODE = exports.TurbineErrorCode.CONNECTION;
548
719
  /**
549
720
  * The driver code that produced this error: a Postgres SQLSTATE (`28P01`
550
721
  * wrong password, `3D000` no such database, `08006` connection failure, ...)
@@ -599,6 +770,7 @@ function malformedConnectionStringMessage(engine, example) {
599
770
  }
600
771
  /** Thrown when a relation reference is invalid */
601
772
  class RelationError extends TurbineError {
773
+ static CODE = exports.TurbineErrorCode.RELATION;
602
774
  constructor(message) {
603
775
  super(exports.TurbineErrorCode.RELATION, message);
604
776
  this.name = 'RelationError';
@@ -607,6 +779,7 @@ class RelationError extends TurbineError {
607
779
  exports.RelationError = RelationError;
608
780
  /** Thrown when a migration operation fails */
609
781
  class MigrationError extends TurbineError {
782
+ static CODE = exports.TurbineErrorCode.MIGRATION;
610
783
  constructor(message) {
611
784
  super(exports.TurbineErrorCode.MIGRATION, message);
612
785
  this.name = 'MigrationError';
@@ -615,6 +788,7 @@ class MigrationError extends TurbineError {
615
788
  exports.MigrationError = MigrationError;
616
789
  /** Thrown when circular relation nesting is detected */
617
790
  class CircularRelationError extends TurbineError {
791
+ static CODE = exports.TurbineErrorCode.CIRCULAR_RELATION;
618
792
  path;
619
793
  constructor(path) {
620
794
  super(exports.TurbineErrorCode.CIRCULAR_RELATION, `Circular or too-deep relation nesting detected: ${path.join(' → ')}. Maximum nesting depth is 10.`);
@@ -638,6 +812,7 @@ function detailFromCause(cause) {
638
812
  }
639
813
  /** Thrown when a UNIQUE constraint is violated (pg code 23505) */
640
814
  class UniqueConstraintError extends TurbineError {
815
+ static CODE = exports.TurbineErrorCode.UNIQUE_VIOLATION;
641
816
  constraint;
642
817
  columns;
643
818
  table;
@@ -671,6 +846,7 @@ class UniqueConstraintError extends TurbineError {
671
846
  exports.UniqueConstraintError = UniqueConstraintError;
672
847
  /** Thrown when a FOREIGN KEY constraint is violated (pg code 23503) */
673
848
  class ForeignKeyError extends TurbineError {
849
+ static CODE = exports.TurbineErrorCode.FOREIGN_KEY_VIOLATION;
674
850
  constraint;
675
851
  table;
676
852
  constructor(opts = {}) {
@@ -701,6 +877,7 @@ class ForeignKeyError extends TurbineError {
701
877
  exports.ForeignKeyError = ForeignKeyError;
702
878
  /** Thrown when a NOT NULL constraint is violated (pg code 23502) */
703
879
  class NotNullViolationError extends TurbineError {
880
+ static CODE = exports.TurbineErrorCode.NOT_NULL_VIOLATION;
704
881
  column;
705
882
  table;
706
883
  constructor(opts = {}) {
@@ -746,6 +923,7 @@ exports.NotNullViolationError = NotNullViolationError;
746
923
  * ```
747
924
  */
748
925
  class DeadlockError extends TurbineError {
926
+ static CODE = exports.TurbineErrorCode.DEADLOCK_DETECTED;
749
927
  /** Marks this error as safe to retry */
750
928
  isRetryable = true;
751
929
  constraint;
@@ -780,6 +958,7 @@ exports.DeadlockError = DeadlockError;
780
958
  * ```
781
959
  */
782
960
  class SerializationFailureError extends TurbineError {
961
+ static CODE = exports.TurbineErrorCode.SERIALIZATION_FAILURE;
783
962
  /** Marks this error as safe to retry */
784
963
  isRetryable = true;
785
964
  constructor(opts = {}) {
@@ -796,6 +975,7 @@ class SerializationFailureError extends TurbineError {
796
975
  exports.SerializationFailureError = SerializationFailureError;
797
976
  /** Thrown when a CHECK constraint is violated (pg code 23514) */
798
977
  class CheckConstraintError extends TurbineError {
978
+ static CODE = exports.TurbineErrorCode.CHECK_VIOLATION;
799
979
  constraint;
800
980
  table;
801
981
  constructor(opts = {}) {
@@ -825,6 +1005,7 @@ class CheckConstraintError extends TurbineError {
825
1005
  }
826
1006
  exports.CheckConstraintError = CheckConstraintError;
827
1007
  class ExclusionConstraintError extends TurbineError {
1008
+ static CODE = exports.TurbineErrorCode.EXCLUSION_VIOLATION;
828
1009
  constraint;
829
1010
  table;
830
1011
  constructor(opts = {}) {
@@ -874,6 +1055,7 @@ exports.ExclusionConstraintError = ExclusionConstraintError;
874
1055
  * ```
875
1056
  */
876
1057
  class PipelineError extends TurbineError {
1058
+ static CODE = exports.TurbineErrorCode.PIPELINE;
877
1059
  /** Per-query results: each slot is either `{status:'ok', value}` or `{status:'error', error}` */
878
1060
  results;
879
1061
  /** Zero-based index of the first query that failed */
@@ -895,16 +1077,30 @@ class PipelineError extends TurbineError {
895
1077
  }
896
1078
  exports.PipelineError = PipelineError;
897
1079
  class OptimisticLockError extends TurbineError {
1080
+ static CODE = exports.TurbineErrorCode.OPTIMISTIC_LOCK;
898
1081
  table;
899
1082
  versionField;
900
1083
  expectedVersion;
1084
+ /**
1085
+ * The comparison that failed, value included: `expected version = 3`.
1086
+ * Populated in both modes, like `expectedVersion` itself; only the MESSAGE
1087
+ * withholds the value under `errorMessages: 'safe'`. `optimisticLock` accepts
1088
+ * any column as the version field (an `updated_at` timestamp, an etag
1089
+ * string), so the value is a stored cell like any other, and the message
1090
+ * treats it the way the constraint classes treat `detail`.
1091
+ */
1092
+ detail;
901
1093
  constructor(opts) {
902
- super(exports.TurbineErrorCode.OPTIMISTIC_LOCK, `Optimistic lock failed on "${opts.table}", ` +
903
- `expected ${opts.versionField} = ${opts.expectedVersion} but row was modified by another transaction`);
1094
+ const detail = `expected ${opts.versionField} = ${String(opts.expectedVersion)}`;
1095
+ const account = currentErrorMessageMode() === 'verbose'
1096
+ ? `${detail} but row was modified by another transaction`
1097
+ : `the ${opts.versionField} value did not match, the row was modified by another transaction`;
1098
+ super(exports.TurbineErrorCode.OPTIMISTIC_LOCK, `Optimistic lock failed on "${opts.table}", ${account}`);
904
1099
  this.name = 'OptimisticLockError';
905
1100
  this.table = opts.table;
906
1101
  this.versionField = opts.versionField;
907
1102
  this.expectedVersion = opts.expectedVersion;
1103
+ this.detail = detail;
908
1104
  }
909
1105
  }
910
1106
  exports.OptimisticLockError = OptimisticLockError;
@@ -915,6 +1111,7 @@ exports.OptimisticLockError = OptimisticLockError;
915
1111
  * clear `unsupported on <engine>` message instead of generating broken SQL.
916
1112
  */
917
1113
  class UnsupportedFeatureError extends TurbineError {
1114
+ static CODE = exports.TurbineErrorCode.UNSUPPORTED_FEATURE;
918
1115
  feature;
919
1116
  dialect;
920
1117
  constructor(feature, dialect, hint) {
@@ -940,6 +1137,7 @@ exports.UnsupportedFeatureError = UnsupportedFeatureError;
940
1137
  * identically; route it to a writable primary instead.
941
1138
  */
942
1139
  class ReadOnlyError extends TurbineError {
1140
+ static CODE = exports.TurbineErrorCode.READ_ONLY;
943
1141
  /**
944
1142
  * Why the write was refused. `'snapshot'`: the database itself is read-only
945
1143
  * (snapshot serving, an embedded `readonly: true` open, or the client-level
@@ -1057,9 +1255,218 @@ const CONNECTION_ERROR_HINTS = {
1057
1255
  CERT_HAS_EXPIRED: "The server's TLS certificate has expired. Renew it, or supply the correct CA via `ssl: { ca }`.",
1058
1256
  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.",
1059
1257
  };
1258
+ /** The SQLSTATE shape: five characters from [0-9A-Z]. */
1259
+ const SQLSTATE_RE = /^[0-9A-Z]{5}$/;
1260
+ /**
1261
+ * Whether `e` is a PostgreSQL SERVER error, as opposed to anything else that
1262
+ * reaches {@link wrapPgError} carrying a `.code`. The distinction matters for
1263
+ * the safe-mode scrub of an unclassified code, which rewrites the MESSAGE: a
1264
+ * user's own `fs` error thrown inside a `$transaction` callback travels the
1265
+ * same path with `code: 'EPERM'`, five uppercase characters like a SQLSTATE,
1266
+ * and rewriting that would turn "permission denied, open /etc/x" into
1267
+ * "Database error EPERM". node-postgres stamps `severity` (`ERROR`, `FATAL`,
1268
+ * `PANIC`) on every ErrorResponse it parses and on nothing else, so its
1269
+ * presence is the positive signal. The engines that reuse this function
1270
+ * (SQLite, MySQL, SQL Server) rewrite `.code` to a mapped SQLSTATE for the
1271
+ * shapes they classify and leave their native codes on the rest, so their
1272
+ * unclassified errors pass through exactly as before.
1273
+ */
1274
+ function isPgServerError(e) {
1275
+ return typeof e.code === 'string' && SQLSTATE_RE.test(e.code) && typeof e.severity === 'string';
1276
+ }
1277
+ /**
1278
+ * Value-free accounts of the common data exceptions, so the safe-mode message
1279
+ * still says WHAT kind of bad input it was. PostgreSQL's own text is
1280
+ * `<fixed phrase>: "<the value>"`; the fixed half is what these paraphrase.
1281
+ * Anything else in class 22 gets the class-wide fallback. Never derived from
1282
+ * the driver text.
1283
+ */
1284
+ const DATA_EXCEPTION_PHRASES = {
1285
+ '22P02': 'the value could not be parsed as the column type',
1286
+ '22003': 'the number is out of range for the column type',
1287
+ '22001': 'the string is too long for the column type',
1288
+ '22007': 'the value is not a valid date or time',
1289
+ '22008': 'a date or time field is out of range',
1290
+ '22012': 'division by zero',
1291
+ };
1292
+ const DATA_EXCEPTION_FALLBACK = "invalid input for the column's type";
1293
+ /**
1294
+ * Wrap a driver error whose MESSAGE embeds the offending value into a
1295
+ * ValidationError (E003): SQLSTATE class 22 (data exception) and the
1296
+ * `to_tsquery` parse failure. `describe` is the value-free account of what went
1297
+ * wrong; in 'verbose' mode the driver text replaces it, because that text IS
1298
+ * the precise account and 'verbose' exists to show it.
1299
+ *
1300
+ * The driver text also goes on `.detail` (redacted in 'safe' mode, full in
1301
+ * 'verbose'), and the driver error is marked value-bearing before it becomes
1302
+ * `.cause`, so the base constructor's cause redaction withholds its message in
1303
+ * 'safe' mode exactly as it does for the MySQL and SQL Server augmenters'
1304
+ * errors. There is no `detail` field to redact for these codes: the value sits
1305
+ * in `message`, which is why the constraint-class treatment (redact `detail`,
1306
+ * keep `message`) was not enough.
1307
+ */
1308
+ function wrapValueBearingPgError(err, e, subject, describe, hint = '') {
1309
+ const driverText = typeof e.message === 'string' && e.message.length > 0 ? e.message : undefined;
1310
+ const verbose = currentErrorMessageMode() === 'verbose';
1311
+ const account = verbose && driverText ? driverText : describe;
1312
+ markValueBearingMessage(err);
1313
+ return new ValidationError(`${subject}: ${account} (SQLSTATE ${e.code}).${hint}`, {
1314
+ cause: err,
1315
+ column: e.column,
1316
+ sqlstate: e.code,
1317
+ detail: verbose ? driverText : exports.REDACTED_DETAIL,
1318
+ });
1319
+ }
1320
+ /**
1321
+ * SQLSTATE CLASSES whose MESSAGE text is structurally value-free, so 'safe'
1322
+ * mode keeps it instead of scrubbing it.
1323
+ *
1324
+ * WHY AN ALLOWLIST AT ALL. Scrubbing every unclassified SQLSTATE deletes the
1325
+ * single most useful sentence a first-run or mistyped query produces, and buys
1326
+ * no privacy doing it. `relation "orders" does not exist` (42P01: you forgot to
1327
+ * migrate) and `column "emial" of relation "users" does not exist` (42703: you
1328
+ * misspelled a field) name SCHEMA OBJECTS. There is no row value in either, so
1329
+ * withholding them protects nothing and costs the reader the entire diagnosis,
1330
+ * in the mode that is on by default. A redaction that removes text which cannot
1331
+ * leak is not a safety measure, it is a cost with nothing on the other side.
1332
+ *
1333
+ * THE RULE. A class is listed when PostgreSQL's message grammar for the codes
1334
+ * in it is written over schema object NAMES, over the STRUCTURE of the
1335
+ * statement, or over SERVER STATE, and never over row data:
1336
+ * - `42` syntax error or access rule violation: undefined table / column /
1337
+ * function / object, duplicate object, insufficient privilege, datatype
1338
+ * mismatch. Names and types, and the whole developer-typo population.
1339
+ * - `3D` invalid catalog name and `3F` invalid schema name: a database or a
1340
+ * schema name. (3D000 is claimed by CONNECTION_ERROR_CODES before it can
1341
+ * reach here; the list states the rule for the class, not which branch
1342
+ * happens to answer first.)
1343
+ * - `08` connection exception, `53` insufficient resources, `57` operator
1344
+ * intervention, `58` system error: transport, resource and server state. No
1345
+ * statement data at all.
1346
+ *
1347
+ * WHAT IS DELIBERATELY NOT LISTED, because the argument for it fails:
1348
+ * - `P0001` raise_exception and class `55` object_not_in_prerequisite_state
1349
+ * carry text written by the FUNCTION AUTHOR, and `RAISE EXCEPTION 'order %
1350
+ * is already shipped', order_id` is the idiomatic way to write a business
1351
+ * rule. What is in that text is not knowable from here, and this mode's job
1352
+ * is to be right about the case it cannot see. So they scrub, and the
1353
+ * replacement message says the text was withheld and how to see it.
1354
+ * - Every class nobody has argued about yet. The safe direction for an
1355
+ * unknown grammar is to withhold; adding a class later is cheap and needs
1356
+ * exactly the argument above.
1357
+ *
1358
+ * ONLY THE MESSAGE. `detail`, `hint`, `where` and `internalQuery` are redacted
1359
+ * on a listed class exactly as on any other, for the reasons in
1360
+ * {@link VALUE_BEARING_FIELDS}.
1361
+ */
1362
+ const VALUE_FREE_MESSAGE_CLASSES = new Set(['08', '3D', '3F', '42', '53', '57', '58']);
1363
+ /**
1364
+ * The one carve-out from {@link VALUE_FREE_MESSAGE_CLASSES}. `42601`
1365
+ * syntax_error is the single class-42 code whose message is defined over the
1366
+ * statement TEXT rather than over names: PostgreSQL quotes the offending token,
1367
+ * and a token can be a string literal, so `syntax error at or near
1368
+ * "'alice@example.com'"` is a reachable message. Turbine binds every value it
1369
+ * emits, so its own SQL text never holds one; SQL a caller assembled by
1370
+ * concatenation does, and that is precisely the code path a syntax error comes
1371
+ * from. Keeping the rule ("scrub where a value can actually ride") true is
1372
+ * worth losing the text on a raw-SQL syntax error, which the replacement
1373
+ * message says how to get back.
1374
+ *
1375
+ * (The `to_tsquery` 42601 never reaches here; {@link wrapPgError} classifies it
1376
+ * as an E003 further up.)
1377
+ */
1378
+ const VALUE_BEARING_CODES_IN_VALUE_FREE_CLASSES = new Set(['42601']);
1379
+ /** Whether 'safe' mode may keep the driver's message text for this SQLSTATE. */
1380
+ function messageTextIsValueFree(code) {
1381
+ return VALUE_FREE_MESSAGE_CLASSES.has(code.slice(0, 2)) && !VALUE_BEARING_CODES_IN_VALUE_FREE_CLASSES.has(code);
1382
+ }
1383
+ /**
1384
+ * The replacement for a withheld server message. It SAYS the text was withheld
1385
+ * and how to see it: `Database error P0001` on its own reads like the whole of
1386
+ * what the server said, so a reader has no reason to look further. Same
1387
+ * reasoning as {@link REDACTED_DETAIL}, which marks the field rather than
1388
+ * deleting it.
1389
+ */
1390
+ function withheldServerMessage(code) {
1391
+ return `Database error ${code} (driver text withheld by errorMessages: 'safe'; set errorMessages: 'verbose' to see it)`;
1392
+ }
1393
+ /**
1394
+ * The 'safe'-mode treatment of a PostgreSQL server error {@link wrapPgError}
1395
+ * has no class for. 'verbose' mode never reaches this function: the raw error
1396
+ * passes through, as it always did.
1397
+ *
1398
+ * WHAT COMES BACK: a clone of the driver error, same prototype, same frames,
1399
+ * same `.code` (the SQLSTATE), plus `.sqlstate` carrying that same SQLSTATE
1400
+ * under the name every typed Turbine error uses for it
1401
+ * (`ValidationError.sqlstate`, `ConnectionError.sqlstate`), so one `catch`
1402
+ * branch reads one field whichever of the two an operation produced. Every
1403
+ * {@link VALUE_BEARING_FIELDS} entry is redacted. The MESSAGE survives when
1404
+ * {@link messageTextIsValueFree} says this SQLSTATE's grammar cannot hold a row
1405
+ * value, and is replaced by {@link withheldServerMessage} (and substituted out
1406
+ * of the rendered stack) when it can.
1407
+ *
1408
+ * The original is on `.cause` under the same rules, and that matters more than
1409
+ * it looks: `.cause` is the documented escape hatch, so a scrub that empties it
1410
+ * too leaves no way to debug at all. Its message is withheld only where the
1411
+ * returned error's is.
1412
+ *
1413
+ * NOT A TurbineError, deliberately. The obvious complaint about this branch is
1414
+ * that `err instanceof TurbineError` is false, so a caller's
1415
+ * `catch (e) { if (e instanceof TurbineError) ... }` misses it. Making it one is
1416
+ * still wrong, because 'verbose' mode returns the raw driver error UNCHANGED
1417
+ * here and always has: the same database failure would then be a TurbineError
1418
+ * under one setting and a `pg.DatabaseError` under the other, i.e. a
1419
+ * LOG-REDACTION setting would decide which `catch` branch runs. An error whose
1420
+ * TYPE depends on a logging option is a worse bug than the one that would fix.
1421
+ * The frozen code set (STABILITY.md) points the same way: there is no
1422
+ * TURBINE_E0NN for "a SQLSTATE we have no opinion about", minting one is out of
1423
+ * scope, and a Turbine class whose `.code` held a raw SQLSTATE instead would
1424
+ * break both the `TurbineErrorCode` type and the brand matching in
1425
+ * {@link TurbineError[Symbol.hasInstance]}. So `.code` stays the raw SQLSTATE it
1426
+ * has always been, `.sqlstate` is added beside it, and there is no `.docsUrl`:
1427
+ * that field is the anchor for a Turbine CODE, and pointing it at a docs section
1428
+ * that does not exist would be worse than its absence.
1429
+ */
1430
+ function scrubUnclassifiedServerError(err, code) {
1431
+ const keepMessage = messageTextIsValueFree(code);
1432
+ try {
1433
+ const overrides = redactValueBearingFields(err);
1434
+ // The exact message string to substitute out of the rendered stack.
1435
+ const withheld = [];
1436
+ if (!keepMessage) {
1437
+ const original = err.message;
1438
+ overrides.message = {
1439
+ value: withheldServerMessage(code),
1440
+ writable: true,
1441
+ enumerable: false,
1442
+ configurable: true,
1443
+ };
1444
+ if (typeof original === 'string' && original.length > 0)
1445
+ withheld.push(original);
1446
+ }
1447
+ overrides.sqlstate = { value: code, writable: true, enumerable: true, configurable: true };
1448
+ // The value-bearing-message flag is set on `err` itself, so the omit list
1449
+ // strips it from the clone: the clone's own message is either the driver's
1450
+ // value-free text or the withheld sentence, and neither needs the flag.
1451
+ overrides.cause = {
1452
+ value: redactCauseForMode(keepMessage ? err : markValueBearingMessage(err)),
1453
+ writable: true,
1454
+ enumerable: false,
1455
+ configurable: true,
1456
+ };
1457
+ return cloneErrorWithOverrides(err, overrides, withheld, [VALUE_BEARING_MESSAGE]);
1458
+ }
1459
+ catch {
1460
+ // Same posture as redactCauseForMode: a driver error whose descriptors
1461
+ // cannot be replayed must not become a TypeError, and must not leak either.
1462
+ // The fallback withholds the text whatever the allowlist said, because
1463
+ // reading it off an object whose own descriptors just failed to replay is
1464
+ // the one thing this branch cannot safely do.
1465
+ return Object.assign(new Error(withheldServerMessage(code)), { code, sqlstate: code });
1466
+ }
1467
+ }
1060
1468
  /**
1061
1469
  * Translate a pg driver error into a typed Turbine error.
1062
- * If the error doesn't match a known constraint code, returns it unchanged.
1063
1470
  *
1064
1471
  * Maps:
1065
1472
  * 23505 (unique_violation) -> UniqueConstraintError
@@ -1073,6 +1480,22 @@ const CONNECTION_ERROR_HINTS = {
1073
1480
  * 28P01 / 28000 (auth refused) -> ConnectionError, with a remediation hint
1074
1481
  * 3D000 (no such database) -> ConnectionError, with a remediation hint
1075
1482
  * connection-class codes -> ConnectionError (see CONNECTION_ERROR_CODES)
1483
+ * 22xxx (data exception) -> ValidationError naming the column (when the
1484
+ * server did) and the SQLSTATE; driver text
1485
+ * on `.detail`, redacted in 'safe' mode
1486
+ * 42601 from to_tsquery -> ValidationError with a `search` hint
1487
+ * any other server SQLSTATE -> 'safe' mode: a clone keeping the SQLSTATE
1488
+ * on `.code` and repeating it on `.sqlstate`,
1489
+ * with the value-bearing fields redacted, the
1490
+ * driver message kept when the SQLSTATE
1491
+ * class's grammar cannot hold a row value and
1492
+ * withheld when it can, and the original on
1493
+ * `.cause` under the same rules (see
1494
+ * scrubUnclassifiedServerError);
1495
+ * 'verbose' mode: returned unchanged
1496
+ *
1497
+ * Anything that is not a PostgreSQL server error (no SQLSTATE-shaped code, or
1498
+ * no `severity`) is returned unchanged whatever the mode: see isPgServerError.
1076
1499
  *
1077
1500
  * The original pg error is preserved as `.cause` on the wrapped error.
1078
1501
  */
@@ -1143,6 +1566,27 @@ function wrapPgError(err) {
1143
1566
  const head = pgMessage ? `Database connection error: ${pgMessage}` : `Database connection error (${e.code})`;
1144
1567
  return new ConnectionError(hint ? `${head} (${e.code}) ${hint}` : head, { cause: err, sqlstate: e.code });
1145
1568
  }
1569
+ // Class 22, data exception: a bound value did not fit the column type
1570
+ // (`where: { id: req.params.id }` with a non-numeric id is the common
1571
+ // one). Specific enough on its own that no severity check is needed:
1572
+ // nothing but a SQL engine produces a `22xxx` code.
1573
+ if (e.code.startsWith('22') && SQLSTATE_RE.test(e.code)) {
1574
+ const subject = e.column ? `Invalid input for column "${e.column}"` : 'Invalid input';
1575
+ // Own-property lookup, same reason as CONNECTION_ERROR_HINTS above.
1576
+ const phrase = Object.hasOwn(DATA_EXCEPTION_PHRASES, e.code)
1577
+ ? DATA_EXCEPTION_PHRASES[e.code]
1578
+ : DATA_EXCEPTION_FALLBACK;
1579
+ return wrapValueBearingPgError(err, e, subject, phrase);
1580
+ }
1581
+ // 42601 is any syntax error. Only the to_tsquery parse failure is a
1582
+ // caller-input problem Turbine can name (the `search` operator), so the
1583
+ // message text is the discriminator; every other 42601 takes the generic
1584
+ // path below.
1585
+ if (e.code === '42601' && typeof e.message === 'string' && /tsquery/i.test(e.message)) {
1586
+ 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.');
1587
+ }
1588
+ if (currentErrorMessageMode() === 'safe' && isPgServerError(e))
1589
+ return scrubUnclassifiedServerError(err, e.code);
1146
1590
  return err;
1147
1591
  }
1148
1592
  }