turbine-orm 0.7.0 → 0.8.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.
@@ -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,7 +22,8 @@
22
22
  * ```
23
23
  */
24
24
  import pg from 'pg';
25
- import { type PipelineResults } from './pipeline.js';
25
+ import { type ErrorMessageMode } from './errors.js';
26
+ import { type PipelineOptions, type PipelineResults } from './pipeline.js';
26
27
  import { type DeferredQuery, QueryInterface, type QueryInterfaceOptions } from './query.js';
27
28
  import type { SchemaMetadata } from './schema.js';
28
29
  /**
@@ -110,6 +111,37 @@ 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;
128
+ /**
129
+ * Enable prepared statements. Queries are submitted with `{ name, text, values }`
130
+ * to the pg driver, which caches the parse+plan on the server per connection.
131
+ *
132
+ * Default: `true` for Turbine-owned pools, `false` for external pools (serverless
133
+ * drivers may not support named statements).
134
+ *
135
+ * Override with `TURBINE_DISABLE_PREPARED=1` env var.
136
+ */
137
+ preparedStatements?: boolean;
138
+ /**
139
+ * Enable the SQL template cache. Repeated queries with the same shape reuse
140
+ * cached SQL text instead of rebuilding from scratch.
141
+ *
142
+ * Default: `true`. Set to `false` as a nuclear kill switch.
143
+ */
144
+ sqlCache?: boolean;
113
145
  }
114
146
  /** Parameters passed to middleware functions */
115
147
  export interface MiddlewareParams {
@@ -221,9 +253,21 @@ export declare class TurbineClient {
221
253
  /**
222
254
  * Execute multiple queries in a single database round-trip.
223
255
  *
224
- * Pass the result of any `.build*()` method on a table accessor.
256
+ * Two call styles:
257
+ * - `db.pipeline(q1, q2, q3)` — rest params (backward-compatible)
258
+ * - `db.pipeline([q1, q2, q3], { transactional: false })` — array + options
259
+ *
260
+ * On pg.Pool-backed connections with TCP, this uses the real Postgres
261
+ * extended-query pipeline protocol (one TCP flush, one round-trip).
262
+ * On HTTP-based drivers it falls back to sequential execution.
263
+ */
264
+ pipeline<T extends readonly DeferredQuery<unknown>[]>(...args: T | [T, PipelineOptions?]): Promise<PipelineResults<T>>;
265
+ /**
266
+ * Check whether the underlying pool supports the real pipeline protocol.
267
+ * Returns `true` for standard pg.Pool TCP connections, `false` for HTTP
268
+ * drivers (Neon HTTP, Vercel Postgres, etc.) and mock pools.
225
269
  */
226
- pipeline<T extends readonly DeferredQuery<unknown>[]>(...queries: T): Promise<PipelineResults<T>>;
270
+ pipelineSupported(): Promise<boolean>;
227
271
  /**
228
272
  * Execute a raw SQL query with parameter interpolation via tagged templates.
229
273
  *
package/dist/client.js CHANGED
@@ -22,8 +22,8 @@
22
22
  * ```
23
23
  */
24
24
  import pg from 'pg';
25
- import { TimeoutError, wrapPgError } from './errors.js';
26
- import { executePipeline } from './pipeline.js';
25
+ import { setErrorMessageMode, TimeoutError, wrapPgError } from './errors.js';
26
+ import { executePipeline, pipelineSupported } from './pipeline.js';
27
27
  import { QueryInterface } from './query.js';
28
28
  /** Maps isolation level names to SQL */
