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.
package/README.md CHANGED
@@ -8,34 +8,35 @@ npm install turbine-orm
8
8
 
9
9
  ## Why Turbine?
10
10
 
11
- Turbine is a PostgreSQL-native TypeScript ORM with features no other ORM offers together: **cursor-based streaming** through nested relations, **typed error classes** with PostgreSQL constraint mapping, **pipeline batching** (N queries, 1 round-trip), **middleware**, and a driver-agnostic core that plugs into any pg-compatible pool so it runs on Vercel Edge, Cloudflare Workers, Deno Deploy, and similar environments. It resolves nested relations in a single SQL query using `json_agg` — an approach now shared by Prisma 7+ and Drizzle v2, but Turbine does it with 1 runtime dependency (`pg`) and ~110KB on npm.
11
+ Turbine is a PostgreSQL-native TypeScript ORM with features no other ORM offers together: **deep typed `with` inference** (`users[0].posts[0].comments[0].author` autocompletes after one `findMany`), **cursor-based streaming** through nested relations, **typed error classes** with PostgreSQL constraint mapping, **pipeline batching** (N queries, 1 round-trip), **middleware**, and a driver-agnostic core that plugs into any pg-compatible pool so it runs on Vercel Edge, Cloudflare Workers, Deno Deploy, and similar environments. 1 runtime dependency (`pg`), ~110KB on npm.
12
12
 
13
- **One query for nested relations.** When you write `db.users.findMany({ with: { posts: { with: { comments: true } } } })`, Turbine generates a single SQL statement using correlated subqueries with `json_agg`. Modern ORMs like Prisma 7+ and Drizzle v2 use similar single-query approaches (LATERAL JOINs). Turbine's advantage is architectural simplicity: 1 dependency, no code generation DSL, and PostgreSQL-native depth.
13
+ **One round-trip for nested relations.** `db.users.findMany({ with: { posts: { with: { comments: true } } } })` resolves the entire object graph in a single database round-trip, regardless of nesting depth. Prisma 7+ and Drizzle v2 also do single-query nested loads Turbine's advantage is architectural simplicity: 1 dependency, no code generation DSL, no query plan compiler.
14
14
 
15
15
  ## Benchmarks
16
16
 
17
- Tested against **Prisma 7.6** (adapter-pg, relationJoins) and **Drizzle 0.45** (relational queries) on the same PostgreSQL database. 200 iterations, 20 warmup, Node v22.
17
+ Tested against **Prisma 7.6** (adapter-pg, relationJoins preview on) and **Drizzle 0.45** (relational queries) on a **Neon** PostgreSQL database (pooled endpoint, US-East, PostgreSQL 17.8). 100 iterations, 20 warmup, Node v22. Same schema, same data (1K users, 10K posts, 50K comments), same connection pool config.
18
18
 
19
19
  | Scenario | Turbine | Prisma 7 | Drizzle v2 |
20
20
  |---|---|---|---|
21
- | **findMany — 100 rows (flat)** | **0.39 ms** | 0.58 ms | 0.44 ms |
22
- | **findMany — L2 nested (users + posts)** | **1.29 ms** | 1.84 ms | 1.30 ms |
23
- | **findMany — L3 nested (users → posts → comments)** | **0.50 ms** | 0.91 ms | 0.69 ms |
24
- | **findUnique by PK** | **0.08 ms** | 0.13 ms | 0.14 ms |
25
- | **findUnique — L3 nested** | **0.18 ms** | 0.32 ms | 0.34 ms |
26
- | **count** | **0.06 ms** | 0.10 ms | 0.08 ms |
21
+ | findMany — 100 users (flat) | **51.97 ms** | 52.90 ms | 53.51 ms |
22
+ | findMany — 50 users + posts (L2) | **55.84 ms** | 56.10 ms | 88.80 ms |
23
+ | findMany — 10 users → posts → comments (L3) | 52.77 ms | 59.35 ms | **52.38 ms** |
24
+ | findUnique — single user by PK | **47.66 ms** | 52.15 ms | 47.78 ms |
25
+ | findUnique — user + posts + comments (L3) | **51.71 ms** | 54.42 ms | 52.47 ms |
26
+ | count — all users | **44.57 ms** | 47.54 ms | 46.75 ms |
27
+ | stream — iterate 50K rows (batch 1000) | 3,207 ms | **3,099 ms** | 4,620 ms |
28
+ | atomic increment — `view_count + 1` | 49.76 ms | 49.09 ms | **46.25 ms** |
29
+ | pipeline — 5-query batch | 318 ms | 327 ms | **316 ms** |
27
30
 
