turbine-orm 0.7.0 → 0.7.1
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 +99 -1
- package/dist/cjs/cli/index.js +72 -3
- package/dist/cjs/cli/loader.js +129 -0
- package/dist/cjs/cli/migrate.js +33 -9
- package/dist/cjs/client.js +49 -3
- package/dist/cjs/errors.js +135 -4
- package/dist/cjs/generate.js +120 -9
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/query.js +102 -6
- package/dist/cjs/schema-builder.js +57 -6
- package/dist/cjs/schema-sql.js +85 -19
- package/dist/cjs/serverless.js +8 -7
- package/dist/cli/index.js +72 -3
- package/dist/cli/loader.d.ts +45 -0
- package/dist/cli/loader.js +91 -0
- package/dist/cli/migrate.d.ts +7 -1
- package/dist/cli/migrate.js +33 -9
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +15 -0
- package/dist/client.js +50 -4
- package/dist/errors.d.ts +88 -1
- package/dist/errors.js +130 -3
- package/dist/generate.d.ts +6 -0
- package/dist/generate.js +120 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/query.d.ts +126 -11
- package/dist/query.js +102 -6
- package/dist/schema-builder.d.ts +36 -3
- package/dist/schema-builder.js +57 -6
- package/dist/schema-sql.js +85 -19
- package/dist/serverless.d.ts +8 -7
- package/dist/serverless.js +8 -7
- package/package.json +3 -3
package/dist/cli/migrate.js
CHANGED
|
@@ -260,12 +260,19 @@ async function validateChecksums(client, migrationsDir) {
|
|
|
260
260
|
* Features:
|
|
261
261
|
* - Idempotent: running twice is safe (already-applied migrations are skipped)
|
|
262
262
|
* - Advisory lock: prevents concurrent migration runs
|
|
263
|
-
* - Checksum validation: detects modified migration files
|
|
263
|
+
* - Checksum validation: detects modified migration files (BLOCKING — use
|
|
264
|
+
* `allowDrift: true` to bypass when intentionally rewriting history)
|
|
264
265
|
* - Each migration runs in its own transaction
|
|
266
|
+
*
|
|
267
|
+
* Throws `MigrationError` if any applied migration has been modified or deleted
|
|
268
|
+
* on disk, listing the offending files. Pass `{ allowDrift: true }` to bypass
|
|
269
|
+
* this check (the CLI exposes this as `--allow-drift`).
|
|
265
270
|
*/
|
|
266
271
|
export async function migrateUp(connectionString, migrationsDir, options) {
|
|
267
272
|
const client = new pg.Client({ connectionString });
|
|
268
273
|
await client.connect();
|
|
274
|
+
// Treat `force` as an alias for `allowDrift` for backwards compatibility.
|
|
275
|
+
const allowDrift = options?.allowDrift === true || options?.force === true;
|
|
269
276
|
try {
|
|
270
277
|
// Acquire advisory lock to prevent concurrent migrations
|
|
271
278
|
const gotLock = await acquireLock(client);
|
|
@@ -274,18 +281,35 @@ export async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
274
281
|
}
|
|
275
282
|
try {
|
|
276
283
|
await ensureTrackingTable(client);
|
|
277
|
-
// Validate checksums of already-applied migrations
|
|
278
|
-
|
|
284
|
+
// Validate checksums of already-applied migrations.
|
|
285
|
+
// Drift = an APPLIED migration's on-disk file has changed (or been deleted)
|
|
286
|
+
// since it was run. Either situation means the database state and the
|
|
287
|
+
// migration history no longer agree, so we BLOCK the run by default.
|
|
288
|
+
// Users can pass `allowDrift: true` (CLI: `--allow-drift`) to force past
|
|
289
|
+
// the block when they are intentionally rewriting history.
|
|
290
|
+
if (!allowDrift) {
|
|
279
291
|
const mismatches = await validateChecksums(client, migrationsDir);
|
|
280
292
|
if (mismatches.length > 0) {
|
|
281
293
|
const modified = mismatches.filter((m) => m.type === 'modified');
|
|
282
294
|
const missing = mismatches.filter((m) => m.type === 'missing');
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
295
|
+
const lines = [
|
|
296
|
+
'[turbine] Migration drift detected — refusing to apply pending migrations.',
|
|
297
|
+
'',
|
|
298
|
+
'Applied migrations should be immutable. The following files no longer match their applied state:',
|
|
299
|
+
'',
|
|
300
|
+
];
|
|
301
|
+
for (const m of modified) {
|
|
302
|
+
lines.push(` - ${m.name}.sql (modified on disk)`);
|
|
303
|
+
}
|
|
304
|
+
for (const m of missing) {
|
|
305
|
+
lines.push(` - ${m.name}.sql (deleted from disk)`);
|
|
306
|
+
}
|
|
307
|
+
lines.push('');
|
|
308
|
+
lines.push('Fix one of these:');
|
|
309
|
+
lines.push(' 1. Restore the file(s) to their original content, OR');
|
|
310
|
+
lines.push(' 2. Roll back the affected migrations with `npx turbine migrate down`, OR');
|
|
311
|
+
lines.push(' 3. Pass `--allow-drift` to bypass this check (advanced — make sure you know what you are doing).');
|
|
312
|
+
throw new MigrationError(lines.join('\n'));
|
|
289
313
|
}
|
|
290
314
|
}
|
|
291
315
|
const applied = await getAppliedMigrations(client);
|
package/dist/cli/ui.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export declare const symbols: {
|
|
|
34
34
|
readonly warning: "⚠" | "!";
|
|
35
35
|
readonly dot: "." | "∙";
|
|
36
36
|
readonly line: "─" | "-";
|
|
37
|
-
readonly vertLine: "
|
|
37
|
+
readonly vertLine: "|" | "│";
|
|
38
38
|
readonly topLeft: "╭" | "+";
|
|
39
39
|
readonly topRight: "+" | "╮";
|
|
40
40
|
readonly bottomLeft: "+" | "╰";
|
package/dist/client.d.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
import pg from 'pg';
|
|
25
|
+
import { type ErrorMessageMode } from './errors.js';
|
|
25
26
|
import { type PipelineResults } from './pipeline.js';
|
|
26
27
|
import { type DeferredQuery, QueryInterface, type QueryInterfaceOptions } from './query.js';
|
|
27
28
|
import type { SchemaMetadata } from './schema.js';
|
|
@@ -110,6 +111,20 @@ export interface TurbineConfig {
|
|
|
110
111
|
defaultLimit?: number;
|
|
111
112
|
/** Log a warning when findMany() is called without a limit (default: false) */
|
|
112
113
|
warnOnUnlimited?: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* Controls how `NotFoundError` (and other where-aware errors) format their
|
|
116
|
+
* messages.
|
|
117
|
+
*
|
|
118
|
+
* - `'safe'` (default): the message includes only the keys of the where
|
|
119
|
+
* clause (e.g. `where: { id, email }`). Values are redacted to avoid
|
|
120
|
+
* leaking PII into error logs (Sentry, Datadog, etc.).
|
|
121
|
+
* - `'verbose'`: the message includes the full JSON-serialized where
|
|
122
|
+
* clause (e.g. `where: {"id":1,"email":"alice@x.com"}`).
|
|
123
|
+
*
|
|
124
|
+
* The full `where` object is always available as `err.where` for
|
|
125
|
+
* programmatic access regardless of mode.
|
|
126
|
+
*/
|
|
127
|
+
errorMessages?: ErrorMessageMode;
|
|
113
128
|
}
|
|
114
129
|
/** Parameters passed to middleware functions */
|
|
115
130
|
export interface MiddlewareParams {
|
package/dist/client.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
import pg from 'pg';
|
|
25
|
-
import { TimeoutError, wrapPgError } from './errors.js';
|
|
25
|
+
import { setErrorMessageMode, TimeoutError, wrapPgError } from './errors.js';
|
|
26
26
|
import { executePipeline } from './pipeline.js';
|
|
27
27
|
import { QueryInterface } from './query.js';
|
|
28
28
|
/** Maps isolation level names to SQL */
|
|
@@ -188,6 +188,11 @@ export class TurbineClient {
|
|
|
188
188
|
defaultLimit: config.defaultLimit,
|
|
189
189
|
warnOnUnlimited: config.warnOnUnlimited,
|
|
190
190
|
};
|
|
191
|
+
// Apply NotFoundError message redaction mode (default: safe — values are
|
|
192
|
+
// stripped from messages to avoid leaking PII into error logs).
|
|
193
|
+
if (config.errorMessages) {
|
|
194
|
+
setErrorMessageMode(config.errorMessages);
|
|
195
|
+
}
|
|
191
196
|
if (config.pool) {
|
|
192
197
|
// External pool — use directly. Turbine doesn't manage its lifecycle.
|
|
193
198
|
this.pool = config.pool;
|
|
@@ -392,6 +397,24 @@ export class TurbineClient {
|
|
|
392
397
|
async $transaction(fn, options) {
|
|
393
398
|
const client = await this.pool.connect();
|
|
394
399
|
const timeout = options?.timeout;
|
|
400
|
+
/**
|
|
401
|
+
* Track whether the connection has already been released so the finally
|
|
402
|
+
* block doesn't double-release. When a timeout fires we destroy the
|
|
403
|
+
* connection eagerly to abort the in-flight backend query.
|
|
404
|
+
*/
|
|
405
|
+
let released = false;
|
|
406
|
+
const releaseOnce = (err) => {
|
|
407
|
+
if (released)
|
|
408
|
+
return;
|
|
409
|
+
released = true;
|
|
410
|
+
try {
|
|
411
|
+
client.release(err);
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
// pg may throw if the client is already released — swallow.
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
let timedOut = false;
|
|
395
418
|
try {
|
|
396
419
|
// BEGIN with optional isolation level
|
|
397
420
|
let beginSQL = 'BEGIN';
|
|
@@ -415,10 +438,22 @@ export class TurbineClient {
|
|
|
415
438
|
}
|
|
416
439
|
let result;
|
|
417
440
|
if (timeout) {
|
|
418
|
-
// Race between the function and a timeout
|
|
441
|
+
// Race between the function and a timeout. If the timeout fires we
|
|
442
|
+
// need to actually abort the in-flight query — otherwise the backend
|
|
443
|
+
// keeps running until pg's own timeout, holding a pool slot the whole
|
|
444
|
+
// time. The simplest reliable cancellation is to destroy the
|
|
445
|
+
// connection: passing a truthy argument to client.release() tells the
|
|
446
|
+
// pg pool to discard the client (its socket is closed, which causes
|
|
447
|
+
// Postgres to abort the active query and roll back the transaction).
|
|
448
|
+
// The pool will spin up a fresh connection on the next checkout.
|
|
419
449
|
let timer;
|
|
420
450
|
const timeoutPromise = new Promise((_, reject) => {
|
|
421
451
|
timer = setTimeout(() => {
|
|
452
|
+
timedOut = true;
|
|
453
|
+
// Destroy the connection to abort the in-flight backend query.
|
|
454
|
+
// We do this BEFORE rejecting so the socket is gone by the time
|
|
455
|
+
// the caller's catch block runs.
|
|
456
|
+
releaseOnce(new Error('[turbine] Transaction timeout — connection destroyed'));
|
|
422
457
|
reject(new TimeoutError(timeout, 'Transaction'));
|
|
423
458
|
}, timeout);
|
|
424
459
|
});
|
|
@@ -439,14 +474,25 @@ export class TurbineClient {
|
|
|
439
474
|
return result;
|
|
440
475
|
}
|
|
441
476
|
catch (err) {
|
|
442
|
-
|
|
477
|
+
// If the timeout fired we already destroyed the connection — issuing a
|
|
478
|
+
// ROLLBACK on a released client would throw "Client has already been
|
|
479
|
+
// released". Skip the rollback in that case (the backend rolled back
|
|
480
|
+
// when its socket was closed).
|
|
481
|
+
if (!timedOut && !released) {
|
|
482
|
+
try {
|
|
483
|
+
await client.query('ROLLBACK');
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// Best-effort rollback — the connection may have died mid-query.
|
|
487
|
+
}
|
|
488
|
+
}
|
|
443
489
|
if (this.logging) {
|
|
444
490
|
console.log('[turbine] Transaction rolled back');
|
|
445
491
|
}
|
|
446
492
|
throw err;
|
|
447
493
|
}
|
|
448
494
|
finally {
|
|
449
|
-
|
|
495
|
+
releaseOnce();
|
|
450
496
|
}
|
|
451
497
|
}
|
|
452
498
|
// -------------------------------------------------------------------------
|
package/dist/errors.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export declare const TurbineErrorCode: {
|
|
|
17
17
|
readonly FOREIGN_KEY_VIOLATION: "TURBINE_E009";
|
|
18
18
|
readonly NOT_NULL_VIOLATION: "TURBINE_E010";
|
|
19
19
|
readonly CHECK_VIOLATION: "TURBINE_E011";
|
|
20
|
+
readonly DEADLOCK_DETECTED: "TURBINE_E012";
|
|
21
|
+
readonly SERIALIZATION_FAILURE: "TURBINE_E013";
|
|
20
22
|
};
|
|
21
23
|
export type TurbineErrorCode = (typeof TurbineErrorCode)[keyof typeof TurbineErrorCode];
|
|
22
24
|
/** Base error class for all Turbine errors */
|
|
@@ -26,6 +28,30 @@ export declare class TurbineError extends Error {
|
|
|
26
28
|
cause?: unknown;
|
|
27
29
|
});
|
|
28
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Controls whether NotFoundError messages include the actual `where` values
|
|
33
|
+
* (`'verbose'`) or only the where-clause keys (`'safe'`, the default).
|
|
34
|
+
*
|
|
35
|
+
* Defaults to `'safe'` to avoid leaking PII into error logs (Sentry, Datadog,
|
|
36
|
+
* etc.). The full `where` object is always available as `err.where` for
|
|
37
|
+
* programmatic access — only the human-readable message is redacted.
|
|
38
|
+
*
|
|
39
|
+
* Set via `setErrorMessageMode('verbose')` or by constructing TurbineClient
|
|
40
|
+
* with `{ errorMessages: 'verbose' }`.
|
|
41
|
+
*/
|
|
42
|
+
export type ErrorMessageMode = 'safe' | 'verbose';
|
|
43
|
+
/**
|
|
44
|
+
* Set the global NotFoundError message mode. Called from the TurbineClient
|
|
45
|
+
* constructor when `TurbineConfig.errorMessages` is provided.
|
|
46
|
+
*
|
|
47
|
+
* - `'safe'` (default): the message includes only the keys of the where
|
|
48
|
+
* clause (e.g. `where: { id, email }`). Values are redacted.
|
|
49
|
+
* - `'verbose'`: the message includes the full JSON-serialized where
|
|
50
|
+
* clause (e.g. `where: {"id":1,"email":"alice@x.com"}`).
|
|
51
|
+
*/
|
|
52
|
+
export declare function setErrorMessageMode(mode: ErrorMessageMode): void;
|
|
53
|
+
/** Returns the current NotFoundError message mode. Exported for tests. */
|
|
54
|
+
export declare function getErrorMessageMode(): ErrorMessageMode;
|
|
29
55
|
/**
|
|
30
56
|
* Thrown when a record is not found (findUniqueOrThrow, findFirstOrThrow,
|
|
31
57
|
* update/delete against a non-matching row, etc.)
|
|
@@ -35,8 +61,16 @@ export declare class TurbineError extends Error {
|
|
|
35
61
|
* - `new NotFoundError({ table, where, operation, cause, message })`
|
|
36
62
|
*
|
|
37
63
|
* When called with an options object and no explicit `message`, a Prisma-style
|
|
38
|
-
* message is built automatically,
|
|
64
|
+
* message is built automatically. By default, only the where-clause keys are
|
|
65
|
+
* shown to avoid leaking PII into logs:
|
|
66
|
+
* `[turbine] findUniqueOrThrow on "users" found no record matching where: { id }`
|
|
67
|
+
*
|
|
68
|
+
* Set `setErrorMessageMode('verbose')` (or pass `errorMessages: 'verbose'` to
|
|
69
|
+
* the TurbineClient constructor) to include the full where values:
|
|
39
70
|
* `[turbine] findUniqueOrThrow on "users" found no record matching where: {"id":1}`
|
|
71
|
+
*
|
|
72
|
+
* The full `where` object, `table`, and `operation` are always available as
|
|
73
|
+
* structured properties on the error instance regardless of mode.
|
|
40
74
|
*/
|
|
41
75
|
export declare class NotFoundError extends TurbineError {
|
|
42
76
|
readonly table?: string;
|
|
@@ -111,6 +145,57 @@ export declare class NotNullViolationError extends TurbineError {
|
|
|
111
145
|
cause?: unknown;
|
|
112
146
|
});
|
|
113
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Thrown when Postgres detects a deadlock (pg code 40P01).
|
|
150
|
+
*
|
|
151
|
+
* This error is **retryable** — when caught, callers can safely retry the
|
|
152
|
+
* transaction (typically with backoff). Catch it explicitly:
|
|
153
|
+
*
|
|
154
|
+
* ```ts
|
|
155
|
+
* try {
|
|
156
|
+
* await db.$transaction(async (tx) => { ... });
|
|
157
|
+
* } catch (err) {
|
|
158
|
+
* if (err instanceof DeadlockError) {
|
|
159
|
+
* // safe to retry
|
|
160
|
+
* }
|
|
161
|
+
* }
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
export declare class DeadlockError extends TurbineError {
|
|
165
|
+
/** Marks this error as safe to retry */
|
|
166
|
+
readonly isRetryable: true;
|
|
167
|
+
readonly constraint?: string;
|
|
168
|
+
constructor(opts?: {
|
|
169
|
+
message?: string;
|
|
170
|
+
constraint?: string;
|
|
171
|
+
cause?: unknown;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Thrown when a Serializable transaction fails due to a serialization
|
|
176
|
+
* conflict (pg code 40001 — `could not serialize access due to ...`).
|
|
177
|
+
*
|
|
178
|
+
* This error is **retryable** — by Postgres documentation, the recommended
|
|
179
|
+
* response is to re-run the entire transaction. Catch it explicitly:
|
|
180
|
+
*
|
|
181
|
+
* ```ts
|
|
182
|
+
* try {
|
|
183
|
+
* await db.$transaction(async (tx) => { ... }, { isolationLevel: 'Serializable' });
|
|
184
|
+
* } catch (err) {
|
|
185
|
+
* if (err instanceof SerializationFailureError) {
|
|
186
|
+
* // safe to retry the whole transaction
|
|
187
|
+
* }
|
|
188
|
+
* }
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
export declare class SerializationFailureError extends TurbineError {
|
|
192
|
+
/** Marks this error as safe to retry */
|
|
193
|
+
readonly isRetryable: true;
|
|
194
|
+
constructor(opts?: {
|
|
195
|
+
message?: string;
|
|
196
|
+
cause?: unknown;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
114
199
|
/** Thrown when a CHECK constraint is violated (pg code 23514) */
|
|
115
200
|
export declare class CheckConstraintError extends TurbineError {
|
|
116
201
|
readonly constraint?: string;
|
|
@@ -131,6 +216,8 @@ export declare class CheckConstraintError extends TurbineError {
|
|
|
131
216
|
* 23503 (foreign_key_violation) -> ForeignKeyError
|
|
132
217
|
* 23502 (not_null_violation) -> NotNullViolationError
|
|
133
218
|
* 23514 (check_violation) -> CheckConstraintError
|
|
219
|
+
* 40P01 (deadlock_detected) -> DeadlockError (retryable)
|
|
220
|
+
* 40001 (serialization_failure) -> SerializationFailureError (retryable)
|
|
134
221
|
*
|
|
135
222
|
* The original pg error is preserved as `.cause` on the wrapped error.
|
|
136
223
|
*/
|
package/dist/errors.js
CHANGED
|
@@ -17,6 +17,8 @@ export const TurbineErrorCode = {
|
|
|
17
17
|
FOREIGN_KEY_VIOLATION: 'TURBINE_E009',
|
|
18
18
|
NOT_NULL_VIOLATION: 'TURBINE_E010',
|
|
19
19
|
CHECK_VIOLATION: 'TURBINE_E011',
|
|
20
|
+
DEADLOCK_DETECTED: 'TURBINE_E012',
|
|
21
|
+
SERIALIZATION_FAILURE: 'TURBINE_E013',
|
|
20
22
|
};
|
|
21
23
|
/** Base error class for all Turbine errors */
|
|
22
24
|
export class TurbineError extends Error {
|
|
@@ -27,6 +29,47 @@ export class TurbineError extends Error {
|
|
|
27
29
|
this.code = code;
|
|
28
30
|
}
|
|
29
31
|
}
|
|
32
|
+
let errorMessageMode = 'safe';
|
|
33
|
+
/**
|
|
34
|
+
* Set the global NotFoundError message mode. Called from the TurbineClient
|
|
35
|
+
* constructor when `TurbineConfig.errorMessages` is provided.
|
|
36
|
+
*
|
|
37
|
+
* - `'safe'` (default): the message includes only the keys of the where
|
|
38
|
+
* clause (e.g. `where: { id, email }`). Values are redacted.
|
|
39
|
+
* - `'verbose'`: the message includes the full JSON-serialized where
|
|
40
|
+
* clause (e.g. `where: {"id":1,"email":"alice@x.com"}`).
|
|
41
|
+
*/
|
|
42
|
+
export function setErrorMessageMode(mode) {
|
|
43
|
+
errorMessageMode = mode;
|
|
44
|
+
}
|
|
45
|
+
/** Returns the current NotFoundError message mode. Exported for tests. */
|
|
46
|
+
export function getErrorMessageMode() {
|
|
47
|
+
return errorMessageMode;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Render a `where` clause for error messages. In 'safe' mode (the default),
|
|
51
|
+
* only the keys are shown; values are stripped to avoid leaking PII into logs.
|
|
52
|
+
* Nested AND/OR/NOT combinators are recursively rendered.
|
|
53
|
+
*/
|
|
54
|
+
function renderWhereForMessage(where, mode) {
|
|
55
|
+
if (mode === 'verbose') {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.stringify(where);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return '[unserializable]';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// safe mode: keys only
|
|
64
|
+
if (where === null || where === undefined)
|
|
65
|
+
return '';
|
|
66
|
+
if (typeof where !== 'object')
|
|
67
|
+
return '';
|
|
68
|
+
const keys = Object.keys(where);
|
|
69
|
+
if (keys.length === 0)
|
|
70
|
+
return '{}';
|
|
71
|
+
return `{ ${keys.join(', ')} }`;
|
|
72
|
+
}
|
|
30
73
|
/**
|
|
31
74
|
* Thrown when a record is not found (findUniqueOrThrow, findFirstOrThrow,
|
|
32
75
|
* update/delete against a non-matching row, etc.)
|
|
@@ -36,8 +79,16 @@ export class TurbineError extends Error {
|
|
|
36
79
|
* - `new NotFoundError({ table, where, operation, cause, message })`
|
|
37
80
|
*
|
|
38
81
|
* When called with an options object and no explicit `message`, a Prisma-style
|
|
39
|
-
* message is built automatically,
|
|
82
|
+
* message is built automatically. By default, only the where-clause keys are
|
|
83
|
+
* shown to avoid leaking PII into logs:
|
|
84
|
+
* `[turbine] findUniqueOrThrow on "users" found no record matching where: { id }`
|
|
85
|
+
*
|
|
86
|
+
* Set `setErrorMessageMode('verbose')` (or pass `errorMessages: 'verbose'` to
|
|
87
|
+
* the TurbineClient constructor) to include the full where values:
|
|
40
88
|
* `[turbine] findUniqueOrThrow on "users" found no record matching where: {"id":1}`
|
|
89
|
+
*
|
|
90
|
+
* The full `where` object, `table`, and `operation` are always available as
|
|
91
|
+
* structured properties on the error instance regardless of mode.
|
|
41
92
|
*/
|
|
42
93
|
export class NotFoundError extends TurbineError {
|
|
43
94
|
table;
|
|
@@ -54,11 +105,11 @@ export class NotFoundError extends TurbineError {
|
|
|
54
105
|
let message = input.message;
|
|
55
106
|
if (!message) {
|
|
56
107
|
if (operation && table) {
|
|
57
|
-
const wherePart = where !== undefined ? ` matching where: ${
|
|
108
|
+
const wherePart = where !== undefined ? ` matching where: ${renderWhereForMessage(where, errorMessageMode)}` : '';
|
|
58
109
|
message = `[turbine] ${operation} on "${table}" found no record${wherePart}`;
|
|
59
110
|
}
|
|
60
111
|
else if (table) {
|
|
61
|
-
const wherePart = where !== undefined ? ` matching where ${
|
|
112
|
+
const wherePart = where !== undefined ? ` matching where ${renderWhereForMessage(where, errorMessageMode)}` : '';
|
|
62
113
|
message = `[turbine] No record found in "${table}"${wherePart}`;
|
|
63
114
|
}
|
|
64
115
|
else {
|
|
@@ -194,6 +245,71 @@ export class NotNullViolationError extends TurbineError {
|
|
|
194
245
|
this.table = table;
|
|
195
246
|
}
|
|
196
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Thrown when Postgres detects a deadlock (pg code 40P01).
|
|
250
|
+
*
|
|
251
|
+
* This error is **retryable** — when caught, callers can safely retry the
|
|
252
|
+
* transaction (typically with backoff). Catch it explicitly:
|
|
253
|
+
*
|
|
254
|
+
* ```ts
|
|
255
|
+
* try {
|
|
256
|
+
* await db.$transaction(async (tx) => { ... });
|
|
257
|
+
* } catch (err) {
|
|
258
|
+
* if (err instanceof DeadlockError) {
|
|
259
|
+
* // safe to retry
|
|
260
|
+
* }
|
|
261
|
+
* }
|
|
262
|
+
* ```
|
|
263
|
+
*/
|
|
264
|
+
export class DeadlockError extends TurbineError {
|
|
265
|
+
/** Marks this error as safe to retry */
|
|
266
|
+
isRetryable = true;
|
|
267
|
+
constraint;
|
|
268
|
+
constructor(opts = {}) {
|
|
269
|
+
const { constraint, cause } = opts;
|
|
270
|
+
let message = opts.message;
|
|
271
|
+
if (!message) {
|
|
272
|
+
const pgMessage = cause?.message;
|
|
273
|
+
message = pgMessage ? `[turbine] Deadlock detected: ${pgMessage}` : '[turbine] Deadlock detected';
|
|
274
|
+
}
|
|
275
|
+
super(TurbineErrorCode.DEADLOCK_DETECTED, message, { cause });
|
|
276
|
+
this.name = 'DeadlockError';
|
|
277
|
+
this.constraint = constraint;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Thrown when a Serializable transaction fails due to a serialization
|
|
282
|
+
* conflict (pg code 40001 — `could not serialize access due to ...`).
|
|
283
|
+
*
|
|
284
|
+
* This error is **retryable** — by Postgres documentation, the recommended
|
|
285
|
+
* response is to re-run the entire transaction. Catch it explicitly:
|
|
286
|
+
*
|
|
287
|
+
* ```ts
|
|
288
|
+
* try {
|
|
289
|
+
* await db.$transaction(async (tx) => { ... }, { isolationLevel: 'Serializable' });
|
|
290
|
+
* } catch (err) {
|
|
291
|
+
* if (err instanceof SerializationFailureError) {
|
|
292
|
+
* // safe to retry the whole transaction
|
|
293
|
+
* }
|
|
294
|
+
* }
|
|
295
|
+
* ```
|
|
296
|
+
*/
|
|
297
|
+
export class SerializationFailureError extends TurbineError {
|
|
298
|
+
/** Marks this error as safe to retry */
|
|
299
|
+
isRetryable = true;
|
|
300
|
+
constructor(opts = {}) {
|
|
301
|
+
const { cause } = opts;
|
|
302
|
+
let message = opts.message;
|
|
303
|
+
if (!message) {
|
|
304
|
+
const pgMessage = cause?.message;
|
|
305
|
+
message = pgMessage
|
|
306
|
+
? `[turbine] Serializable transaction conflict: ${pgMessage}`
|
|
307
|
+
: '[turbine] Serializable transaction conflict';
|
|
308
|
+
}
|
|
309
|
+
super(TurbineErrorCode.SERIALIZATION_FAILURE, message, { cause });
|
|
310
|
+
this.name = 'SerializationFailureError';
|
|
311
|
+
}
|
|
312
|
+
}
|
|
197
313
|
/** Thrown when a CHECK constraint is violated (pg code 23514) */
|
|
198
314
|
export class CheckConstraintError extends TurbineError {
|
|
199
315
|
constraint;
|
|
@@ -234,6 +350,8 @@ function parseColumnsFromDetail(detail) {
|
|
|
234
350
|
* 23503 (foreign_key_violation) -> ForeignKeyError
|
|
235
351
|
* 23502 (not_null_violation) -> NotNullViolationError
|
|
236
352
|
* 23514 (check_violation) -> CheckConstraintError
|
|
353
|
+
* 40P01 (deadlock_detected) -> DeadlockError (retryable)
|
|
354
|
+
* 40001 (serialization_failure) -> SerializationFailureError (retryable)
|
|
237
355
|
*
|
|
238
356
|
* The original pg error is preserved as `.cause` on the wrapped error.
|
|
239
357
|
*/
|
|
@@ -271,6 +389,15 @@ export function wrapPgError(err) {
|
|
|
271
389
|
table: e.table,
|
|
272
390
|
cause: err,
|
|
273
391
|
});
|
|
392
|
+
case '40P01':
|
|
393
|
+
return new DeadlockError({
|
|
394
|
+
constraint: e.constraint,
|
|
395
|
+
cause: err,
|
|
396
|
+
});
|
|
397
|
+
case '40001':
|
|
398
|
+
return new SerializationFailureError({
|
|
399
|
+
cause: err,
|
|
400
|
+
});
|
|
274
401
|
default:
|
|
275
402
|
return err;
|
|
276
403
|
}
|
package/dist/generate.d.ts
CHANGED
|
@@ -21,4 +21,10 @@ export declare function generate(options: GenerateOptions): {
|
|
|
21
21
|
outDir: string;
|
|
22
22
|
files: string[];
|
|
23
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* Generate the contents of `types.ts` (entity interfaces, *Create / *Update,
|
|
26
|
+
* and *Relations brand-field interfaces). Exported so tests can pin the
|
|
27
|
+
* generator output without writing files to disk.
|
|
28
|
+
*/
|
|
29
|
+
export declare function generateTypes(schema: SchemaMetadata): string;
|
|
24
30
|
//# sourceMappingURL=generate.d.ts.map
|