turbine-orm 0.19.0 → 0.19.2

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
@@ -12,8 +12,8 @@ npm install turbine-orm
12
12
 
13
13
  Prisma ships a 1.6 MB WASM query engine. Drizzle ships zero runtime but no Studio, no typed errors, no migration checksums. Turbine ships **one dependency (`pg`) and no engine binary**, and bundles six things no other TS ORM has together:
14
14
 
15
- 1. **One runtime dependency (`pg`).** No engine binary, no WASM adapter, no adapter packages to keep in lockstep. The main entry bundles to ~30 KB gzipped (~109 KB minified); the edge entry to ~21 KB gzipped. Prisma's WASM query engine alone is 1.6 MB.
16
- 2. **Built-in read-only Studio.** `npx turbine studio` spins up a loopback-bound web UI with 192-bit auth tokens, `BEGIN READ ONLY` transactions, and a statement-stacking guard. The only TS ORM Studio that physically cannot mutate your database. DBA-approvable.
15
+ 1. **One runtime dependency (`pg`).** No engine binary, no WASM adapter, no adapter packages to keep in lockstep. The main entry bundles to ~31 kB brotli (~109 KB minified); the edge entry to ~22 kB brotli. Prisma's WASM query engine alone is 1.6 MB.
16
+ 2. **Built-in read-only Studio.** `npx turbine studio` spins up a loopback-bound web UI with 192-bit auth tokens, `BEGIN READ ONLY` transactions, and since v0.19 — no raw-SQL surface at all: queries are composed in the ORM's own validated builder. The only TS ORM Studio that physically cannot mutate your database. DBA-approvable.
17
17
  3. **PII-safe error messages.** Turbine errors show WHERE keys, not values. A `UniqueConstraintError` says which column violated the constraint — never the actual user data. Safe to log, safe to surface to monitoring, no scrubbing needed.
18
18
  4. **SQL-first migrations with drift detection.** Write real SQL. SHA-256 checksums catch modified migration files. `pg_try_advisory_lock()` prevents concurrent runs. Each migration in its own transaction. No shadow database, no magic DSL.
19
19
  5. **Edge-native — one import swap.** `turbineHttp(pool, schema)` — same API on Neon, Vercel Postgres, Cloudflare Hyperdrive, Supabase. No WASM bundle, no adapter package, no separate serverless build.