28
- | Scenario | Turbine | Prisma 7 | Drizzle v2 |
29
- |---|---|---|---|
30
- | findMany flat | **1.00x** | 1.51x | 1.15x |
31
- | findMany L2 nested | **1.00x** | 1.43x | 1.01x |
32
- | findManyL3 nested | **1.00x** | 1.81x | 1.38x |
33
- | findUnique by PK | **1.00x** | 1.67x | 1.69x |
34
- | findUnique — L3 nested | **1.00x** | 1.81x | 1.93x |
35
- | count | **1.00x** | 1.70x | 1.38x |
31
+ **Against a real pooled database, most single-query scenarios are within noise** — network round-trip to Neon is ~33–40 ms, which swamps per-query CPU overhead. But a few results stand out:
32
+
33
+ - **L2 nested reads.** Turbine and Prisma are neck-and-neck (~56 ms), while Drizzle is **1.59× slower** (89 ms) on the 50-user + posts scenario. Turbine's `json_agg` approach and SQL template caching pay off here.
34
+ - **Streaming 50K rows.** Turbine's optimized streaming (speculative first fetch + batch size 1000) matches Prisma at ~3.1–3.2 s. Drizzle's keyset pagination is 1.49× slower at 4.6 s. Turbine's cursor still gives you correctness on any `orderBy` and clean early-`break` semantics.
35
+ - **Pipeline batching** puts 5 independent queries through a single round-trip using the Postgres extended-query pipeline protocol all three ORMs are tied here since each runs 5 queries sequentially in a transaction.
36
36
 
37
- Turbine is fastest in every scenario. The advantage is largest on deep nesting (1.8x vs Prisma, up to 1.9x vs Drizzle) and single-record lookups (1.7x). All three ORMs now use single-query approaches for nested relations Turbine's advantage comes from lower per-query overhead (minimal JS object allocation, no query plan compilation layer, direct pg driver access).
37
+ Beyond the numbers, Turbine's real strengths are: **one runtime dependency** (`pg`, ~110 KB), a **single import swap** for edge runtimes (`turbine-orm/serverless`), **typed Postgres errors** with a `readonly isRetryable` const for retry loops, and **inferred `with` result types** `users[0].posts[0].comments[0].author.name` autocompletes from a single `findMany` with no manual assertion.
38
38
 
39
+ > Full analysis with p50/p95/p99 and methodology notes: [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md).
39
40
  > Reproduce: `cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench.ts`
40
41
 
41
42
  ## Quick Start
@@ -147,6 +148,30 @@ const deleted = await db.users.delete({
147
148
  });
148
149
  ```
149
150
 
151
+ ### Atomic update operators
152
+
153
+ 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.
154
+
155
+ ```typescript
156
+ // Atomic increment — no read-modify-write race
157
+ await db.posts.update({
158
+ where: { id: 1 },
159
+ data: { viewCount: { increment: 1 } },
160
+ });
161
+
162
+ // Other supported operators on numeric columns
163
+ await db.posts.update({
164
+ where: { id: 1 },
165
+ data: {
166
+ viewCount: { increment: 5 },
167
+ likesCount: { decrement: 1 },
168
+ score: { multiply: 2 },
169
+ rank: { divide: 2 },
170
+ title: { set: 'New title' }, // explicit set, equivalent to a literal
171
+ },
172
+ });
173
+ ```
174
+
150
175
  ### Transactions
151
176
 
152
177
  ```typescript