29
29
  const ISOLATION_LEVELS = {
@@ -128,9 +128,15 @@ export class TransactionClient {
128
128
  // Return a minimal pool-compatible object that routes queries
129
129
  // through the transaction client
130
130
  return {
131
- query: async (text, values) => {
131
+ query: async (textOrConfig, values) => {
132
132
  try {
133
- return await client.query(text, values);
133
+ if (typeof textOrConfig === 'string') {
134
+ return await client.query(textOrConfig, values);
135
+ }
136
+ // Object form for prepared statements: { name, text, values }
137
+ // pg.PoolClient.query accepts QueryConfig but the overloads make TS
138
+ // unhappy with the union, so we cast through unknown.
139
+ return await client.query(textOrConfig);
134
140
  }
135
141
  catch (err) {
136
142
  throw wrapPgError(err);
@@ -184,10 +190,19 @@ export class TurbineClient {
184
190
  }
185
191
  this.logging = config.logging ?? false;
186
192
  this.schema = schema;
193
+ // Respect env var kill switch
194
+ const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
187
195
  this.queryOptions = {
188
196
  defaultLimit: config.defaultLimit,
189
197
  warnOnUnlimited: config.warnOnUnlimited,
198
+ preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
199
+ sqlCache: config.sqlCache ?? true,
190
200
  };
201
+ // Apply NotFoundError message redaction mode (default: safe — values are
202
+ // stripped from messages to avoid leaking PII into error logs).
203
+ if (config.errorMessages) {
204
+ setErrorMessageMode(config.errorMessages);
205
+ }
191
206
  if (config.pool) {
192
207
  // External pool — use directly. Turbine doesn't manage its lifecycle.
193
208
  this.pool = config.pool;
@@ -295,13 +310,41 @@ export class TurbineClient {
295
310
  /**
296
311
  * Execute multiple queries in a single database round-trip.
297
312
  *
298
- * Pass the result of any `.build*()` method on a table accessor.
313
+ * Two call styles:
314
+ * - `db.pipeline(q1, q2, q3)` — rest params (backward-compatible)
315
+ * - `db.pipeline([q1, q2, q3], { transactional: false })` — array + options
316
+ *
317
+ * On pg.Pool-backed connections with TCP, this uses the real Postgres
318
+ * extended-query pipeline protocol (one TCP flush, one round-trip).
319
+ * On HTTP-based drivers it falls back to sequential execution.
299
320
  */
300
- async pipeline(...queries) {
321
+ async pipeline(...args) {
322
+ let queries;
323
+ let options;
324
+ // Detect which overload was used
325
+ if (args.length > 0 &&
326
+ Array.isArray(args[0]) &&
327
+ args[0].every((item) => item && typeof item === 'object' && 'sql' in item)) {
328
+ // Array form: pipeline([q1, q2], opts?)
329
+ queries = args[0];
330
+ options = args[1];
331
+ }
332
+ else {
333
+ // Rest-param form: pipeline(q1, q2, q3)
334
+ queries = args;
335
+ }
301
336
  if (this.logging) {
302
337
  console.log(`[turbine] Pipeline: ${queries.length} queries — ${queries.map((q) => q.tag).join(', ')}`);
303
338
  }
304
- return executePipeline(this.pool, queries);
339
+ return executePipeline(this.pool, queries, options);
340
+ }
341
+ /**
342
+ * Check whether the underlying pool supports the real pipeline protocol.
343
+ * Returns `true` for standard pg.Pool TCP connections, `false` for HTTP
344
+ * drivers (Neon HTTP, Vercel Postgres, etc.) and mock pools.
345
+ */
346
+ async pipelineSupported() {
347
+ return pipelineSupported(this.pool);
305
348
  }
306
349
  // -------------------------------------------------------------------------
307
350
  // Raw SQL — tagged template literal escape hatch
@@ -392,6 +435,24 @@ export class TurbineClient {
392
435
  async $transaction(fn, options) {
393
436
  const client = await this.pool.connect();
394
437
  const timeout = options?.timeout;
438
+ /**
439
+ * Track whether the connection has already been released so the finally
440
+ * block doesn't double-release. When a timeout fires we destroy the
441
+ * connection eagerly to abort the in-flight backend query.
442
+ */
443
+ let released = false;
444
+ const releaseOnce = (err) => {
445
+ if (released)
446
+ return;
447
+ released = true;
448
+ try {
449
+ client.release(err);
450
+ }
451
+ catch {
452
+ // pg may throw if the client is already released — swallow.
453
+ }
454
+ };
455
+ let timedOut = false;
395
456
  try {
396
457
  // BEGIN with optional isolation level
397
458
  let beginSQL = 'BEGIN';
@@ -415,10 +476,22 @@ export class TurbineClient {
415
476
  }
416
477
  let result;
417
478
  if (timeout) {
418
- // Race between the function and a timeout
479
+ // Race between the function and a timeout. If the timeout fires we
480
+ // need to actually abort the in-flight query — otherwise the backend
481
+ // keeps running until pg's own timeout, holding a pool slot the whole
482
+ // time. The simplest reliable cancellation is to destroy the
483
+ // connection: passing a truthy argument to client.release() tells the
484
+ // pg pool to discard the client (its socket is closed, which causes
485
+ // Postgres to abort the active query and roll back the transaction).
486
+ // The pool will spin up a fresh connection on the next checkout.
419
487
  let timer;
420
488
  const timeoutPromise = new Promise((_, reject) => {
421
489
  timer = setTimeout(() => {
490
+ timedOut = true;
491
+ // Destroy the connection to abort the in-flight backend query.
492
+ // We do this BEFORE rejecting so the socket is gone by the time
493
+ // the caller's catch block runs.
494
+ releaseOnce(new Error('[turbine] Transaction timeout — connection destroyed'));
422
495
  reject(new TimeoutError(timeout, 'Transaction'));
423
496
  }, timeout);
424
497
  });
@@ -439,14 +512,25 @@ export class TurbineClient {
439
512
  return result;
440
513
  }
441
514
  catch (err) {
442
- await client.query('ROLLBACK');
515
+ // If the timeout fired we already destroyed the connection — issuing a
516
+ // ROLLBACK on a released client would throw "Client has already been
517
+ // released". Skip the rollback in that case (the backend rolled back
518
+ // when its socket was closed).
519
+ if (!timedOut && !released) {
520
+ try {
521
+ await client.query('ROLLBACK');
522
+ }
523
+ catch {
524
+ // Best-effort rollback — the connection may have died mid-query.
525
+ }
526
+ }
443
527
  if (this.logging) {
444
528
  console.log('[turbine] Transaction rolled back');
445
529
  }
446
530
  throw err;
447
531
  }
448
532
  finally {
449
- client.release();
533
+ releaseOnce();
450
534
  }
451
535
  }
452
536
  // -------------------------------------------------------------------------
package/dist/errors.d.ts CHANGED
@@ -17,6 +17,9 @@ 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";
22
+ readonly PIPELINE: "TURBINE_E014";
20
23
  };
21
24
  export type TurbineErrorCode = (typeof TurbineErrorCode)[keyof typeof TurbineErrorCode];
22
25
  /** Base error class for all Turbine errors */
@@ -26,6 +29,30 @@ export declare class TurbineError extends Error {
26
29
  cause?: unknown;
27
30
  });
28
31
  }
32
+ /**
33
+ * Controls whether NotFoundError messages include the actual `where` values
34
+ * (`'verbose'`) or only the where-clause keys (`'safe'`, the default).
35
+ *
36
+ * Defaults to `'safe'` to avoid leaking PII into error logs (Sentry, Datadog,
37
+ * etc.). The full `where` object is always available as `err.where` for
38
+ * programmatic access — only the human-readable message is redacted.
39
+ *
40
+ * Set via `setErrorMessageMode('verbose')` or by constructing TurbineClient
41
+ * with `{ errorMessages: 'verbose' }`.
42
+ */
43
+ export type ErrorMessageMode = 'safe' | 'verbose';
44
+ /**
45
+ * Set the global NotFoundError message mode. Called from the TurbineClient
46
+ * constructor when `TurbineConfig.errorMessages` is provided.
47
+ *
48
+ * - `'safe'` (default): the message includes only the keys of the where
49
+ * clause (e.g. `where: { id, email }`). Values are redacted.
50
+ * - `'verbose'`: the message includes the full JSON-serialized where
51
+ * clause (e.g. `where: {"id":1,"email":"alice@x.com"}`).
52
+ */
53
+ export declare function setErrorMessageMode(mode: ErrorMessageMode): void;
54
+ /** Returns the current NotFoundError message mode. Exported for tests. */
55
+ export declare function getErrorMessageMode(): ErrorMessageMode;
29
56
  /**
30
57
  * Thrown when a record is not found (findUniqueOrThrow, findFirstOrThrow,
31
58
  * update/delete against a non-matching row, etc.)
@@ -35,8 +62,16 @@ export declare class TurbineError extends Error {
35
62
  * - `new NotFoundError({ table, where, operation, cause, message })`
36
63
  *
37
64
  * When called with an options object and no explicit `message`, a Prisma-style
38
- * message is built automatically, e.g.:
65
+ * message is built automatically. By default, only the where-clause keys are
66
+ * shown to avoid leaking PII into logs:
67
+ * `[turbine] findUniqueOrThrow on "users" found no record matching where: { id }`
68
+ *
69
+ * Set `setErrorMessageMode('verbose')` (or pass `errorMessages: 'verbose'` to
70
+ * the TurbineClient constructor) to include the full where values:
39
71
  * `[turbine] findUniqueOrThrow on "users" found no record matching where: {"id":1}`
72
+ *
73
+ * The full `where` object, `table`, and `operation` are always available as
74
+ * structured properties on the error instance regardless of mode.
40
75
  */
41
76
  export declare class NotFoundError extends TurbineError {
42
77
  readonly table?: string;
@@ -111,6 +146,57 @@ export declare class NotNullViolationError extends TurbineError {
111
146
  cause?: unknown;
112
147
  });
113
148
  }
149
+ /**
150
+ * Thrown when Postgres detects a deadlock (pg code 40P01).
151
+ *
152
+ * This error is **retryable** — when caught, callers can safely retry the
153
+ * transaction (typically with backoff). Catch it explicitly:
154
+ *
155
+ * ```ts
156
+ * try {
157
+ * await db.$transaction(async (tx) => { ... });
158
+ * } catch (err) {
159
+ * if (err instanceof DeadlockError) {
160
+ * // safe to retry
161
+ * }
162
+ * }
163
+ * ```
164
+ */
165
+ export declare class DeadlockError extends TurbineError {
166
+ /** Marks this error as safe to retry */
167
+ readonly isRetryable: true;
168
+ readonly constraint?: string;
169
+ constructor(opts?: {
170
+ message?: string;
171
+ constraint?: string;
172
+ cause?: unknown;
173
+ });
174
+ }
175
+ /**
176
+ * Thrown when a Serializable transaction fails due to a serialization
177
+ * conflict (pg code 40001 — `could not serialize access due to ...`).
178
+ *
179
+ * This error is **retryable** — by Postgres documentation, the recommended
180
+ * response is to re-run the entire transaction. Catch it explicitly:
181
+ *
182
+ * ```ts
183
+ * try {
184
+ * await db.$transaction(async (tx) => { ... }, { isolationLevel: 'Serializable' });
185
+ * } catch (err) {
186
+ * if (err instanceof SerializationFailureError) {
187
+ * // safe to retry the whole transaction
188
+ * }
189
+ * }
190
+ * ```
191
+ */
192
+ export declare class SerializationFailureError extends TurbineError {
193
+ /** Marks this error as safe to retry */
194
+ readonly isRetryable: true;
195
+ constructor(opts?: {
196
+ message?: string;
197
+ cause?: unknown;
198
+ });
199
+ }
114
200
  /** Thrown when a CHECK constraint is violated (pg code 23514) */
115
201
  export declare class CheckConstraintError extends TurbineError {
116
202
  readonly constraint?: string;
@@ -122,6 +208,49 @@ export declare class CheckConstraintError extends TurbineError {
122
208
  cause?: unknown;
123
209
  });
124
210
  }
211
+ /** Result slot for a single query in a non-transactional pipeline */
212
+ export type PipelineResultSlot = {
213
+ status: 'ok';
214
+ value: unknown;
215
+ } | {
216
+ status: 'error';
217
+ error: Error;
218
+ };
219
+ /**
220
+ * Thrown when a non-transactional pipeline has partial failures.
221
+ *
222
+ * In non-transactional mode (`{ transactional: false }`), each query executes
223
+ * independently. If one or more queries fail, the pipeline rejects with a
224
+ * `PipelineError` that carries per-query results so callers can inspect which
225
+ * succeeded and which failed.
226
+ *
227
+ * ```ts
228
+ * try {
229
+ * await db.pipeline([q1, q2, q3], { transactional: false });
230
+ * } catch (err) {
231
+ * if (err instanceof PipelineError) {
232
+ * for (const slot of err.results) {
233
+ * if (slot.status === 'error') console.error(slot.error);
234
+ * }
235
+ * }
236
+ * }
237
+ * ```
238
+ */
239
+ export declare class PipelineError extends TurbineError {
240
+ /** Per-query results: each slot is either `{status:'ok', value}` or `{status:'error', error}` */
241
+ readonly results: PipelineResultSlot[];
242
+ /** Zero-based index of the first query that failed */
243
+ readonly failedIndex?: number;
244
+ /** Tag of the first query that failed (from DeferredQuery.tag) */
245
+ readonly failedTag?: string;
246
+ constructor(opts: {
247
+ message?: string;
248
+ results: PipelineResultSlot[];
249
+ failedIndex?: number;
250
+ failedTag?: string;
251
+ cause?: unknown;
252
+ });
253
+ }
125
254
  /**
126
255
  * Translate a pg driver error into a typed Turbine error.
127
256
  * If the error doesn't match a known constraint code, returns it unchanged.
@@ -131,6 +260,8 @@ export declare class CheckConstraintError extends TurbineError {
131
260
  * 23503 (foreign_key_violation) -> ForeignKeyError
132
261
  * 23502 (not_null_violation) -> NotNullViolationError
133
262
  * 23514 (check_violation) -> CheckConstraintError
263
+ * 40P01 (deadlock_detected) -> DeadlockError (retryable)
264
+ * 40001 (serialization_failure) -> SerializationFailureError (retryable)
134
265
  *
135
266
  * The original pg error is preserved as `.cause` on the wrapped error.
136
267
  */