@@ -343,6 +343,8 @@ const db = turbine({
343
343
 
344
344
  ### Middleware
345
345
 
346
+ Middleware wraps every query. It runs **after SQL generation**, so it can observe what's about to execute (`params.model`, `params.action`, `params.args`), measure timing, and transform the result returned by `next()` — but it cannot change the query itself.
347
+
346
348
  ```typescript
347
349
  // Query timing
348
350
  db.$use(async (params, next) => {
@@ -352,15 +354,33 @@ db.$use(async (params, next) => {
352
354
  return result;
353
355
  });
354
356
 
355
- // Soft-delete filter
357
+ // Result transformation — redact a field on the way out
356
358
  db.$use(async (params, next) => {
357
- if (params.action === 'findMany' || params.action === 'findUnique') {
358
- params.args.where = { ...params.args.where, deletedAt: null };
359
+ const result = await next(params);
360
+ if (params.model === 'users' && Array.isArray(result)) {
361
+ for (const row of result as { email?: string }[]) row.email = '[redacted]';
359
362
  }
360
- return next(params);
363
+ return result;
361
364
  });
362
365
  ```
363
366
 
367
+ > **Warning:** `params.args` is a read-only snapshot — mutating it does not change the executed SQL. The query is fully built and parameterized before middleware runs.
368
+
369
+ Because middleware can't rewrite queries, cross-cutting filters like **soft deletes** belong in the query itself — either explicitly or via a small scoped helper:
370
+
371
+ ```typescript
372
+ import type { WhereClause } from 'turbine-orm';
373
+
374
+ // Explicit filter
375
+ const users = await db.users.findMany({ where: { deletedAt: null } });
376
+
377
+ // Scoped helper that always applies the filter
378
+ const activeUsers = (where: WhereClause<User> = {}) =>
379
+ db.users.findMany({ where: { ...where, deletedAt: null } });
380
+
381
+ const rows = await activeUsers({ orgId: 1 });
382
+ ```
383
+
364
384
  ### Error handling
365
385
 
366
386
  Turbine throws typed errors you can catch programmatically:
@@ -557,6 +577,7 @@ Commands:
557
577
  seed Run seed file
558
578
  status Show database schema summary
559
579
  studio Launch local read-only Studio web UI
580
+ observe Launch local metrics dashboard (requires TURBINE_OBSERVE_URL)
560
581
 
561
582
  Options:
562
583
  --url, -u <url> Postgres connection string
@@ -614,7 +635,7 @@ npx turbine migrate status
614
635
 
615
636
  ## Studio
616
637
 
617
- The only Postgres ORM with a Studio your DBA will approve. `turbine studio` launches a local, read-only web UI for exploring your database — no mutations, no writes, no way around the transaction guard.
638
+ The only Postgres ORM with a Studio your DBA will approve. `turbine studio` launches a local, read-only web UI for exploring your database — no mutations, no writes, and since v0.19 **no raw-SQL surface at all**: every query is composed visually in the ORM and compiled by the same validated query builder your application uses.
618
639
 
619
640
  ```bash
620
641
  DATABASE_URL=postgres://user:pass@localhost:5432/mydb npx turbine studio
@@ -624,19 +645,55 @@ npx turbine studio --port 5173 --host 127.0.0.1 --no-open
624
645
 
625
646
  **Features**
626
647
 
627
- - **Data / Schema / SQL / Builder tabs.** Browse rows, inspect tables and relations, run ad-hoc `SELECT`s, or compose queries visually with a live TypeScript preview.
628
- - **Saved queries.** Named SQL snippets persisted to `.turbine/studio-queries.json` — share them across runs without committing them.
648
+ - **Query / Data / Schema tabs.** Compose queries visually, browse rows, and inspect tables and relations.
649
+ - **ORM-native query composer.** The Query tab builds a real `findMany` — drill into relations (`with`) to any depth, pick fields (`select`/`omit`), add filters (`where`), `orderBy`, and `limit` at every level with a live TypeScript preview of the exact call to copy into your codebase.
650
+ - **Saved queries.** Named builder queries persisted to `.turbine/studio-queries.json` — share them across runs without committing them.
629
651
  - **Cmd+K command palette.** Jump to any table, tab, or saved query in one keystroke.
630
652
  - **Full-text search across rows.** The Data tab supports substring search across every text column of the current table.
631
- - **Visual query composer.** The Builder tab lets you click together `where` / `orderBy` / `with` / `limit` clauses and renders the matching `db.table.findMany(...)` TypeScript in real time — copy it into your codebase.
632
653
 
633
654
  **Security posture (read-only by design)**
634
655
 
656
+ - **No SQL input surface.** There is nothing to inject into — builder requests are validated identifier-by-identifier against the introspected schema, and every value is bound as a `$N` parameter.
635
657
  - **Loopback by default** (`127.0.0.1`) with a loud warning if you bind to a non-loopback address.
636
658
  - **Per-process auth token** — 24 random bytes of hex, stored in a `SameSite=Strict` `HttpOnly` cookie.
637
- - **Every query runs inside `BEGIN READ ONLY` + `SET LOCAL statement_timeout = '30s'`.** Writes are physically impossible at the transaction level.
638
- - **SELECT/WITH-only SQL parser** strips comments and rejects non-trailing semicolons, blocking statement-stacking attacks.
639
- - **Security headers on every response** — `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`.
659
+ - **Every query runs inside `BEGIN READ ONLY`** with a 30s transaction-local statement timeout (parameterized `set_config`). Writes are physically impossible at the transaction level.
660
+ - **Security headers on every response** CSP, `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` plus per-session rate limiting and cross-origin refusal.
661
+
662
+ ## Observability
663
+
664
+ Built-in query metrics with zero new dependencies. `$observe` buffers per-query timings in memory and flushes **per-minute aggregates** — count, avg, p50, p95, p99, and error count per `model:action` — to a `_turbine_metrics` table in a **separate database**, over its own 1-connection pool so metrics writes never contend with your application pool.
665
+
666
+ ```typescript
667
+ const handle = await db.$observe({
668
+ connectionString: process.env.TURBINE_OBSERVE_URL!, // metrics DB (not your app DB)
669
+ flushIntervalMs: 60_000, // default: 60s
670
+ retentionDays: 30, // default: 30 — older buckets are pruned on flush
671
+ });
672
+
673
+ // Later, to flush remaining metrics and close the metrics pool
674
+ await handle.stop();
675
+ ```
676
+
677
+ `$observe` creates the `_turbine_metrics` table if it doesn't exist. Flushes are fire-and-forget (`INSERT ... ON CONFLICT` additive merge) and never throw into your application. If the `TURBINE_OBSERVE_URL` environment variable is set, the client starts observing automatically on construction — no code needed.
678
+
679
+ For your own instrumentation, subscribe to query events with `$on('query')` — each event carries `sql`, `params`, `duration` (ms), `model`, `action`, `rows`, `timestamp`, and `error` (if the query failed):
680
+
681
+ ```typescript
682
+ db.$on('query', (e) => {
683
+ if (e.duration > 200) {
684
+ console.warn(`slow query: ${e.model}.${e.action} (${e.duration.toFixed(1)}ms, ${e.rows} rows)`);
685
+ }
686
+ });
687
+ ```
688
+
689
+ View the collected metrics in a local dashboard:
690
+
691
+ ```bash
692
+ TURBINE_OBSERVE_URL=postgres://... npx turbine observe
693
+ # Flags: --port (default 4984), --host (default 127.0.0.1), --no-open
694
+ ```
695
+
696
+ Same security model as Studio: loopback binding by default, per-process random auth token in an `HttpOnly` cookie, CSP headers, and read-only access to the metrics table.
640
697
 
641
698
  ## Serverless / Edge
642
699
 
@@ -761,11 +818,11 @@ Turbine maps Postgres types to TypeScript:
761
818
  |---|---|---|---|---|
762
819
  | **Engine / runtime** | No engine binary (`pg` only) | Client + 1.6 MB WASM engine | No engine | No engine |
763
820
  | **Runtime deps** | 1 (`pg`) | `@prisma/client` + adapter | 0 | 0 |
764
- | **Main bundle (gzip)** | ~30 KB | dominated by 1.6 MB WASM | ~7 KB core | small |
821
+ | **Main bundle (brotli)** | ~31 kB | dominated by 1.6 MB WASM | ~7 KB core | small |
765
822
  | **Studio** | Read-only, 192-bit auth | Full CRUD, cloud-hosted | Paid tier | None |
766
823
  | **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
767
824
  | **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
768
- | **Edge runtime** | One import swap, ~21 KB gzip | 1.6 MB WASM adapter | Native | Native |
825
+ | **Edge runtime** | One import swap, ~22 kB brotli | 1.6 MB WASM adapter | Native | Native |
769
826
  | **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
770
827
  | **Typed errors** | `isRetryable` discriminant | Error codes only | None | None |
771
828
  | **Nested relations** | 1 query, deep type inference | 1 query, shallow inference | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) |
@@ -818,6 +875,17 @@ Turbine is focused and opinionated. Here's what it doesn't do:
818
875
  - PostgreSQL >= 14
819
876
  - Works with both ESM (`import`) and CommonJS (`require`)
820
877
 
878
+ ## Contributing
879
+
880
+ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the test strategy, and the PR checklist. The unit suite runs without a database:
881
+
882
+ ```bash
883
+ npm install
884
+ npm run test:unit
885
+ ```
886
+
887
+ Integration tests need a PostgreSQL instance via `DATABASE_URL` (see CONTRIBUTING.md for a one-command seeded setup).
888
+
821
889
  ## License
822
890
 
823
891
  MIT
@@ -55,8 +55,9 @@ export interface DatabaseAdapter {
55
55
  introspectionOverrides?: Partial<IntrospectionOverrides>;
56
56
  /**
57
57
  * Generate the SQL to set a statement timeout within a transaction.
58
- * PostgreSQL uses `SET LOCAL statement_timeout = $1`.
59
- * CockroachDB uses `SET transaction_timeout = $1` (v23.1+).
58
+ * PostgreSQL uses `SELECT set_config('statement_timeout', $1, true)`.
59
+ * CockroachDB uses `SELECT set_config('transaction_timeout', $1, true)` (v23.1+).
60
+ * (`SET LOCAL ... = $1` is a syntax error — SET takes no bind params.)
60
61
  *
61
62
  * @param seconds — timeout in seconds
62
63
  * @returns an object with the parameterized SQL and its bound values
@@ -167,6 +167,15 @@ function failMissingTsLoader(filePath, reason) {
167
167
  console.log(` ${(0, ui_js_1.dim)('Your Node.js version does not support')} ${(0, ui_js_1.cyan)('module.register()')}.`);
168
168
  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.')}`);
169
169
  }