@@ -199,7 +224,7 @@ const users = await db.users.findMany({
199
224
  // Stream rows using PostgreSQL cursors — constant memory, no matter how many rows
200
225
  for await (const user of db.users.findManyStream({
201
226
  where: { orgId: 1 },
202
- batchSize: 500, // internal FETCH batch size (default: 100)
227
+ batchSize: 500, // internal FETCH batch size (default: 1000)
203
228
  orderBy: { id: 'asc' },
204
229
  with: { posts: true }, // nested relations work too
205
230
  })) {
@@ -271,7 +296,74 @@ try {
271
296
  }
272
297
  ```
273
298
 
274
- Error codes: `TURBINE_E001` (NotFound), `TURBINE_E002` (Timeout), `TURBINE_E003` (Validation), `TURBINE_E004` (Connection), `TURBINE_E005` (Relation), `TURBINE_E006` (Migration), `TURBINE_E007` (CircularRelation).
299
+ Error codes: `TURBINE_E001` (NotFound), `TURBINE_E002` (Timeout), `TURBINE_E003` (Validation), `TURBINE_E004` (Connection), `TURBINE_E005` (Relation), `TURBINE_E006` (Migration), `TURBINE_E007` (CircularRelation), `TURBINE_E008` (UniqueConstraint), `TURBINE_E009` (ForeignKey), `TURBINE_E010` (NotNullViolation), `TURBINE_E011` (CheckConstraint), `TURBINE_E012` (Deadlock), `TURBINE_E013` (SerializationFailure), `TURBINE_E014` (Pipeline).
300
+
301
+ ## WHERE Operator Reference
302
+
303
+ Every operator supported by the `where` clause. Operators compose freely with `AND`, `OR`, `NOT`, and the relation filters `some` / `every` / `none`.
304
+
305
+ ### Equality
306
+
307
+ | Operator | Description | Example |
308
+ |---|---|---|
309
+ | literal | Implicit equality | `where: { email: 'a@b.com' }` |
310
+ | `equals` | Explicit equality | `where: { email: { equals: 'a@b.com' } }` |
311
+ | `not` | Inequality (or `not: null` for `IS NOT NULL`) | `where: { role: { not: 'admin' } }` |
312
+
313
+ ### Sets
314
+
315
+ | Operator | Description | Example |
316
+ |---|---|---|
317
+ | `in` | Match any value in the array | `where: { id: { in: [1, 2, 3] } }` |
318
+ | `notIn` | Match none of the values in the array | `where: { role: { notIn: ['banned', 'spam'] } }` |
319
+
320
+ ### Comparison
321
+
322
+ | Operator | Description | Example |
323
+ |---|---|---|
324
+ | `gt` | Greater than | `where: { score: { gt: 100 } }` |
325
+ | `gte` | Greater than or equal | `where: { score: { gte: 100 } }` |
326
+ | `lt` | Less than | `where: { score: { lt: 100 } }` |
327
+ | `lte` | Less than or equal | `where: { score: { lte: 100 } }` |
328
+
329
+ ### String
330
+
331
+ | Operator | Description | Example |
332
+ |---|---|---|
333
+ | `contains` | Substring match (`LIKE %v%`) | `where: { title: { contains: 'sql' } }` |
334
+ | `startsWith` | Prefix match (`LIKE v%`) | `where: { email: { startsWith: 'admin@' } }` |
335
+ | `endsWith` | Suffix match (`LIKE %v`) | `where: { email: { endsWith: '@acme.com' } }` |
336
+ | `mode: 'insensitive'` | Switch any string operator to `ILIKE` | `where: { title: { contains: 'SQL', mode: 'insensitive' } }` |
337
+
338
+ LIKE wildcards in user input are escaped automatically — `%`, `_`, and `\` are treated as literals.
339
+
340
+ ### Relation filters
341
+
342
+ Filter parent rows by predicates against their related child rows. Available on `hasMany` and `hasOne` relations.
343
+
344
+ | Operator | Description | Example |
345
+ |---|---|---|
346
+ | `some` | At least one related row matches | `where: { posts: { some: { published: true } } }` |
347
+ | `every` | Every related row matches | `where: { posts: { every: { published: true } } }` |
348
+ | `none` | No related row matches | `where: { posts: { none: { published: false } } }` |
349
+
350
+ ### Array columns
351
+
352
+ Operators for Postgres array columns (`text[]`, `int[]`, etc.).
353
+
354
+ | Operator | Description | Example |
355
+ |---|---|---|
356
+ | `has` | Array contains the given element | `where: { tags: { has: 'sql' } }` |
357
+ | `hasEvery` | Array contains every element in the list | `where: { tags: { hasEvery: ['sql', 'postgres'] } }` |
358
+ | `hasSome` | Array contains at least one element from the list | `where: { tags: { hasSome: ['sql', 'mysql'] } }` |
359
+
360
+ ### Combinators
361
+
362
+ | Operator | Description | Example |
363
+ |---|---|---|
364
+ | `AND` | All sub-clauses must match | `where: { AND: [{ orgId: 1 }, { role: 'admin' }] }` |
365
+ | `OR` | Any sub-clause matches | `where: { OR: [{ role: 'admin' }, { role: 'owner' }] }` |
366
+ | `NOT` | Negate a sub-clause | `where: { NOT: { role: 'banned' } }` |
275
367
 
276
368
  ## CLI
277
369
 
@@ -441,22 +533,9 @@ Priority order: CLI flags > environment variables (`DATABASE_URL`) > config file
441
533
 
442
534
  ## How It Works
443
535
 
444
- Turbine generates a single SQL query using Postgres `json_agg` + subqueries to fetch nested relations:
445
-
446
- ```sql
447
- -- db.users.findMany({ where: { orgId: 1 }, with: { posts: { with: { comments: true } } } })
448
- SELECT u.*,
449
- (SELECT COALESCE(json_agg(sub), '[]'::json) FROM (
450
- SELECT p.*,
451
- (SELECT COALESCE(json_agg(sub2), '[]'::json) FROM (
452
- SELECT c.* FROM comments c WHERE c.post_id = p.id
453
- ) sub2) AS comments
454
- FROM posts p WHERE p.user_id = u.id
455
- ) sub) AS posts
456
- FROM users u WHERE u.org_id = 1
457
- ```
536
+ Turbine resolves the entire object graph in a single database round-trip, regardless of nesting depth. The `with` clause is fully type-inferred end-to-end `users[0].posts[0].comments[0].author.name` autocompletes from a single `findMany` call, with no manual type assertions.
458
537
 
459
- This resolves the entire 3-level object graph in one database round-trip. Prisma 7+ and Drizzle v2 also use single-query approaches (LATERAL JOINs), but Turbine's correlated subquery strategy has lower per-query overhead see [Benchmarks](#benchmarks).
538
+ Prisma 7+ and Drizzle v2 also do single-query nested loads. Turbine's advantage isn't query latency (see [Benchmarks](#benchmarks) — all three are within noise over a real pooled database); it's architectural simplicity. One runtime dependency (`pg`), no DSL compiler, no driver adapter shim for edge, and deep `with` type inference without verbose helper types.
460
539
 
461
540
  ## Type Mapping
462
541
 
@@ -478,28 +557,43 @@ Turbine maps Postgres types to TypeScript:
478
557
 
479
558
  | | **Turbine** | **Prisma** | **Drizzle** | **Kysely** |
480
559
  |---|---|---|---|---|
481
- | **Nested relations** | 1 query (`json_agg`) | 1 query (LATERAL JOIN + json_agg, since v5.8) | 1 query (LATERAL JOINs) | Manual (`jsonArrayFrom`) |
560
+ | **Nested relations** | 1 query, deep type inference | 1 query (since v5.8), shallow inference | 1 query, requires `relations()` re-declaration | Manual (`jsonArrayFrom`) |
482
561
  | **API style** | `findMany`, `with` | `findMany`, `include` | SQL-like + relational | SQL builder |
483
562
  | **Schema** | TypeScript | Custom DSL (`.prisma`) | TypeScript | Manual interfaces |
484
563
  | **Runtime deps** | 1 (`pg`) | `@prisma/client` + adapter | 0 | 0 |
485
564
  | **Multi-DB** | PostgreSQL only | PG, MySQL, SQLite, MSSQL | PG, MySQL, SQLite | PG, MySQL, SQLite |
486
565
  | **Code generation** | `turbine generate` | `prisma generate` | Not needed | Not needed |
487
566
 
488
- All three ORMs now use single-query approaches for nested relations. Turbine uses correlated subqueries with `json_agg`, Prisma 7 uses LATERAL JOIN + `json_agg`, and Drizzle uses LATERAL JOINs. Turbine is 1.4–1.9x faster due to lower per-query overhead minimal JS object allocation, no query plan compilation layer, and direct pg driver access. See [Benchmarks](#benchmarks) for full results.
567
+ All three ORMs now do single-query nested loads. Over a real pooled database (Neon, US-East) most single-query scenarios land within noise — but Turbine's SQL template caching and prepared statements give it a consistent edge, particularly on L2 nested reads (1.59× faster than Drizzle) and streaming (at parity with Prisma, 1.49× faster than Drizzle). Turbine's differentiators are both architectural and performance: one runtime dependency, one import swap for edge, typed errors with `isRetryable`, deep `with` type inference, and real Postgres pipeline protocol support. See [Benchmarks](#benchmarks) and [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md) for the full breakdown.
489
568
 
490
569
  ## Limitations
491
570
 
492
571
  Turbine is focused and opinionated. Here's what it doesn't do:
493
572
 
494
- - **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.
573
+ - **PostgreSQL only.** No MySQL, SQLite, or MSSQL. By design — going deep on one database enables the performance advantage and the edge-runtime story.
496
574
  - **No full-text search operators.** TSVECTOR/TSQUERY are not exposed in the query builder. Use `db.raw` for full-text queries.
497
- - **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.
575
+ - **Large nested result sets.** Nested results are materialized server-side in PostgreSQL memory. For relations with 10K+ rows, always use `limit` in your `with` clause or stream the parents with `findManyStream` and resolve children per-row.
498
576
  - **No admin UI.** Turbine Studio is planned but not yet available.
499
577
 
500
578
  ## Examples
501
579
 
580
+ **Feature demos**
581
+
582
+ - **[Thread Machine](./examples/thread-machine/)** — HN clone rendered from a single `findMany`. 4-level object graph (stories → comments → replies → author), every property autocompletes through the chain
583
+ - **[Streaming CSV](./examples/streaming-csv/)** — Export 100K orders + line items to CSV with constant memory. PostgreSQL cursors, live heap meter, nested `with` inside `findManyStream`
584
+ - **[Clickstorm](./examples/clickstorm/)** — Side-by-side atomic-increment vs read-modify-write load test. 10K concurrent clicks. The atomic path wins every time
585
+
586
+ **Runtime targets**
587
+
502
588
  - **[Next.js](./examples/nextjs/)** — Server-rendered app with nested relations, streaming, and live code demos
589
+ - **[Neon Edge](./examples/neon-edge/)** — Vercel Edge route handler talking to Neon over HTTP via `@neondatabase/serverless`
590
+ - **[Vercel Postgres](./examples/vercel-postgres/)** — Next.js app router route handler on `@vercel/postgres`
591
+ - **[Cloudflare Worker](./examples/cloudflare-worker/)** — Worker `fetch` handler with `pg` over Cloudflare Hyperdrive
592
+ - **[Supabase](./examples/supabase/)** — Standalone script over the standard `pg` driver against Supabase
593
+
594
+ ## Guides
595
+
596
+ - **[Migrating from Prisma](./docs/migrate-from-prisma.md)** — API mapping table, side-by-side `findMany`, and notes on the differences
503
597
 
504
598
  ## Requirements
505
599
 
@@ -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);