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.
@@ -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 (skip with --force)
278
- if (!options?.force) {
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 parts = [];
284
- if (modified.length > 0)
285
- parts.push(`modified: ${modified.map((m) => m.name).join(', ')}`);
286
- if (missing.length > 0)
287
- parts.push(`deleted: ${missing.map((m) => m.name).join(', ')}`);
288
- throw new MigrationError(`[turbine] Migration integrity check failed — ${parts.join('; ')}. Applied migrations should be immutable. Use --force to skip this check.`);
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
- await client.query('ROLLBACK');
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
- client.release();
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, e.g.:
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, e.g.:
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: ${JSON.stringify(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 ${JSON.stringify(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
  }
@@ -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