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 CHANGED
@@ -147,6 +147,30 @@ const deleted = await db.users.delete({
147
147
  });
148
148
  ```
149
149
 
150
+ ### Atomic update operators
151
+
152
+ For race-free counter updates, pass an operator object instead of a literal. Turbine generates `col = col + $n` style SQL so concurrent updates are safe.
153
+
154
+ ```typescript
155
+ // Atomic increment — no read-modify-write race
156
+ await db.posts.update({
157
+ where: { id: 1 },
158
+ data: { viewCount: { increment: 1 } },
159
+ });
160
+
161
+ // Other supported operators on numeric columns
162
+ await db.posts.update({
163
+ where: { id: 1 },
164
+ data: {
165
+ viewCount: { increment: 5 },
166
+ likesCount: { decrement: 1 },
167
+ score: { multiply: 2 },
168
+ rank: { divide: 2 },
169
+ title: { set: 'New title' }, // explicit set, equivalent to a literal
170
+ },
171
+ });
172
+ ```
173
+
150
174
  ### Transactions
151
175
 
152
176
  ```typescript
@@ -273,6 +297,73 @@ try {
273
297
 
274
298
  Error codes: `TURBINE_E001` (NotFound), `TURBINE_E002` (Timeout), `TURBINE_E003` (Validation), `TURBINE_E004` (Connection), `TURBINE_E005` (Relation), `TURBINE_E006` (Migration), `TURBINE_E007` (CircularRelation).
275
299
 
300
+ ## WHERE Operator Reference
301
+
302
+ Every operator supported by the `where` clause. Operators compose freely with `AND`, `OR`, `NOT`, and the relation filters `some` / `every` / `none`.
303
+
304
+ ### Equality
305
+
306
+ | Operator | Description | Example |
307
+ |---|---|---|
308
+ | literal | Implicit equality | `where: { email: 'a@b.com' }` |
309
+ | `equals` | Explicit equality | `where: { email: { equals: 'a@b.com' } }` |
310
+ | `not` | Inequality (or `not: null` for `IS NOT NULL`) | `where: { role: { not: 'admin' } }` |
311
+
312
+ ### Sets
313
+
314
+ | Operator | Description | Example |
315
+ |---|---|---|
316
+ | `in` | Match any value in the array | `where: { id: { in: [1, 2, 3] } }` |
317
+ | `notIn` | Match none of the values in the array | `where: { role: { notIn: ['banned', 'spam'] } }` |
318
+
319
+ ### Comparison
320
+
321
+ | Operator | Description | Example |
322
+ |---|---|---|
323
+ | `gt` | Greater than | `where: { score: { gt: 100 } }` |
324
+ | `gte` | Greater than or equal | `where: { score: { gte: 100 } }` |
325
+ | `lt` | Less than | `where: { score: { lt: 100 } }` |
326
+ | `lte` | Less than or equal | `where: { score: { lte: 100 } }` |
327
+
328
+ ### String
329
+
330
+ | Operator | Description | Example |
331
+ |---|---|---|
332
+ | `contains` | Substring match (`LIKE %v%`) | `where: { title: { contains: 'sql' } }` |
333
+ | `startsWith` | Prefix match (`LIKE v%`) | `where: { email: { startsWith: 'admin@' } }` |
334
+ | `endsWith` | Suffix match (`LIKE %v`) | `where: { email: { endsWith: '@acme.com' } }` |
335
+ | `mode: 'insensitive'` | Switch any string operator to `ILIKE` | `where: { title: { contains: 'SQL', mode: 'insensitive' } }` |
336
+
337
+ LIKE wildcards in user input are escaped automatically — `%`, `_`, and `\` are treated as literals.
338
+
339
+ ### Relation filters
340
+
341
+ Filter parent rows by predicates against their related child rows. Available on `hasMany` and `hasOne` relations.
342
+
343
+ | Operator | Description | Example |
344
+ |---|---|---|
345
+ | `some` | At least one related row matches | `where: { posts: { some: { published: true } } }` |
346
+ | `every` | Every related row matches | `where: { posts: { every: { published: true } } }` |
347
+ | `none` | No related row matches | `where: { posts: { none: { published: false } } }` |
348
+
349
+ ### Array columns
350
+
351
+ Operators for Postgres array columns (`text[]`, `int[]`, etc.).
352
+
353
+ | Operator | Description | Example |
354
+ |---|---|---|
355
+ | `has` | Array contains the given element | `where: { tags: { has: 'sql' } }` |
356
+ | `hasEvery` | Array contains every element in the list | `where: { tags: { hasEvery: ['sql', 'postgres'] } }` |
357
+ | `hasSome` | Array contains at least one element from the list | `where: { tags: { hasSome: ['sql', 'mysql'] } }` |
358
+
359
+ ### Combinators
360
+
361
+ | Operator | Description | Example |
362
+ |---|---|---|
363
+ | `AND` | All sub-clauses must match | `where: { AND: [{ orgId: 1 }, { role: 'admin' }] }` |
364
+ | `OR` | Any sub-clause matches | `where: { OR: [{ role: 'admin' }, { role: 'owner' }] }` |
365
+ | `NOT` | Negate a sub-clause | `where: { NOT: { role: 'banned' } }` |
366
+
276
367
  ## CLI
277
368
 
278
369
  ```
@@ -492,7 +583,6 @@ All three ORMs now use single-query approaches for nested relations. Turbine use
492
583
  Turbine is focused and opinionated. Here's what it doesn't do:
493
584
 
494
585
  - **PostgreSQL only.** No MySQL, SQLite, or MSSQL. This is by design — the `json_agg` approach is PostgreSQL-specific, and going deep on one database enables the performance advantage.
495
- - **No incremental updates.** Prisma's `{ count: { increment: 1 } }` syntax is not yet supported. Use raw SQL for atomic increments.
496
586
  - **No full-text search operators.** TSVECTOR/TSQUERY are not exposed in the query builder. Use `db.raw` for full-text queries.
497
587
  - **Large nested result sets.** `json_agg` builds the entire JSON array in PostgreSQL memory. For relations with 10K+ rows, always use `limit` in your `with` clause to cap the aggregation size.
498
588
  - **No admin UI.** Turbine Studio is planned but not yet available.
@@ -500,6 +590,14 @@ Turbine is focused and opinionated. Here's what it doesn't do:
500
590
  ## Examples
501
591
 
502
592
  - **[Next.js](./examples/nextjs/)** — Server-rendered app with nested relations, streaming, and live code demos
593
+ - **[Neon Edge](./examples/neon-edge/)** — Vercel Edge route handler talking to Neon over HTTP via `@neondatabase/serverless`
594
+ - **[Vercel Postgres](./examples/vercel-postgres/)** — Next.js app router route handler on `@vercel/postgres`
595
+ - **[Cloudflare Worker](./examples/cloudflare-worker/)** — Worker `fetch` handler with `pg` over Cloudflare Hyperdrive
596
+ - **[Supabase](./examples/supabase/)** — Standalone script over the standard `pg` driver against Supabase
597
+
598
+ ## Guides
599
+
600
+ - **[Migrating from Prisma](./docs/migrate-from-prisma.md)** — API mapping table, side-by-side `findMany`, and notes on the differences
503
601
 
504
602
  ## Requirements
505
603
 
@@ -61,6 +61,7 @@ const generate_js_1 = require("../generate.js");
61
61
  const introspect_js_1 = require("../introspect.js");
62
62
  const schema_sql_js_1 = require("../schema-sql.js");
63
63
  const config_js_1 = require("./config.js");
64
+ const loader_js_1 = require("./loader.js");
64
65
  const migrate_js_1 = require("./migrate.js");
65
66
  const ui_js_1 = require("./ui.js");
66
67
  function parseArgs() {
@@ -113,6 +114,9 @@ function parseArgs() {
113
114
  case '--auto':
114
115
  result.auto = true;
115
116
  break;
117
+ case '--allow-drift':
118
+ result.allowDrift = true;
119
+ break;
116
120
  case '--force':
117
121
  case '-f':
118
122
  result.force = true;
@@ -135,6 +139,36 @@ function parseArgs() {
135
139
  return result;
136
140
  }
137
141
  // ---------------------------------------------------------------------------
142
+ // TypeScript loader — user-facing error helper
143
+ // ---------------------------------------------------------------------------
144
+ /**
145
+ * Print a friendly error explaining how to install tsx, then exit.
146
+ * Called when we know we need to load a `.ts` file but the loader isn't available.
147
+ */
148
+ function failMissingTsLoader(filePath, reason) {
149
+ (0, ui_js_1.newline)();
150
+ (0, ui_js_1.error)(`Cannot load TypeScript file: ${filePath}`);
151
+ (0, ui_js_1.newline)();
152
+ if (reason === 'unsupported') {
153
+ console.log(` ${(0, ui_js_1.dim)('Your Node.js version does not support')} ${(0, ui_js_1.cyan)('module.register()')}.`);
154
+ console.log(` ${(0, ui_js_1.dim)('Upgrade to Node.js')} ${(0, ui_js_1.cyan)('20.6+')} ${(0, ui_js_1.dim)('or use a')} ${(0, ui_js_1.cyan)('.js')} ${(0, ui_js_1.dim)('/')} ${(0, ui_js_1.cyan)('.mjs')} ${(0, ui_js_1.dim)('config file.')}`);
155
+ }
156
+ else {
157
+ console.log(` ${(0, ui_js_1.dim)('Loading .ts config / schema files requires')} ${(0, ui_js_1.cyan)('tsx')} ${(0, ui_js_1.dim)('to be installed.')}`);
158
+ (0, ui_js_1.newline)();
159
+ console.log(` ${(0, ui_js_1.dim)('Install it as a dev dependency:')}`);
160
+ console.log(` ${(0, ui_js_1.cyan)('npm install --save-dev tsx')}`);
161
+ console.log(` ${(0, ui_js_1.dim)('or')}`);
162
+ console.log(` ${(0, ui_js_1.cyan)('pnpm add -D tsx')}`);
163
+ console.log(` ${(0, ui_js_1.dim)('or')}`);
164
+ console.log(` ${(0, ui_js_1.cyan)('yarn add -D tsx')}`);
165
+ (0, ui_js_1.newline)();
166
+ console.log(` ${(0, ui_js_1.dim)('Alternatively, rename your file to')} ${(0, ui_js_1.cyan)('.js')} ${(0, ui_js_1.dim)('or')} ${(0, ui_js_1.cyan)('.mjs')}.`);
167
+ }
168
+ (0, ui_js_1.newline)();
169
+ process.exit(1);
170
+ }
171
+ // ---------------------------------------------------------------------------
138
172
  // Helpers
139
173
  // ---------------------------------------------------------------------------
140
174
  function requireUrl(config) {
@@ -157,6 +191,15 @@ async function loadSchemaFile(schemaFile) {
157
191
  console.log(` ${(0, ui_js_1.dim)('Create one with:')} ${(0, ui_js_1.cyan)('turbine init')}`);
158
192
  process.exit(1);
159
193
  }
194
+ // If this is a TypeScript file, ensure the tsx ESM loader is registered
195
+ // before we attempt the dynamic import. Without this, Node throws
196
+ // ERR_UNKNOWN_FILE_EXTENSION for `.ts`.
197
+ if ((0, loader_js_1.needsTsLoader)(absPath)) {
198
+ const status = await (0, loader_js_1.registerTsLoader)();
199
+ if (status === 'missing' || status === 'unsupported') {
200
+ failMissingTsLoader(schemaFile, status);
201
+ }
202
+ }
160
203
  try {
161
204
  const fileUrl = (0, node_url_1.pathToFileURL)(absPath).href;
162
205
  const mod = await Promise.resolve(`${fileUrl}`).then(s => __importStar(require(s)));
@@ -171,6 +214,11 @@ async function loadSchemaFile(schemaFile) {
171
214
  (0, ui_js_1.error)(`Failed to load schema file: ${schemaFile}`);
172
215
  if (err instanceof Error) {
173
216
  console.log(` ${(0, ui_js_1.dim)(err.message)}`);
217
+ // If the error is the classic ERR_UNKNOWN_FILE_EXTENSION, give a hint.
218
+ if (err.message.includes('ERR_UNKNOWN_FILE_EXTENSION') || err.message.includes('Unknown file extension')) {
219
+ (0, ui_js_1.newline)();
220
+ console.log(` ${(0, ui_js_1.dim)('Hint: install')} ${(0, ui_js_1.cyan)('tsx')} ${(0, ui_js_1.dim)('to load .ts files:')} ${(0, ui_js_1.cyan)('npm install --save-dev tsx')}`);
221
+ }
174
222
  }
175
223
  process.exit(1);
176
224
  }
@@ -521,9 +569,10 @@ async function cmdMigrate(args, config) {
521
569
  console.log(` ${(0, ui_js_1.cyan)('status')} Show migration status`);
522
570
  (0, ui_js_1.newline)();
523
571
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
524
- console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff`);
525
- console.log(` ${(0, ui_js_1.cyan)('--step, -n')} Number of migrations to apply/rollback`);
526
- console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
572
+ console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff`);
573
+ console.log(` ${(0, ui_js_1.cyan)('--step, -n')} Number of migrations to apply/rollback`);
574
+ console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
575
+ console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation on ${(0, ui_js_1.cyan)('migrate up')} ${(0, ui_js_1.dim)('(advanced)')}`);
527
576
  (0, ui_js_1.newline)();
528
577
  console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
529
578
  console.log(` ${(0, ui_js_1.dim)('npx turbine migrate create add_users_table')}`);
@@ -639,9 +688,18 @@ async function cmdMigrateUp(args, config) {
639
688
  (0, ui_js_1.newline)();
640
689
  return;
641
690
  }
691
+ // Big, loud warning when bypassing drift detection — this is a deliberately
692
+ // dangerous operation and the user should see it on every invocation.
693
+ if (args.allowDrift) {
694
+ (0, ui_js_1.warn)('--allow-drift is set — checksum validation is DISABLED for this run.');
695
+ console.log(` ${(0, ui_js_1.dim)('Applied migrations may have been modified or deleted on disk.')}`);
696
+ console.log(` ${(0, ui_js_1.dim)('Proceed only if you are intentionally rewriting migration history.')}`);
697
+ (0, ui_js_1.newline)();
698
+ }
642
699
  const spinner = new ui_js_1.Spinner('Applying migrations').start();
643
700
  const result = await (0, migrate_js_1.migrateUp)(url, config.migrationsDir, {
644
701
  step: args.step,
702
+ allowDrift: args.allowDrift,
645
703
  });
646
704
  if (result.applied.length === 0 && result.errors.length === 0) {
647
705
  spinner.succeed('All migrations are up to date');
@@ -970,6 +1028,7 @@ function showMigrateHelp() {
970
1028
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
971
1029
  console.log(` ${(0, ui_js_1.cyan)('--step, -n')} ${(0, ui_js_1.dim)('<N>')} Number of migrations to apply/rollback`);
972
1030
  console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
1031
+ console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation ${(0, ui_js_1.dim)('(migrate up only — advanced)')}`);
973
1032
  console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
974
1033
  (0, ui_js_1.newline)();
975
1034
  console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
@@ -1078,6 +1137,16 @@ async function main() {
1078
1137
  showVersion();
1079
1138
  return;
1080
1139
  }
1140
+ // If the user has a TypeScript config file, register the tsx ESM loader
1141
+ // before we attempt to import it. Otherwise Node throws
1142
+ // ERR_UNKNOWN_FILE_EXTENSION for `.ts`.
1143
+ const configPath = (0, config_js_1.findConfigFile)();
1144
+ if ((0, loader_js_1.needsTsLoader)(configPath)) {
1145
+ const status = await (0, loader_js_1.registerTsLoader)();
1146
+ if (status === 'missing' || status === 'unsupported') {
1147
+ failMissingTsLoader(configPath ?? 'turbine.config.ts', status);
1148
+ }
1149
+ }
1081
1150
  // Load config file
1082
1151
  let fileConfig = {};
1083
1152
  try {
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm CLI — TypeScript loader registration
4
+ *
5
+ * The CLI loads user-supplied config and schema files via dynamic `import()`.
6
+ * Plain Node has no built-in `.ts` loader, so importing `turbine.config.ts`
7
+ * blows up with `ERR_UNKNOWN_FILE_EXTENSION` unless we register a TypeScript
8
+ * loader first.
9
+ *
10
+ * Strategy:
11
+ * 1. If the file we're about to import ends in `.ts` / `.mts` / `.cts`,
12
+ * probe whether `tsx/esm` is resolvable from the user's CWD.
13
+ * 2. If yes, call `module.register('tsx/esm', ...)` ONCE per process.
14
+ * 3. If no, surface an actionable error telling the user to install `tsx`.
15
+ *
16
+ * `tsx` is intentionally NOT a runtime dependency — many projects already
17
+ * have it, and adding a heavy dev tool to a 1-dependency ORM would be silly.
18
+ */
19
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ var desc = Object.getOwnPropertyDescriptor(m, k);
22
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
23
+ desc = { enumerable: true, get: function() { return m[k]; } };
24
+ }
25
+ Object.defineProperty(o, k2, desc);
26
+ }) : (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ o[k2] = m[k];
29
+ }));
30
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
31
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
32
+ }) : function(o, v) {
33
+ o["default"] = v;
34
+ });
35
+ var __importStar = (this && this.__importStar) || (function () {
36
+ var ownKeys = function(o) {
37
+ ownKeys = Object.getOwnPropertyNames || function (o) {
38
+ var ar = [];
39
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
40
+ return ar;
41
+ };
42
+ return ownKeys(o);
43
+ };
44
+ return function (mod) {
45
+ if (mod && mod.__esModule) return mod;
46
+ var result = {};
47
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
48
+ __setModuleDefault(result, mod);
49
+ return result;
50
+ };
51
+ })();
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.needsTsLoader = needsTsLoader;
54
+ exports.canResolveTsx = canResolveTsx;
55
+ exports.registerTsLoader = registerTsLoader;
56
+ exports._resetTsLoaderStateForTests = _resetTsLoaderStateForTests;
57
+ const node_module_1 = require("node:module");
58
+ const node_url_1 = require("node:url");
59
+ /**
60
+ * Detect whether a config / schema file path needs the tsx ESM loader.
61
+ * Returns true for `.ts`, `.mts`, and `.cts` files; false for `.js`, `.mjs`,
62
+ * `.cjs`, `.json`, missing paths, or anything else.
63
+ */
64
+ function needsTsLoader(filePath) {
65
+ if (!filePath)
66
+ return false;
67
+ return /\.(ts|mts|cts)$/i.test(filePath);
68
+ }
69
+ /**
70
+ * Probe whether `tsx/esm` is resolvable from the user's current working
71
+ * directory. Returns true if `tsx` is installed in the user's project.
72
+ *
73
+ * Accepts an injected `resolver` so unit tests don't need a real filesystem.
74
+ */
75
+ function canResolveTsx(resolver) {
76
+ try {
77
+ if (resolver) {
78
+ resolver('tsx/esm');
79
+ return true;
80
+ }
81
+ // Probe relative to the user's CWD, not Turbine's install location.
82
+ // This way we honour whatever `tsx` version the user has pinned.
83
+ const userRequire = (0, node_module_1.createRequire)(`${process.cwd()}/`);
84
+ userRequire.resolve('tsx/esm');
85
+ return true;
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ let tsLoaderState = null;
92
+ /**
93
+ * Register the tsx ESM loader so subsequent dynamic imports of `.ts` files
94
+ * work. Safe to call multiple times — internal flag prevents double registration.
95
+ *
96
+ * Returns:
97
+ * - 'registered' loader was successfully registered this call
98
+ * - 'already' a loader was previously registered (idempotent)
99
+ * - 'unsupported' Node lacks `module.register()` (Node < 20.6)
100
+ * - 'missing' `tsx` is not installed in the user's project
101
+ */
102
+ async function registerTsLoader() {
103
+ if (tsLoaderState === 'registered' || tsLoaderState === 'already') {
104
+ return 'already';
105
+ }
106
+ if (!canResolveTsx()) {
107
+ tsLoaderState = 'missing';
108
+ return 'missing';
109
+ }
110
+ try {
111
+ const mod = await Promise.resolve().then(() => __importStar(require('node:module')));
112
+ const register = mod.register;
113
+ if (typeof register !== 'function') {
114
+ tsLoaderState = 'unsupported';
115
+ return 'unsupported';
116
+ }
117
+ register('tsx/esm', (0, node_url_1.pathToFileURL)(`${process.cwd()}/`));
118
+ tsLoaderState = 'registered';
119
+ return 'registered';
120
+ }
121
+ catch {
122
+ tsLoaderState = 'missing';
123
+ return 'missing';
124
+ }
125
+ }
126
+ /** Reset the loader state — used by unit tests only. */
127
+ function _resetTsLoaderStateForTests() {
128
+ tsLoaderState = null;
129
+ }
@@ -276,12 +276,19 @@ async function validateChecksums(client, migrationsDir) {
276
276
  * Features:
277
277
  * - Idempotent: running twice is safe (already-applied migrations are skipped)
278
278
  * - Advisory lock: prevents concurrent migration runs
279
- * - Checksum validation: detects modified migration files
279
+ * - Checksum validation: detects modified migration files (BLOCKING — use
280
+ * `allowDrift: true` to bypass when intentionally rewriting history)
280
281
  * - Each migration runs in its own transaction
282
+ *
283
+ * Throws `MigrationError` if any applied migration has been modified or deleted
284
+ * on disk, listing the offending files. Pass `{ allowDrift: true }` to bypass
285
+ * this check (the CLI exposes this as `--allow-drift`).
281
286
  */
282
287
  async function migrateUp(connectionString, migrationsDir, options) {
283
288
  const client = new pg_1.default.Client({ connectionString });
284
289
  await client.connect();
290
+ // Treat `force` as an alias for `allowDrift` for backwards compatibility.
291
+ const allowDrift = options?.allowDrift === true || options?.force === true;
285
292
  try {
286
293
  // Acquire advisory lock to prevent concurrent migrations
287
294
  const gotLock = await acquireLock(client);
@@ -290,18 +297,35 @@ async function migrateUp(connectionString, migrationsDir, options) {
290
297
  }
291
298
  try {
292
299
  await ensureTrackingTable(client);
293
- // Validate checksums of already-applied migrations (skip with --force)
294
- if (!options?.force) {
300
+ // Validate checksums of already-applied migrations.
301
+ // Drift = an APPLIED migration's on-disk file has changed (or been deleted)
302
+ // since it was run. Either situation means the database state and the
303
+ // migration history no longer agree, so we BLOCK the run by default.
304
+ // Users can pass `allowDrift: true` (CLI: `--allow-drift`) to force past
305
+ // the block when they are intentionally rewriting history.
306
+ if (!allowDrift) {
295
307
  const mismatches = await validateChecksums(client, migrationsDir);
296
308
  if (mismatches.length > 0) {
297
309
  const modified = mismatches.filter((m) => m.type === 'modified');
298
310
  const missing = mismatches.filter((m) => m.type === 'missing');
299
- const parts = [];
300
- if (modified.length > 0)
301
- parts.push(`modified: ${modified.map((m) => m.name).join(', ')}`);
302
- if (missing.length > 0)
303
- parts.push(`deleted: ${missing.map((m) => m.name).join(', ')}`);
304
- throw new errors_js_1.MigrationError(`[turbine] Migration integrity check failed — ${parts.join('; ')}. Applied migrations should be immutable. Use --force to skip this check.`);
311
+ const lines = [
312
+ '[turbine] Migration drift detected — refusing to apply pending migrations.',
313
+ '',
314
+ 'Applied migrations should be immutable. The following files no longer match their applied state:',
315
+ '',
316
+ ];
317
+ for (const m of modified) {
318
+ lines.push(` - ${m.name}.sql (modified on disk)`);
319
+ }
320
+ for (const m of missing) {
321
+ lines.push(` - ${m.name}.sql (deleted from disk)`);
322
+ }
323
+ lines.push('');
324
+ lines.push('Fix one of these:');
325
+ lines.push(' 1. Restore the file(s) to their original content, OR');
326
+ lines.push(' 2. Roll back the affected migrations with `npx turbine migrate down`, OR');
327
+ lines.push(' 3. Pass `--allow-drift` to bypass this check (advanced — make sure you know what you are doing).');
328
+ throw new errors_js_1.MigrationError(lines.join('\n'));
305
329
  }
306
330
  }
307
331
  const applied = await getAppliedMigrations(client);
@@ -195,6 +195,11 @@ class TurbineClient {
195
195
  defaultLimit: config.defaultLimit,
196
196
  warnOnUnlimited: config.warnOnUnlimited,
197
197
  };
198
+ // Apply NotFoundError message redaction mode (default: safe — values are
199
+ // stripped from messages to avoid leaking PII into error logs).
200
+ if (config.errorMessages) {
201
+ (0, errors_js_1.setErrorMessageMode)(config.errorMessages);
202
+ }
198
203
  if (config.pool) {
199
204
  // External pool — use directly. Turbine doesn't manage its lifecycle.
200
205
  this.pool = config.pool;
@@ -399,6 +404,24 @@ class TurbineClient {
399
404
  async $transaction(fn, options) {
400
405
  const client = await this.pool.connect();
401
406
  const timeout = options?.timeout;
407
+ /**
408
+ * Track whether the connection has already been released so the finally
409
+ * block doesn't double-release. When a timeout fires we destroy the
410
+ * connection eagerly to abort the in-flight backend query.
411
+ */
412
+ let released = false;
413
+ const releaseOnce = (err) => {
414
+ if (released)
415
+ return;
416
+ released = true;
417
+ try {
418
+ client.release(err);
419
+ }
420
+ catch {
421
+ // pg may throw if the client is already released — swallow.
422
+ }
423
+ };
424
+ let timedOut = false;
402
425
  try {
403
426
  // BEGIN with optional isolation level
404
427
  let beginSQL = 'BEGIN';
@@ -422,10 +445,22 @@ class TurbineClient {
422
445
  }
423
446
  let result;
424
447
  if (timeout) {
425
- // Race between the function and a timeout
448
+ // Race between the function and a timeout. If the timeout fires we
449
+ // need to actually abort the in-flight query — otherwise the backend
450
+ // keeps running until pg's own timeout, holding a pool slot the whole
451
+ // time. The simplest reliable cancellation is to destroy the
452
+ // connection: passing a truthy argument to client.release() tells the
453
+ // pg pool to discard the client (its socket is closed, which causes
454
+ // Postgres to abort the active query and roll back the transaction).
455
+ // The pool will spin up a fresh connection on the next checkout.
426
456
  let timer;
427
457
  const timeoutPromise = new Promise((_, reject) => {
428
458
  timer = setTimeout(() => {
459
+ timedOut = true;
460
+ // Destroy the connection to abort the in-flight backend query.
461
+ // We do this BEFORE rejecting so the socket is gone by the time
462
+ // the caller's catch block runs.
463
+ releaseOnce(new Error('[turbine] Transaction timeout — connection destroyed'));
429
464
  reject(new errors_js_1.TimeoutError(timeout, 'Transaction'));
430
465
  }, timeout);
431
466
  });
@@ -446,14 +481,25 @@ class TurbineClient {
446
481
  return result;
447
482
  }
448
483
  catch (err) {
449
- await client.query('ROLLBACK');
484
+ // If the timeout fired we already destroyed the connection — issuing a
485
+ // ROLLBACK on a released client would throw "Client has already been
486
+ // released". Skip the rollback in that case (the backend rolled back
487
+ // when its socket was closed).
488
+ if (!timedOut && !released) {
489
+ try {
490
+ await client.query('ROLLBACK');
491
+ }
492
+ catch {
493
+ // Best-effort rollback — the connection may have died mid-query.
494
+ }
495
+ }
450
496
  if (this.logging) {
451
497
  console.log('[turbine] Transaction rolled back');
452
498
  }
453
499
  throw err;
454
500
  }
455
501
  finally {
456
- client.release();
502
+ releaseOnce();
457
503
  }
458
504
  }
459
505
  // -------------------------------------------------------------------------