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 +134 -40
- package/dist/cjs/cli/index.js +72 -3
- package/dist/cjs/cli/loader.js +129 -0
- package/dist/cjs/cli/migrate.js +33 -9
- package/dist/cjs/client.js +92 -8
- package/dist/cjs/errors.js +177 -4
- package/dist/cjs/generate.js +120 -9
- package/dist/cjs/index.js +7 -1
- package/dist/cjs/pipeline-submittable.js +403 -0
- package/dist/cjs/pipeline.js +90 -37
- package/dist/cjs/query.js +943 -137
- package/dist/cjs/schema-builder.js +57 -6
- package/dist/cjs/schema-sql.js +85 -19
- package/dist/cjs/serverless.js +8 -7
- package/dist/cli/index.js +72 -3
- package/dist/cli/loader.d.ts +45 -0
- package/dist/cli/loader.js +91 -0
- package/dist/cli/migrate.d.ts +7 -1
- package/dist/cli/migrate.js +33 -9
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +47 -3
- package/dist/client.js +94 -10
- package/dist/errors.d.ts +132 -1
- package/dist/errors.js +171 -3
- package/dist/generate.d.ts +6 -0
- package/dist/generate.js +120 -10
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/pipeline-submittable.d.ts +94 -0
- package/dist/pipeline-submittable.js +397 -0
- package/dist/pipeline.d.ts +37 -9
- package/dist/pipeline.js +89 -37
- package/dist/query.d.ts +268 -17
- package/dist/query.js +941 -137
- package/dist/schema-builder.d.ts +36 -3
- package/dist/schema-builder.js +57 -6
- package/dist/schema-sql.js +85 -19
- package/dist/serverless.d.ts +8 -7
- package/dist/serverless.js +8 -7
- package/package.json +3 -3
package/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.
|
|
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
|
|
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
|
|
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
|
-
|
|
|
22
|
-
|
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
26
|
-
|
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.**
|
|
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
|
|
package/dist/cjs/cli/index.js
CHANGED
|
@@ -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')}
|
|
525
|
-
console.log(` ${(0, ui_js_1.cyan)('--step, -n')}
|
|
526
|
-
console.log(` ${(0, ui_js_1.cyan)('--dry-run')}
|
|
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
|
+
}
|
package/dist/cjs/cli/migrate.js
CHANGED
|
@@ -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
|
|
294
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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);
|