170
+ else if (reason === 'failed') {
171
+ // tsx IS installed but registering its loader threw. Report the real
172
+ // cause — telling the user to install tsx here would be a misdiagnosis.
173
+ console.log(` ${(0, ui_js_1.dim)('tsx is installed, but registering its TypeScript loader failed:')}`);
174
+ (0, ui_js_1.newline)();
175
+ console.log(` ${(0, loader_js_1.getTsLoaderError)() ?? '(unknown error)'}`);
176
+ (0, ui_js_1.newline)();
177
+ console.log(` ${(0, ui_js_1.dim)('Try upgrading tsx:')} ${(0, ui_js_1.cyan)('npm install --save-dev tsx@latest')}${(0, ui_js_1.dim)(', or rename your file to')} ${(0, ui_js_1.cyan)('.mjs')}.`);
178
+ }
170
179
  else {
171
180
  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.')}`);
172
181
  (0, ui_js_1.newline)();
@@ -210,7 +219,7 @@ async function loadSchemaFile(schemaFile) {
210
219
  // ERR_UNKNOWN_FILE_EXTENSION for `.ts`.
211
220
  if ((0, loader_js_1.needsTsLoader)(absPath)) {
212
221
  const status = await (0, loader_js_1.registerTsLoader)();
213
- if (status === 'missing' || status === 'unsupported') {
222
+ if (status === 'missing' || status === 'unsupported' || status === 'failed') {
214
223
  failMissingTsLoader(schemaFile, status);
215
224
  }
216
225
  }
@@ -346,7 +355,7 @@ export default defineSchema({
346
355
  // id: { type: 'serial', primaryKey: true },
347
356
  // email: { type: 'text', notNull: true, unique: true },
348
357
  // name: { type: 'text', notNull: true },
349
- // created_at: { type: 'timestamptz', default: 'NOW()' },
358
+ // created_at: { type: 'timestamp', default: 'NOW()' },
350
359
  // },
351
360
  });
352
361
  `, 'utf-8');
@@ -409,6 +418,9 @@ export default defineSchema({
409
418
  console.log(` ${(0, ui_js_1.dim)('or create a')} ${(0, ui_js_1.cyan)('.env')} ${(0, ui_js_1.dim)('file with')} ${(0, ui_js_1.cyan)('DATABASE_URL=postgres://...')}`);
410
419
  }
411
420
  console.log(` ${(0, ui_js_1.dim)('2.')} Run ${(0, ui_js_1.cyan)('npx turbine generate')} to introspect your DB`);
421
+ if (!(0, loader_js_1.canResolveTsx)()) {
422
+ console.log(` ${(0, ui_js_1.dim)('Note: the TypeScript config requires')} ${(0, ui_js_1.cyan)('tsx')} ${(0, ui_js_1.dim)('—')} ${(0, ui_js_1.cyan)('npm install --save-dev tsx')}`);
423
+ }
412
424
  }
413
425
  else {
414
426
  console.log(` ${(0, ui_js_1.dim)('1.')} Import the generated client:`);
@@ -1152,13 +1164,15 @@ function showMigrateHelp() {
1152
1164
  (0, ui_js_1.newline)();
1153
1165
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
1154
1166
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
1167
+ console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff ${(0, ui_js_1.dim)('(create only)')}`);
1155
1168
  console.log(` ${(0, ui_js_1.cyan)('--step, -n')} ${(0, ui_js_1.dim)('<N>')} Number of migrations to apply/rollback`);
1156
- console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
1157
- console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation ${(0, ui_js_1.dim)('(migrate up only — advanced)')}`);
1158
- console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
1169
+ console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
1170
+ console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation ${(0, ui_js_1.dim)('(migrate up only — advanced)')}`);
1171
+ console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
1159
1172
  (0, ui_js_1.newline)();
1160
1173
  console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
1161
1174
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create add_users_table`);
1175
+ console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create add_email_index --auto`);
1162
1176
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate up`);
1163
1177
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate down --step 2`);
1164
1178
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate status`);
@@ -1203,16 +1217,17 @@ function showHelp() {
1203
1217
  (0, ui_js_1.newline)();
1204
1218
  console.log(` ${(0, ui_js_1.bold)('Commands:')}`);
1205
1219
  console.log(` ${(0, ui_js_1.cyan)('init')} Initialize a Turbine project`);
1206
- console.log(` ${(0, ui_js_1.cyan)('generate')} ${(0, ui_js_1.dim)('| pull')} Introspect database ${ui_js_1.symbols.arrow} generate types`);
1220
+ console.log(` ${(0, ui_js_1.cyan)('generate')} ${(0, ui_js_1.dim)('| pull')} Introspect database ${ui_js_1.symbols.arrow} generate types`);
1207
1221
  console.log(` ${(0, ui_js_1.cyan)('push')} Apply schema definitions to database`);
1208
- console.log(` ${(0, ui_js_1.cyan)('migrate')} ${(0, ui_js_1.dim)('<sub>')} SQL migration management`);
1209
- console.log(` ${(0, ui_js_1.dim)('create <name>')} Create a new migration file`);
1222
+ console.log(` ${(0, ui_js_1.cyan)('migrate')} ${(0, ui_js_1.dim)('<sub>')} SQL migration management`);
1223
+ console.log(` ${(0, ui_js_1.dim)('create <name>')} Create a new migration file`);
1210
1224
  console.log(` ${(0, ui_js_1.dim)('up')} Apply pending migrations`);
1211
1225
  console.log(` ${(0, ui_js_1.dim)('down')} Rollback last migration`);
1212
1226
  console.log(` ${(0, ui_js_1.dim)('status')} Show applied/pending migrations`);
1213
1227
  console.log(` ${(0, ui_js_1.cyan)('seed')} Run seed file`);
1214
- console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
1228
+ console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
1215
1229
  console.log(` ${(0, ui_js_1.cyan)('studio')} Launch local read-only web UI`);
1230
+ console.log(` ${(0, ui_js_1.cyan)('observe')} Launch metrics dashboard ${(0, ui_js_1.dim)('(requires TURBINE_OBSERVE_URL)')}`);
1216
1231
  (0, ui_js_1.newline)();
1217
1232
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
1218
1233
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
@@ -1224,8 +1239,13 @@ function showHelp() {
1224
1239
  console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
1225
1240
  console.log(` ${(0, ui_js_1.cyan)('--force, -f')} Overwrite existing files`);
1226
1241
  (0, ui_js_1.newline)();
1227
- console.log(` ${(0, ui_js_1.bold)('Studio options:')}`);
1228
- console.log(` ${(0, ui_js_1.cyan)('--port')} ${(0, ui_js_1.dim)('<n>')} HTTP port ${(0, ui_js_1.dim)('(default: 4983)')}`);
1242
+ console.log(` ${(0, ui_js_1.bold)('Migrate options:')}`);
1243
+ console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff ${(0, ui_js_1.dim)('(create)')}`);
1244
+ console.log(` ${(0, ui_js_1.cyan)('--step, -n')} ${(0, ui_js_1.dim)('<N>')} Number of migrations to apply/rollback`);
1245
+ 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)')}`);
1246
+ (0, ui_js_1.newline)();
1247
+ console.log(` ${(0, ui_js_1.bold)('Studio / observe options:')}`);
1248
+ console.log(` ${(0, ui_js_1.cyan)('--port')} ${(0, ui_js_1.dim)('<n>')} HTTP port ${(0, ui_js_1.dim)('(default: 4983 studio, 4984 observe)')}`);
1229
1249
  console.log(` ${(0, ui_js_1.cyan)('--host')} ${(0, ui_js_1.dim)('<addr>')} Bind address ${(0, ui_js_1.dim)('(default: 127.0.0.1)')}`);
