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.
- package/README.md +13 -9
- package/dist/cjs/cli/config.d.ts +7 -1
- package/dist/cjs/cli/config.js +11 -2
- package/dist/cjs/cli/destructive.d.ts +1 -1
- package/dist/cjs/cli/destructive.js +307 -9
- package/dist/cjs/cli/index.js +252 -42
- package/dist/cjs/cli/mcp.d.ts +23 -0
- package/dist/cjs/cli/mcp.js +190 -152
- package/dist/cjs/cli/migrate.d.ts +243 -3
- package/dist/cjs/cli/migrate.js +432 -43
- package/dist/cjs/cli/sql-statements.js +27 -0
- package/dist/cjs/cli/studio.d.ts +0 -1
- package/dist/cjs/cli/studio.js +9 -7
- package/dist/cjs/client.d.ts +8 -1
- package/dist/cjs/client.js +7 -0
- package/dist/cjs/connection-url.d.ts +82 -0
- package/dist/cjs/connection-url.js +187 -1
- package/dist/cjs/errors.d.ts +112 -12
- package/dist/cjs/errors.js +558 -114
- package/dist/cjs/generate.js +47 -15
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/introspect.d.ts +33 -0
- package/dist/cjs/introspect.js +54 -1
- package/dist/cjs/mssql.js +21 -1
- package/dist/cjs/nested-write.js +85 -14
- package/dist/cjs/pipeline-submittable.d.ts +2 -0
- package/dist/cjs/pipeline-submittable.js +88 -3
- package/dist/cjs/pipeline.js +13 -1
- package/dist/cjs/powdb-introspect.d.ts +5 -1
- package/dist/cjs/powdb-introspect.js +5 -1
- package/dist/cjs/powql.d.ts +14 -0
- package/dist/cjs/powql.js +44 -4
- package/dist/cjs/prisma-compat.js +95 -8
- package/dist/cjs/query/aggregates.js +56 -6
- package/dist/cjs/query/builder.d.ts +76 -13
- package/dist/cjs/query/builder.js +188 -58
- package/dist/cjs/query/compound-unique.d.ts +76 -0
- package/dist/cjs/query/compound-unique.js +129 -0
- package/dist/cjs/query/index.d.ts +1 -1
- package/dist/cjs/query/types.d.ts +65 -11
- package/dist/cjs/query/where.d.ts +85 -19
- package/dist/cjs/query/where.js +262 -47
- package/dist/cjs/query/writes.d.ts +11 -2
- package/dist/cjs/query/writes.js +116 -21
- package/dist/cjs/seed.d.ts +16 -0
- package/dist/cjs/seed.js +16 -0
- package/dist/cli/config.d.ts +7 -1
- package/dist/cli/config.js +11 -2
- package/dist/cli/destructive.d.ts +1 -1
- package/dist/cli/destructive.js +307 -9
- package/dist/cli/index.js +254 -44
- package/dist/cli/mcp.d.ts +23 -0
- package/dist/cli/mcp.js +187 -150
- package/dist/cli/migrate.d.ts +243 -3
- package/dist/cli/migrate.js +423 -45
- package/dist/cli/sql-statements.js +27 -0
- package/dist/cli/studio.d.ts +0 -1
- package/dist/cli/studio.js +10 -7
- package/dist/client.d.ts +8 -1
- package/dist/client.js +7 -0
- package/dist/connection-url.d.ts +82 -0
- package/dist/connection-url.js +183 -0
- package/dist/errors.d.ts +112 -12
- package/dist/errors.js +558 -114
- package/dist/generate.js +47 -15
- package/dist/index.d.ts +1 -1
- package/dist/introspect.d.ts +33 -0
- package/dist/introspect.js +53 -1
- package/dist/mssql.js +21 -1
- package/dist/nested-write.js +85 -14
- package/dist/pipeline-submittable.d.ts +2 -0
- package/dist/pipeline-submittable.js +87 -3
- package/dist/pipeline.js +14 -2
- package/dist/powdb-introspect.d.ts +5 -1
- package/dist/powdb-introspect.js +5 -1
- package/dist/powql.d.ts +14 -0
- package/dist/powql.js +45 -5
- package/dist/prisma-compat.js +96 -9
- package/dist/query/aggregates.js +56 -6
- package/dist/query/builder.d.ts +76 -13
- package/dist/query/builder.js +188 -58
- package/dist/query/compound-unique.d.ts +76 -0
- package/dist/query/compound-unique.js +126 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +65 -11
- package/dist/query/where.d.ts +85 -19
- package/dist/query/where.js +260 -47
- package/dist/query/writes.d.ts +11 -2
- package/dist/query/writes.js +117 -22
- package/dist/seed.d.ts +16 -0
- package/dist/seed.js +16 -0
- package/package.json +3 -3
- 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
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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}:
|
|
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
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
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
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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]:
|
|
267
|
-
* that `cause` is ALREADY non-enumerable (the Error constructor defines
|
|
268
|
-
* that way) and Node prints it anyway, so hiding the property is not a
|
|
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
|
|
273
|
-
* swapped for {@link REDACTED_DETAIL}.
|
|
274
|
-
* driver's own object untouched (a
|
|
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
|
|
278
|
-
*
|
|
279
|
-
*
|
|
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
|
|
296
|
-
* {@link markValueBearingMessage}). When the engine set that
|
|
297
|
-
* also withholds `message`, `sqlMessage` (mysql2's copy, which is
|
|
298
|
-
* mysql2 formats `message` FROM, so leaving it would put the value
|
|
299
|
-
* back) and the message text embedded in the rendered `stack` string.
|
|
300
|
-
* substitution is an exact replacement of the known message string,
|
|
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
|
|
318
|
-
//
|
|
319
|
-
// non-
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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:
|
|
471
|
+
enumerable: Object.getOwnPropertyDescriptor(cause, key)?.enumerable ?? false,
|
|
348
472
|
configurable: true,
|
|
349
473
|
};
|
|
350
474
|
}
|
|
351
475
|
}
|
|
352
|
-
|
|
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
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
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
|
-
|
|
875
|
-
|
|
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
|
}
|