turbine-orm 0.32.0 → 0.32.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
@@ -25,27 +25,28 @@ See [How It Works](#how-it-works) for the `json_agg` query strategy itself — b
25
25
 
26
26
  ## Benchmarks
27
27
 
28
- 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. _Measured April 2026 on turbine-orm 0.7.1; the core read path these scenarios exercise is unchanged through 0.17.0 see [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md) to reproduce._
28
+ Tested against **Prisma 7.6** (adapter-pg, relationJoins preview on) and **Drizzle 0.45** (relational queries) on a **local PostgreSQL 17.9** database over a Unix socket. 200 iterations, 20 warmup, Node v24. Same schema, same data (1K users, 10K posts, 50K comments), same connection pool config. _Measured 2026-07-14 on turbine-orm 0.32.0 (Apple Silicon MacBook Pro, macOS). A local socket has no network round-trip, so these numbers are sub-millisecond and are **not** comparable to the earlier pooled-Neon table: they isolate per-query overhead instead of hiding it behind ~35 ms of network latency. See [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md) to reproduce._
29
29
 
30
30
  | Scenario | Turbine | Prisma 7 | Drizzle 0.45 |
31
31
  |---|---|---|---|
32
- | findMany 100 users (flat) | **51.97 ms** | 52.90 ms | 53.51 ms |
33
- | findMany 50 users + posts (L2) | **55.84 ms** | 56.10 ms | 88.80 ms |
34
- | findMany 10 users → posts → comments (L3) | 52.77 ms | 59.35 ms | **52.38 ms** |
35
- | findUnique single user by PK | **47.66 ms** | 52.15 ms | 47.78 ms |
36
- | findUnique user + posts + comments (L3) | **51.71 ms** | 54.42 ms | 52.47 ms |
37
- | count all users | **44.57 ms** | 47.54 ms | 46.75 ms |
38
- | stream iterate 50K rows (batch 1000) | 3,207 ms | **3,099 ms** | 4,620 ms |
39
- | atomic increment `view_count + 1` | 49.76 ms | 49.09 ms | **46.25 ms** |
40
- | pipeline 5-query batch | 318 ms | 327 ms | **316 ms** |
32
+ | findMany, 100 users (flat) | **0.22 ms** | 0.53 ms | 0.34 ms |
33
+ | findMany, 50 users + posts (L2) | 2.41 ms | 4.63 ms | **1.82 ms** |
34
+ | findMany, 10 users → posts → comments (L3) | 1.13 ms | 3.69 ms | **1.01 ms** |
35
+ | findUnique, single user by PK | **0.06 ms** | 0.11 ms | 0.09 ms |
36
+ | findUnique, user + posts + comments (L3) | **0.18 ms** | 0.43 ms | 0.30 ms |
37
+ | count, all users | **0.06 ms** | 0.08 ms | 0.07 ms |
38
+ | stream, iterate 50K rows (batch 1000) | 58.6 ms | 69.7 ms | **48.9 ms** |
39
+ | atomic increment, `view_count + 1` | 0.13 ms | 0.23 ms | **0.11 ms** |
40
+ | pipeline, 5-query batch | **0.20 ms** | 0.61 ms | 0.58 ms |
41
+ | hot findUnique, 500x same shape | **0.05 ms** | 0.09 ms | 0.10 ms |
41
42
 
42
- **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:
43
+ **Over a local socket the network floor disappears, so per-query overhead becomes the whole signal.** The picture that emerges (stable across two full runs):
43
44
 
44
- - **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.
45
- - **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.
46
- - **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.
45
+ - **Turbine leads flat reads, findUnique, count, pipeline, and the hot path.** SQL template caching and prepared statements keep its per-call overhead lowest on simple and repeated-shape queries, and its real Postgres pipeline protocol (one TCP flush for 5 queries) runs the dashboard batch ~3x faster than Prisma's or Drizzle's sequential transaction.
46
+ - **Drizzle leads nested reads (L2), streaming, and atomic increment.** Its relational query builder emits tighter SQL for the posts/comments joins, and its keyset pagination drains 50K rows fastest. Turbine's `json_agg` nesting is close behind and still 1.9x to 3.3x ahead of Prisma on the same L2/L3 shapes. L3 is a genuine Turbine/Drizzle near-tie that flips between runs.
47
+ - **Prisma trails on every scenario here.** Its engine-less client's per-query work is no longer masked by network latency; on a pooled remote database (the regime we measured previously) these same deltas compress back into the noise floor.
47
48
 
48
- Performance is at parity with Prisma and Drizzle the real reasons to choose Turbine are elsewhere: **one dependency and no WASM** (vs Prisma 7's ~1.6 MB TypeScript/WASM query compiler), the **only read-only Studio** in the TS ORM ecosystem, **PII-safe error messages** that never leak user data, and **SQL-first migrations** with SHA-256 drift detection. Deep type inference through `with` clauses works end-to-end: write `db.users.findMany({ with: { posts: { with: { comments: true } } } })` and `users[0].posts[0].comments[0].body` autocompletes no manual assertion, no helper annotation.
49
+ Net: on a local socket Turbine wins 6 of 10 scenarios, loses L2 / streaming / atomic increment to Drizzle, and trades the L3 lead run-to-run. It is competitive-to-ahead across the board rather than a clean sweep, and the honest takeaway is unchanged: performance is close enough that the real reasons to choose Turbine are elsewhere. **One dependency and no WASM** (vs Prisma 7's ~1.6 MB TypeScript/WASM query compiler), the **only read-only Studio** in the TS ORM ecosystem, **PII-safe error messages** that never leak user data, and **SQL-first migrations** with SHA-256 drift detection. Deep type inference through `with` clauses works end-to-end: write `db.users.findMany({ with: { posts: { with: { comments: true } } } })` and `users[0].posts[0].comments[0].body` autocompletes, with no manual assertion and no helper annotation.
49
50
 
50
51
  > Full analysis with p50/p95/p99 and methodology notes: [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md).
51
52
  > Reproduce: `cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench.ts`
@@ -64,16 +65,22 @@ npx turbine init --url postgres://user:pass@localhost:5432/mydb
64
65
  npx turbine generate
65
66
  ```
66
67
 
67
- > **CLI prerequisites.** The `turbine` CLI loads your `turbine.config.ts` / `turbine/schema.ts` directly, so a fresh project needs (1) `tsx` installed otherwise `.ts` config loading fails with *"Loading .ts config / schema files requires tsx to be installed"* and (2) `"type": "module"` in `package.json`, since Turbine is ESM. Without it you'll hit `Error [ERR_REQUIRE_ESM]: Cannot require() ES Module`. `create-next-app` sets neither by default. See [USING-TURBINE-ORM.md §0](docs/USING-TURBINE-ORM.md) for details.
68
+ > **CLI prerequisites.** The `turbine` CLI loads your `turbine.config.ts` / `turbine/schema.ts` directly, so a fresh project needs `tsx` installed (otherwise `.ts` config loading fails with *"Loading .ts config / schema files requires tsx to be installed"*). Turbine ships both ESM and CommonJS builds, so the CLI loads your config and schema correctly in either an ESM (`"type": "module"`) or a CommonJS project; ESM is recommended but not required. See [USING-TURBINE-ORM.md §0](docs/USING-TURBINE-ORM.md) for details.
68
69
 
69
- Works with both ESM and CommonJS:
70
+ The `turbine-orm` package ships real dual builds, so importing the package works from either module system:
70
71
 
71
72
  ```typescript
72
73
  // ESM
73
- import { turbine } from './generated/turbine';
74
+ import { turbine } from 'turbine-orm';
74
75
 
75
76
  // CommonJS
76
- const { turbine } = require('./generated/turbine');
77
+ const { turbine } = require('turbine-orm');
78
+ ```
79
+
80
+ The generated client (`./generated/turbine/`) is TypeScript source: it re-exports across files with ESM-style `./metadata.js` specifiers, so you consume it through your bundler, `tsx`, or `tsc` like the rest of your app:
81
+
82
+ ```typescript
83
+ import { turbine } from './generated/turbine';
77
84
  ```
78
85
 
79
86
  This introspects your database and generates a fully-typed client at `./generated/turbine/`.
@@ -601,12 +608,14 @@ Commands:
601
608
  migrate create <name> Create a new SQL migration file
602
609
  migrate create <name> --auto Auto-generate from schema diff
603
610
  migrate up Apply pending migrations
611
+ migrate deploy Apply pending migrations without prompts
604
612
  migrate down Rollback last migration
605
613
  migrate status Show applied/pending migrations
606
614
  seed Run seed file
607
615
  status Show database schema summary
608
616
  doctor Check relations for missing FK indexes (--fix emits migration)
609
617
  studio Launch local read-only Studio web UI
618
+ mcp Start read-only MCP server over JSON-RPC stdio
610
619
  observe Launch local metrics dashboard (requires TURBINE_OBSERVE_URL)
611
620
 
612
621
  Options:
@@ -40,6 +40,9 @@ var __importStar = (this && this.__importStar) || (function () {
40
40
  })();
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.looksLikeSchemaFilePath = looksLikeSchemaFilePath;
43
+ exports.unwrapModuleDefault = unwrapModuleDefault;
44
+ exports.unwrapConfigModule = unwrapConfigModule;
45
+ exports.loadConfigResult = loadConfigResult;
43
46
  exports.loadConfig = loadConfig;
44
47
  exports.findConfigFile = findConfigFile;
45
48
  exports.resolveConfig = resolveConfig;
@@ -66,15 +69,66 @@ function looksLikeSchemaFilePath(schema) {
66
69
  // ---------------------------------------------------------------------------
67
70
  const CONFIG_FILES = ['turbine.config.ts', 'turbine.config.mts', 'turbine.config.js', 'turbine.config.mjs'];
68
71
  const DEFAULT_SEED_CANDIDATES = ['seed.ts', 'seed.js', 'seed.sql'];
69
- // ---------------------------------------------------------------------------
70
- // Load config
71
- // ---------------------------------------------------------------------------
72
+ function isPlainObject(value) {
73
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
74
+ }
72
75
  /**
73
- * Attempt to load a turbine config file from the current directory.
74
- * Returns the config if found, or an empty object.
76
+ * True when `value` is a pure ESM/CJS-interop wrapper: an object whose only
77
+ * meaningful export is `default` (the `__esModule` marker is ignored). No
78
+ * Turbine config field is named `default`, so a real config never matches.
75
79
  */
76
- async function loadConfig(cwd) {
80
+ function isPureDefaultWrapper(value) {
81
+ const keys = Object.keys(value).filter((k) => k !== '__esModule');
82
+ return keys.length === 1 && keys[0] === 'default';
83
+ }
84
+ /**
85
+ * Unwrap the module object returned by `import(configFile)` down to the actual
86
+ * config value.
87
+ *
88
+ * With `"type": "commonjs"` in the consumer's package.json (the `npm init -y`
89
+ * default) plus the tsx loader, importing `turbine.config.ts` yields a
90
+ * CJS-interop DOUBLE-wrapped default: `mod.default` is itself `{ default: config }`.
91
+ * A naive `mod.default ?? mod` then reads every field as `undefined`, so every
92
+ * command fails with a misleading "No database URL provided".
93
+ *
94
+ * This prefers `default` when present (the historical behavior) and then keeps
95
+ * descending through any additional pure `{ default: … }` wrappers, so both the
96
+ * correct single-default shape and the double-wrapped shape resolve to the same
97
+ * config. A genuine config object (which has real fields, never a lone
98
+ * `default`) is returned untouched.
99
+ */
100
+ function unwrapModuleDefault(mod) {
101
+ // Step 1: prefer `default` at the top level (mirrors `mod.default ?? mod`).
102
+ let value = isPlainObject(mod) && mod.default != null ? mod.default : mod;
103
+ // Step 2: peel off any further pure interop wrappers, bounded to avoid a
104
+ // pathological self-referential object spinning forever.
105
+ for (let depth = 0; depth < 10 && isPlainObject(value) && isPureDefaultWrapper(value); depth++) {
106
+ value = value.default;
107
+ }
108
+ return value;
109
+ }
110
+ /**
111
+ * {@link unwrapModuleDefault} specialized for config files: a non-object export
112
+ * collapses to `{}` so downstream resolution falls through to env vars/flags.
113
+ */
114
+ function unwrapConfigModule(mod) {
115
+ const value = unwrapModuleDefault(mod);
116
+ return isPlainObject(value) ? value : {};
117
+ }
118
+ /**
119
+ * Attempt to load a turbine config file from the given directory, returning the
120
+ * resolved config together with any load failure so the caller can surface it.
121
+ *
122
+ * Candidates are tried in {@link CONFIG_FILES} priority order. The first one
123
+ * that imports successfully wins. If a candidate exists but throws (syntax
124
+ * error, ESM/CJS interop failure, etc.) we remember the first such error and
125
+ * keep trying lower-priority candidates; if none load, the remembered error is
126
+ * returned in `loadError` while `config` stays `{}` so env/flag resolution can
127
+ * still proceed.
128
+ */
129
+ async function loadConfigResult(cwd) {
77
130
  const dir = cwd ?? process.cwd();
131
+ let loadError;
78
132
  for (const filename of CONFIG_FILES) {
79
133
  const filePath = (0, node_path_1.join)(dir, filename);
80
134
  if (!(0, node_fs_1.existsSync)(filePath))
@@ -82,21 +136,27 @@ async function loadConfig(cwd) {
82
136
  try {
83
137
  const absPath = (0, node_path_1.resolve)(filePath);
84
138
  const fileUrl = (0, node_url_1.pathToFileURL)(absPath).href;
85
- // For .ts files, we need to rely on Node's --experimental-strip-types
86
- // or the tsx loader. Dynamic import handles .js/.mjs natively.
139
+ // For .ts files, we rely on the tsx loader being registered by the CLI
140
+ // before this runs. Dynamic import handles .js/.mjs natively.
87
141
  const mod = await Promise.resolve(`${fileUrl}`).then(s => __importStar(require(s)));
88
- const config = mod.default ?? mod;
89
- return config;
142
+ return { config: unwrapConfigModule(mod) };
90
143
  }
91
144
  catch (err) {
92
- // If importing a .ts file fails, try the next one
93
- if (filename.endsWith('.ts') || filename.endsWith('.mts')) {
94
- continue;
95
- }
96
- throw new Error(`Failed to load config from ${filename}: ${err instanceof Error ? err.message : String(err)}`);
145
+ // Remember the first real load failure but keep trying lower-priority
146
+ // candidates (e.g. a working .js next to a broken .ts).
147
+ if (!loadError)
148
+ loadError = { filename, error: err };
97
149
  }
98
150
  }
99
- return {};
151
+ return loadError ? { config: {}, loadError } : { config: {} };
152
+ }
153
+ /**
154
+ * Attempt to load a turbine config file from the current directory.
155
+ * Returns the config if found, or an empty object. Load failures are swallowed
156
+ * here; callers that need to surface them should use {@link loadConfigResult}.
157
+ */
158
+ async function loadConfig(cwd) {
159
+ return (await loadConfigResult(cwd)).config;
100
160
  }
101
161
  /**
102
162
  * Find the config file path (for display purposes).
@@ -59,6 +59,9 @@ var __importStar = (this && this.__importStar) || (function () {
59
59
  })();
60
60
  Object.defineProperty(exports, "__esModule", { value: true });
61
61
  exports.parseArgs = parseArgs;
62
+ exports.loadDotEnvForCli = loadDotEnvForCli;
63
+ exports.dotEnvUrlConflictWarning = dotEnvUrlConflictWarning;
64
+ exports.detectConsumerModuleType = detectConsumerModuleType;
62
65
  exports.buildMigrateDeployOptions = buildMigrateDeployOptions;
63
66
  exports.getSeedExecutionPlan = getSeedExecutionPlan;
64
67
  exports.isLoopbackHost = isLoopbackHost;
@@ -230,7 +233,9 @@ function requireUrl(config) {
230
233
  (0, ui_js_1.newline)();
231
234
  console.log(` ${(0, ui_js_1.dim)('Set it in one of these ways:')}`);
232
235
  console.log(` ${(0, ui_js_1.dim)('1.')} Add ${(0, ui_js_1.cyan)('url')} to ${(0, ui_js_1.cyan)('turbine.config.ts')}`);
233
- console.log(` ${(0, ui_js_1.dim)('2.')} Set ${(0, ui_js_1.cyan)('DATABASE_URL')} environment variable`);
236
+ // .env auto-load needs Node 20.12+ (process.loadEnvFile); be honest below it.
237
+ const envFileNote = typeof process.loadEnvFile === 'function' ? '(auto-loaded)' : '(needs Node 20.12+ to auto-load)';
238
+ console.log(` ${(0, ui_js_1.dim)('2.')} Set ${(0, ui_js_1.cyan)('DATABASE_URL')} in your environment or a ${(0, ui_js_1.cyan)('.env')} file ${(0, ui_js_1.dim)(envFileNote)}`);
234
239
  console.log(` ${(0, ui_js_1.dim)('3.')} Pass ${(0, ui_js_1.cyan)('--url')} flag`);
235
240
  (0, ui_js_1.newline)();
236
241
  process.exit(1);
@@ -256,8 +261,12 @@ async function loadSchemaFile(schemaFile) {
256
261
  try {
257
262
  const fileUrl = (0, node_url_1.pathToFileURL)(absPath).href;
258
263
  const mod = await Promise.resolve(`${fileUrl}`).then(s => __importStar(require(s)));
259
- const schema = mod.default ?? mod;
260
- if (!schema.tables) {
264
+ // Unwrap the same CJS-interop double-wrapped default that bites config files
265
+ // in a "type": "commonjs" project under the tsx loader (see
266
+ // unwrapModuleDefault). Without this, `mod.default ?? mod` reads
267
+ // `{ default: schemaDef }` and `.tables` is undefined.
268
+ const schema = (0, config_js_1.unwrapModuleDefault)(mod);
269
+ if (!schema?.tables) {
261
270
  (0, ui_js_1.error)('Schema file must export a SchemaDef with a "tables" property.');
262
271
  process.exit(1);
263
272
  }
@@ -294,29 +303,151 @@ function printCjsHintIfApplicable(err) {
294
303
  console.log(` ${(0, ui_js_1.dim)('Turbine is an ESM package; without it, Node/tsx tries to')} ${(0, ui_js_1.cyan)('require()')} ${(0, ui_js_1.dim)('it and fails.')}`);
295
304
  }
296
305
  }
306
+ /**
307
+ * Load a local `.env` into `process.env` for the CLI, mirroring what
308
+ * `node --env-file=.env` does. Loaded UNCONDITIONALLY when a `.env` is present,
309
+ * so every variable it defines (not just `DATABASE_URL`) reaches the config
310
+ * file and user scripts.
311
+ *
312
+ * A pre-existing variable ALWAYS wins: `process.loadEnvFile()` never overrides
313
+ * an already-set variable, so a real shell/CI `DATABASE_URL` beats the file.
314
+ * Provenance is tracked so callers can warn when an `.env`-sourced
315
+ * `DATABASE_URL` silently overrides a differing `url` in `turbine.config.ts`:
316
+ * `DATABASE_URL` is `'dotenv'`-sourced only when it was absent before the load
317
+ * and present after.
318
+ *
319
+ * `process.loadEnvFile` is Node 20.12+. Turbine's engines allow `>=20.0.0`, so
320
+ * on older runtimes this no-ops with `unsupported: true` (never throws). A
321
+ * loader that throws (unreadable file, a directory named `.env`) is caught and
322
+ * surfaced as `loadError`, never a raw unhandled rejection. Deliberately
323
+ * CLI-only: the library must never read files.
324
+ *
325
+ * Dependencies are injectable purely so this is unit-testable without mutating
326
+ * the real process environment.
327
+ */
328
+ function loadDotEnvForCli(deps = {}) {
329
+ const env = deps.env ?? process.env;
330
+ const cwd = deps.cwd ?? process.cwd();
331
+ const fileExists = deps.fileExists ?? node_fs_1.existsSync;
332
+ const envPath = (0, node_path_1.join)(cwd, '.env');
333
+ const hadUrlBefore = Boolean(env.DATABASE_URL);
334
+ const shellOrNone = hadUrlBefore ? 'shell' : 'none';
335
+ if (!fileExists(envPath)) {
336
+ return { fileExists: false, loaded: false, unsupported: false, databaseUrlProvenance: shellOrNone };
337
+ }
338
+ const loader = deps.loadEnvFile !== undefined
339
+ ? deps.loadEnvFile
340
+ : typeof process.loadEnvFile === 'function'
341
+ ? process.loadEnvFile.bind(process)
342
+ : null;
343
+ if (!loader) {
344
+ return { fileExists: true, loaded: false, unsupported: true, databaseUrlProvenance: shellOrNone };
345
+ }
346
+ try {
347
+ loader(envPath);
348
+ }
349
+ catch (err) {
350
+ return {
351
+ fileExists: true,
352
+ loaded: false,
353
+ unsupported: false,
354
+ databaseUrlProvenance: shellOrNone,
355
+ loadError: err instanceof Error ? err.message : String(err),
356
+ };
357
+ }
358
+ // `.env`-sourced only if DATABASE_URL was absent before and present after.
359
+ const provenance = hadUrlBefore ? 'shell' : env.DATABASE_URL ? 'dotenv' : 'none';
360
+ return { fileExists: true, loaded: true, unsupported: false, databaseUrlProvenance: provenance };
361
+ }
362
+ /**
363
+ * Decide whether to warn that an `.env`-sourced `DATABASE_URL` is overriding a
364
+ * differing, non-empty `url` in the config file. Pure so it is unit-testable.
365
+ *
366
+ * Precedence is unchanged (`.env` `DATABASE_URL` still wins), this only decides
367
+ * whether that override is silent or loud. We warn ONLY when all hold:
368
+ * - no CLI `--url` override (an explicit override is the user's clear intent),
369
+ * - `DATABASE_URL` came from `.env` (shell-exported stays silent, as before),
370
+ * - the config file has a non-empty `url`, and
371
+ * - the two URLs actually differ.
372
+ *
373
+ * Returns the warning message (URLs redacted), or `null` for no warning.
374
+ */
375
+ function dotEnvUrlConflictWarning(input) {
376
+ if (input.overrideUrl)
377
+ return null;
378
+ if (input.provenance !== 'dotenv')
379
+ return null;
380
+ const fileUrl = input.fileConfigUrl?.trim();
381
+ if (!fileUrl)
382
+ return null;
383
+ if (!input.envUrl)
384
+ return null;
385
+ if (fileUrl === input.envUrl)
386
+ return null;
387
+ return (`DATABASE_URL from .env (${(0, ui_js_1.redactUrl)(input.envUrl)}) is overriding the url in your config file ` +
388
+ `(${(0, ui_js_1.redactUrl)(fileUrl)}). Using the .env value. Remove DATABASE_URL from .env, or unset the config url, ` +
389
+ `to silence this.`);
390
+ }
391
+ /**
392
+ * Read the consumer's `package.json` `"type"` field. Returns `'module'` for an
393
+ * ESM project, `'commonjs'` for an explicit or absent (defaulted) CommonJS
394
+ * project, and `'none'` when there is no readable/parseable package.json.
395
+ */
396
+ function detectConsumerModuleType(cwd = process.cwd()) {
397
+ const pkgPath = (0, node_path_1.join)(cwd, 'package.json');
398
+ if (!(0, node_fs_1.existsSync)(pkgPath))
399
+ return 'none';
400
+ try {
401
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)(pkgPath, 'utf-8'));
402
+ return pkg.type === 'module' ? 'module' : 'commonjs';
403
+ }
404
+ catch {
405
+ return 'none';
406
+ }
407
+ }
297
408
  // ---------------------------------------------------------------------------
298
409
  // Command: init
299
410
  // ---------------------------------------------------------------------------
300
411
  async function cmdInit(args, config) {
301
412
  (0, ui_js_1.banner)();
302
413
  (0, ui_js_1.header)('Initializing Turbine project');
303
- // Detect environment
414
+ // Detect environment. main() has already auto-loaded a local `.env` into
415
+ // process.env (when DATABASE_URL was not otherwise set), so these messages
416
+ // describe the real, post-load state, no more "if set" hand-waving.
304
417
  const envUrl = process.env.DATABASE_URL;
305
418
  const hasEnvFile = (0, node_fs_1.existsSync)('.env');
306
419
  const hasEnvLocal = (0, node_fs_1.existsSync)('.env.local');
420
+ // On Node < 20.12 (no process.loadEnvFile) main() could not auto-load .env, so
421
+ // we cannot claim it "has no DATABASE_URL"; we simply could not read it.
422
+ const canAutoLoadEnv = typeof process.loadEnvFile === 'function';
307
423
  if (envUrl) {
308
- (0, ui_js_1.success)(`Detected ${(0, ui_js_1.cyan)('DATABASE_URL')} in environment`);
424
+ (0, ui_js_1.success)(`Detected ${(0, ui_js_1.cyan)('DATABASE_URL')} in the environment`);
309
425
  }
310
- else if (hasEnvLocal) {
311
- (0, ui_js_1.info)(`Found ${(0, ui_js_1.cyan)('.env.local')} Turbine will use ${(0, ui_js_1.cyan)('DATABASE_URL')} from it if set`);
426
+ else if (hasEnvFile && !canAutoLoadEnv) {
427
+ (0, ui_js_1.info)(`Found ${(0, ui_js_1.cyan)('.env')} ${(0, ui_js_1.dim)('(this Node version cannot auto-load it. Upgrade to Node 20.12+ or export')} ${(0, ui_js_1.cyan)('DATABASE_URL')}${(0, ui_js_1.dim)(')')}`);
312
428
  }
313
429
  else if (hasEnvFile) {
314
- (0, ui_js_1.info)(`Found ${(0, ui_js_1.cyan)('.env')} Turbine will use ${(0, ui_js_1.cyan)('DATABASE_URL')} from it if set`);
430
+ // .env exists but did not provide DATABASE_URL; if it had, the auto-load
431
+ // in main() would have populated envUrl above.
432
+ (0, ui_js_1.info)(`Found ${(0, ui_js_1.cyan)('.env')} ${(0, ui_js_1.dim)('(no')} ${(0, ui_js_1.cyan)('DATABASE_URL')} ${(0, ui_js_1.dim)('set in it yet)')}`);
433
+ }
434
+ else if (hasEnvLocal) {
435
+ (0, ui_js_1.info)(`Found ${(0, ui_js_1.cyan)('.env.local')} ${(0, ui_js_1.dim)('(note: Turbine only auto-loads')} ${(0, ui_js_1.cyan)('.env')}${(0, ui_js_1.dim)(')')}`);
315
436
  }
316
437
  else {
317
438
  (0, ui_js_1.info)(`No ${(0, ui_js_1.cyan)('DATABASE_URL')} found in environment`);
318
439
  }
319
440
  (0, ui_js_1.newline)();
441
+ // Heads-up (not an edit) about the consumer's module system. A CommonJS
442
+ // project (`npm init -y` default, or no "type" field) works fine now that the
443
+ // config loader unwraps the CJS-interop double-wrapped default, but ESM is the
444
+ // smoother path for a TypeScript config file.
445
+ const moduleType = detectConsumerModuleType();
446
+ if (moduleType === 'commonjs') {
447
+ (0, ui_js_1.info)(`Your ${(0, ui_js_1.cyan)('package.json')} is a CommonJS project ${(0, ui_js_1.dim)('(no')} ${(0, ui_js_1.cyan)('"type": "module"')}${(0, ui_js_1.dim)(').')}`);
448
+ console.log(` ${(0, ui_js_1.dim)('Turbine works either way. For the smoothest TypeScript config experience, consider adding')} ${(0, ui_js_1.cyan)('"type": "module"')}${(0, ui_js_1.dim)('.')}`);
449
+ (0, ui_js_1.newline)();
450
+ }
320
451
  const configPath = (0, config_js_1.findConfigFile)();
321
452
  // Create config file
322
453
  if (configPath && !args.force) {
@@ -1726,6 +1857,18 @@ async function main() {
1726
1857
  showVersion();
1727
1858
  return;
1728
1859
  }
1860
+ // Load a local `.env` so `DATABASE_URL` (and every other var it defines) is
1861
+ // available to the config file, to `turbine()` in user scripts, and to command
1862
+ // resolution: exactly what the quickstart promises. A pre-existing env var
1863
+ // always wins. Surfaces the honest state when the file cannot be read.
1864
+ const dotEnv = loadDotEnvForCli();
1865
+ if (dotEnv.loadError) {
1866
+ (0, ui_js_1.warn)(`Could not read ${(0, ui_js_1.cyan)('.env')}: ${dotEnv.loadError}. Continuing without it.`);
1867
+ }
1868
+ else if (dotEnv.fileExists && dotEnv.unsupported) {
1869
+ (0, ui_js_1.warn)(`Found ${(0, ui_js_1.cyan)('.env')} but this Node version cannot auto-load it. ` +
1870
+ `Upgrade to Node 20.12+ or export ${(0, ui_js_1.cyan)('DATABASE_URL')} yourself.`);
1871
+ }
1729
1872
  // If the user has a TypeScript config file, register the tsx ESM loader
1730
1873
  // before we attempt to import it. Otherwise Node throws
1731
1874
  // ERR_UNKNOWN_FILE_EXTENSION for `.ts`.
@@ -1736,17 +1879,16 @@ async function main() {
1736
1879
  failMissingTsLoader(configPath ?? 'turbine.config.ts', status);
1737
1880
  }
1738
1881
  }
1739
- // Load config file
1740
- let fileConfig = {};
1741
- try {
1742
- fileConfig = await (0, config_js_1.loadConfig)();
1743
- }
1744
- catch (err) {
1745
- if (args.command !== 'init') {
1746
- (0, ui_js_1.warn)(`Could not load config: ${err instanceof Error ? err.message : String(err)}`);
1747
- if (err instanceof Error)
1748
- printCjsHintIfApplicable(err);
1749
- }
1882
+ // Load config file. A config that exists but fails to import is surfaced
1883
+ // loudly (with a name + the underlying error) instead of being swallowed and
1884
+ // later misreported as a missing database URL.
1885
+ const { config: fileConfig, loadError } = await (0, config_js_1.loadConfigResult)();
1886
+ if (loadError && args.command !== 'init') {
1887
+ const underlying = loadError.error instanceof Error ? loadError.error.message : String(loadError.error);
1888
+ (0, ui_js_1.warn)(`Could not load ${(0, ui_js_1.cyan)(loadError.filename)}: ${underlying}`);
1889
+ if (loadError.error instanceof Error)
1890
+ printCjsHintIfApplicable(loadError.error);
1891
+ (0, ui_js_1.newline)();
1750
1892
  }
1751
1893
  const overrides = {
1752
1894
  url: args.url,
@@ -1756,6 +1898,19 @@ async function main() {
1756
1898
  exclude: args.exclude,
1757
1899
  };
1758
1900
  const config = (0, config_js_1.resolveConfig)(fileConfig, overrides);
1901
+ // Warn (don't change precedence) when an .env-sourced DATABASE_URL is silently
1902
+ // overriding a differing, non-empty url in the config file (a wrong-database
1903
+ // hazard for push/migrate/seed). Shell-exported DATABASE_URL stays silent.
1904
+ const urlConflict = dotEnvUrlConflictWarning({
1905
+ provenance: dotEnv.databaseUrlProvenance,
1906
+ envUrl: process.env.DATABASE_URL,
1907
+ fileConfigUrl: fileConfig.url,
1908
+ overrideUrl: overrides.url,
1909
+ });
1910
+ if (urlConflict && args.command !== 'init') {
1911
+ (0, ui_js_1.warn)(urlConflict);
1912
+ (0, ui_js_1.newline)();
1913
+ }
1759
1914
  try {
1760
1915
  switch (args.command) {
1761
1916
  case 'init':
@@ -394,9 +394,25 @@ class TurbineClient {
394
394
  idleTimeoutMillis: config.idleTimeoutMs ?? 30_000,
395
395
  connectionTimeoutMillis: config.connectionTimeoutMs ?? 5_000,
396
396
  };
397
+ // Did the caller supply ANY explicit connection target? If not, and a
398
+ // DATABASE_URL is present in the environment, fall back to it so
399
+ // `turbine()` with no arguments just works (the convention Prisma/Drizzle
400
+ // use, and what the generated factory JSDoc + `turbine init` promise).
401
+ // We only read the already-populated env var; the library never parses
402
+ // .env files (that is the CLI's job). An explicit host/port/db/user/pass
403
+ // still takes precedence, so this never overrides a deliberate config.
404
+ const hasExplicitConnection = config.connectionString != null ||
405
+ config.host != null ||
406
+ config.port != null ||
407
+ config.database != null ||
408
+ config.user != null ||
409
+ config.password != null;
397
410
  if (config.connectionString) {
398
411
  poolConfig.connectionString = config.connectionString;
399
412
  }
413
+ else if (!hasExplicitConnection && process.env.DATABASE_URL) {
414
+ poolConfig.connectionString = process.env.DATABASE_URL;
415
+ }
400
416
  else {
401
417
  poolConfig.host = config.host ?? 'localhost';
402
418
  poolConfig.port = config.port ?? 5432;
@@ -152,8 +152,16 @@ exports.NotFoundError = NotFoundError;
152
152
  /** Thrown when a query or transaction exceeds the configured timeout */
153
153
  class TimeoutError extends TurbineError {
154
154
  timeoutMs;
155
- constructor(timeoutMs, context = 'Query') {
156
- super(exports.TurbineErrorCode.TIMEOUT, `[turbine] ${context} timed out after ${timeoutMs}ms`);
155
+ /**
156
+ * @param timeoutMs the client-side timeout budget in ms. Pass `0` when the
157
+ * duration is unknown (e.g. a server-side `statement_timeout` cancellation
158
+ * surfaced via `wrapPgError`, where Turbine did not set the deadline).
159
+ * @param context human label for the operation ("Query", "Transaction").
160
+ * @param options optional `message` override and pg `cause` to preserve, used
161
+ * when wrapping a driver error rather than a client-side timer expiry.
162
+ */
163
+ constructor(timeoutMs, context = 'Query', options) {
164
+ super(exports.TurbineErrorCode.TIMEOUT, options?.message ?? `[turbine] ${context} timed out after ${timeoutMs}ms`, options);
157
165
  this.name = 'TimeoutError';
158
166
  this.timeoutMs = timeoutMs;
159
167
  }
@@ -169,8 +177,13 @@ class ValidationError extends TurbineError {
169
177
  exports.ValidationError = ValidationError;
170
178
  /** Thrown when a database connection fails */
171
179
  class ConnectionError extends TurbineError {
172
- constructor(message) {
173
- super(exports.TurbineErrorCode.CONNECTION, message);
180
+ /**
181
+ * @param message human-readable connection failure description.
182
+ * @param options optional pg/driver `cause` to preserve, used when wrapping a
183
+ * connection-class driver error via `wrapPgError`.
184
+ */
185
+ constructor(message, options) {
186
+ super(exports.TurbineErrorCode.CONNECTION, message, options);
174
187
  this.name = 'ConnectionError';
175
188
  }
176
189
  }
@@ -501,6 +514,35 @@ function parseColumnsFromDetail(detail) {
501
514
  return undefined;
502
515
  return m[1].split(',').map((s) => s.trim());
503
516
  }
517
+ /**
518
+ * Connection-class error codes. Covers both pg SQLSTATEs (class 08
519
+ * connection_exception, plus a few class-53/57 admin/availability codes) and
520
+ * Node driver-level error codes that arrive on the same `.code` field when the
521
+ * socket never reaches Postgres. All map to {@link ConnectionError} (E004).
522
+ *
523
+ * `57014` (query_canceled, a server-side `statement_timeout` cancellation) is
524
+ * intentionally NOT here: it maps to {@link TimeoutError} (E002) instead.
525
+ */
526
+ const CONNECTION_ERROR_CODES = new Set([
527
+ // pg SQLSTATE class 08: connection_exception
528
+ '08000', // connection_exception
529
+ '08001', // sqlclient_unable_to_establish_sqlconnection
530
+ '08003', // connection_does_not_exist
531
+ '08004', // sqlserver_rejected_establishment_of_sqlconnection
532
+ '08006', // connection_failure
533
+ '08P01', // protocol_violation
534
+ // pg SQLSTATE class 53/57 (server unavailable / shutting down)
535
+ '53300', // too_many_connections
536
+ '57P01', // admin_shutdown
537
+ '57P02', // crash_shutdown
538
+ '57P03', // cannot_connect_now
539
+ // Node driver-level socket errors (surface on err.code too)
540
+ 'ECONNREFUSED',
541
+ 'ECONNRESET',
542
+ 'ETIMEDOUT',
543
+ 'ENOTFOUND',
544
+ 'EPIPE',
545
+ ]);
504
546
  /**
505
547
  * Translate a pg driver error into a typed Turbine error.
506
548
  * If the error doesn't match a known constraint code, returns it unchanged.
@@ -513,6 +555,8 @@ function parseColumnsFromDetail(detail) {
513
555
  * 23P01 (exclusion_violation) -> ExclusionConstraintError
514
556
  * 40P01 (deadlock_detected) -> DeadlockError (retryable)
515
557
  * 40001 (serialization_failure) -> SerializationFailureError (retryable)
558
+ * 57014 (query_canceled) -> TimeoutError (server-side statement_timeout)
559
+ * connection-class codes -> ConnectionError (see CONNECTION_ERROR_CODES)
516
560
  *
517
561
  * The original pg error is preserved as `.cause` on the wrapped error.
518
562
  */
@@ -565,7 +609,21 @@ function wrapPgError(err) {
565
609
  return new SerializationFailureError({
566
610
  cause: err,
567
611
  });
612
+ case '57014':
613
+ // query_canceled: a server-side statement_timeout cancelled the query.
614
+ // Turbine did not set the deadline (that lives in Postgres config), so
615
+ // there is no client-side budget to report → timeoutMs = 0.
616
+ return new TimeoutError(0, 'Query', {
617
+ message: '[turbine] Query canceled by server-side statement_timeout',
618
+ cause: err,
619
+ });
568
620
  default:
621
+ if (CONNECTION_ERROR_CODES.has(e.code)) {
622
+ const pgMessage = typeof e.message === 'string' && e.message.length > 0 ? e.message : undefined;
623
+ return new ConnectionError(pgMessage
624
+ ? `[turbine] Database connection error: ${pgMessage}`
625
+ : `[turbine] Database connection error (${e.code})`, { cause: err });
626
+ }
569
627
  return err;
570
628
  }
571
629
  }
@@ -677,7 +677,8 @@ function generateIndex(schema, options) {
677
677
  lines.push('/**');
678
678
  lines.push(' * Create a new Turbine client instance.');
679
679
  lines.push(' *');
680
- lines.push(' * @param config - Connection configuration. Falls back to DATABASE_URL env var.');
680
+ lines.push(' * @param config - Connection configuration. Omit it (or pass no connection');
681
+ lines.push(' * fields) to fall back to the `DATABASE_URL` environment variable.');
681
682
  lines.push(' * @returns A fully-typed TurbineClient with table accessors.');
682
683
  lines.push(' */');
683
684
  lines.push('export function turbine(config?: TurbineConfig): TurbineClient {');