1230
1250
  console.log(` ${(0, ui_js_1.cyan)('--no-open')} Don't auto-open the browser`);
1231
1251
  (0, ui_js_1.newline)();
@@ -1249,7 +1269,17 @@ function showVersion() {
1249
1269
  // Using process.argv[1] instead of import.meta.url so the same code compiles
1250
1270
  // cleanly for both the ESM and CJS builds.
1251
1271
  try {
1252
- let dir = (0, node_path_1.dirname)(process.argv[1] ?? '');
1272
+ // Resolve symlinks first: `npx turbine` runs via node_modules/.bin/turbine,
1273
+ // a symlink whose dirname would walk the CONSUMER's tree and never find
1274
+ // turbine-orm's package.json (printing no version number at all).
1275
+ let entry = process.argv[1] ?? '';
1276
+ try {
1277
+ entry = (0, node_fs_1.realpathSync)(entry);
1278
+ }
1279
+ catch {
1280
+ // keep the raw path if realpath fails (e.g. deleted cwd)
1281
+ }
1282
+ let dir = (0, node_path_1.dirname)(entry);
1253
1283
  for (let i = 0; i < 6; i++) {
1254
1284
  const candidate = (0, node_path_1.resolve)(dir, 'package.json');
1255
1285
  if ((0, node_fs_1.existsSync)(candidate)) {
@@ -1297,7 +1327,7 @@ async function main() {
1297
1327
  const configPath = (0, config_js_1.findConfigFile)();
1298
1328
  if ((0, loader_js_1.needsTsLoader)(configPath)) {
1299
1329
  const status = await (0, loader_js_1.registerTsLoader)();
1300
- if (status === 'missing' || status === 'unsupported') {
1330
+ if (status === 'missing' || status === 'unsupported' || status === 'failed') {
1301
1331
  failMissingTsLoader(configPath ?? 'turbine.config.ts', status);
1302
1332
  }
1303
1333
  }
@@ -9,9 +9,18 @@
9
9
  *
10
10
  * Strategy:
11
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`.
12
+ * probe whether `tsx` is resolvable from the user's CWD.
13
+ * 2. Prefer tsx's supported programmatic API, `tsx/esm/api`'s `register()`.
14
+ * Calling Node's `module.register('tsx/esm', ...)` directly throws
15
+ * "tsx must be loaded with --import instead of --loader" on every Node
16
+ * version that has `module.register()` (>= 20.6) — tsx's hook file
17
+ * guards against being loaded that way. The `tsx/esm/api` entry point
18
+ * is the documented path and works everywhere `module.register()` does.
19
+ * 3. Fall back to `module.register('tsx/esm', ...)` only for very old tsx
20
+ * versions (< 4.0) that predate `tsx/esm/api`.
21
+ * 4. If tsx isn't installed, or registration genuinely fails, surface an
22
+ * actionable error — including the REAL underlying error message, never
23
+ * a misdiagnosed "tsx is not installed".
15
24
  *
16
25
  * `tsx` is intentionally NOT a runtime dependency — many projects already
17
26
  * have it, and adding a heavy dev tool to a 1-dependency ORM would be silly.
@@ -52,6 +61,7 @@ var __importStar = (this && this.__importStar) || (function () {
52
61
  Object.defineProperty(exports, "__esModule", { value: true });
53
62
  exports.needsTsLoader = needsTsLoader;
54
63
  exports.canResolveTsx = canResolveTsx;
64
+ exports.getTsLoaderError = getTsLoaderError;
55
65
  exports.registerTsLoader = registerTsLoader;
56
66
  exports._resetTsLoaderStateForTests = _resetTsLoaderStateForTests;
57
67
  const node_module_1 = require("node:module");
@@ -89,6 +99,14 @@ function canResolveTsx(resolver) {
89
99
  }
90
100
  }
91
101
  let tsLoaderState = null;
102
+ let tsLoaderError = null;
103
+ /**
104
+ * The underlying error message from the last failed registration attempt,
105
+ * or null. Lets the CLI report the REAL cause instead of guessing.
106
+ */
107
+ function getTsLoaderError() {
108
+ return tsLoaderError;
109
+ }
92
110
  /**
93
111
  * Register the tsx ESM loader so subsequent dynamic imports of `.ts` files
94
112
  * work. Safe to call multiple times — internal flag prevents double registration.
@@ -96,17 +114,51 @@ let tsLoaderState = null;
96
114
  * Returns:
97
115
  * - 'registered' loader was successfully registered this call
98
116
  * - 'already' a loader was previously registered (idempotent)
99
- * - 'unsupported' Node lacks `module.register()` (Node < 20.6)
117
+ * - 'unsupported' Node lacks `module.register()` (Node < 20.6) and tsx has
118
+ * no programmatic API to fall back to
100
119
  * - 'missing' `tsx` is not installed in the user's project
120
+ * - 'failed' tsx IS installed but registration threw — see
121
+ * {@link getTsLoaderError} for the underlying message
101
122
  */
102
123
  async function registerTsLoader() {
103
124
  if (tsLoaderState === 'registered' || tsLoaderState === 'already') {
104
125
  return 'already';
105
126
  }
127
+ const userRequire = (0, node_module_1.createRequire)(`${process.cwd()}/`);
128
+ // Preferred: tsx's supported programmatic API (tsx >= 4.0).
129
+ let apiPath = null;
130
+ try {
131
+ apiPath = userRequire.resolve('tsx/esm/api');
132
+ }
133
+ catch {
134
+ apiPath = null;
135
+ }
136
+ if (apiPath) {
137
+ try {
138
+ const api = (await Promise.resolve(`${(0, node_url_1.pathToFileURL)(apiPath).href}`).then(s => __importStar(require(s))));
139
+ if (typeof api.register !== 'function') {
140
+ throw new Error(`tsx/esm/api resolved at ${apiPath} but exports no register() function`);
141
+ }
142
+ api.register();
143
+ tsLoaderState = 'registered';
144
+ tsLoaderError = null;
145
+ return 'registered';
146
+ }
147
+ catch (err) {
148
+ tsLoaderState = 'failed';
149
+ tsLoaderError = err instanceof Error ? err.message : String(err);
150
+ return 'failed';
151
+ }
152
+ }
153
+ // tsx/esm/api not resolvable — is tsx installed at all?
106
154
  if (!canResolveTsx()) {
107
155
  tsLoaderState = 'missing';
108
156
  return 'missing';
109
157
  }
158
+ // Legacy fallback for tsx < 4.0 (no tsx/esm/api): Node's module.register.
159
+ // On tsx >= 4.19 this path throws ("tsx must be loaded with --import
160
+ // instead of --loader") — but those versions all ship tsx/esm/api, so we
161
+ // only land here for genuinely old installs.
110
162
  try {
111
163
  const mod = await Promise.resolve().then(() => __importStar(require('node:module')));
112
164
  const register = mod.register;
@@ -116,14 +168,17 @@ async function registerTsLoader() {
116
168
  }
117
169
  register('tsx/esm', (0, node_url_1.pathToFileURL)(`${process.cwd()}/`));
118
170
  tsLoaderState = 'registered';
171
+ tsLoaderError = null;
119
172
  return 'registered';
120
173
  }
121
- catch {
122
- tsLoaderState = 'missing';
123
- return 'missing';
174
+ catch (err) {
175
+ tsLoaderState = 'failed';
176
+ tsLoaderError = err instanceof Error ? err.message : String(err);
177
+ return 'failed';
124
178
  }
125
179
  }
126
180
  /** Reset the loader state — used by unit tests only. */
127
181
  function _resetTsLoaderStateForTests() {
128
182
  tsLoaderState = null;
183
+ tsLoaderError = null;
129
184
  }